<?php
/**
 * Admin Meta Box for SEO Fields
 *
 * @package SEOvaultSEO
 */

namespace SEOvaultSEO\Admin;

use SEOvaultSEO\Core\PostMeta;
use SEOvaultSEO\Utils\Formatter;
use SEOvaultSEO\Utils\Sanitizer;
use SEOvaultSEO\Utils\SeoAssessment;
use SEOvaultSEO\Utils\Guidelines;
use SEOvaultSEO\Frontend\FrontendScore;
use SEOvaultSEO\Schema\Validation\PostSchemaValidationService;
use SEOvaultSEO\Schema\Validation\SchemaLiveValidationService;
use SEOvaultSEO\Schema\Validation\SchemaValidationFeatures;
use SEOvaultSEO\Schema\Validation\ValidationLimits;

/**
 * Handles SEO meta box on post/page edit screens
 */
class MetaBox {

	/**
	 * Whether init() has already run.
	 *
	 * @var bool
	 */
	private static $initialized = false;

	/**
	 * Initialize meta box
	 */
	public static function init() {
		if ( self::$initialized ) {
			return;
		}

		self::$initialized = true;

		add_action( 'add_meta_boxes', array( __CLASS__, 'add_meta_box' ) );
		add_action( 'save_post', array( __CLASS__, 'save_meta_box' ), 10, 2 );
		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
		add_action( 'wp_ajax_seovaultseo_live_assessment', array( __CLASS__, 'ajax_live_assessment' ) );
		add_action( 'wp_ajax_seovaultseo_resolve_auto', array( __CLASS__, 'ajax_resolve_auto_values' ) );
		add_action( 'wp_ajax_seovaultseo_suggest_keywords', array( __CLASS__, 'ajax_suggest_keywords' ) );
		add_action( 'wp_ajax_seovaultseo_validate_post_schema', array( __CLASS__, 'ajax_validate_post_schema' ) );
		add_action( 'wp_ajax_seovaultseo_validate_live_schema', array( __CLASS__, 'ajax_validate_live_schema' ) );
		add_action( 'wp_ajax_seovaultseo_review_schema', array( __CLASS__, 'ajax_review_schema' ) );
		add_action( 'wp_ajax_seovaultseo_schema_custom_json_format', array( __CLASS__, 'ajax_schema_custom_json_format' ) );
		add_action( 'wp_ajax_seovaultseo_schema_custom_json_preview', array( __CLASS__, 'ajax_schema_custom_json_preview' ) );
		add_action( 'wp_ajax_seovaultseo_schema_custom_json_restore', array( __CLASS__, 'ajax_schema_custom_json_restore' ) );
		add_action( 'wp_ajax_seovaultseo_content_suggestion_dismiss', array( __CLASS__, 'ajax_content_suggestion_dismiss' ) );
		add_action( 'wp_ajax_seovaultseo_content_suggestion_confirm', array( __CLASS__, 'ajax_content_suggestion_confirm' ) );
	}

	/**
	 * Enqueue meta box assets on post edit screens.
	 *
	 * @param string $hook Current admin page hook.
	 */
	public static function enqueue_assets( $hook ) {
		if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
			return;
		}

		// Elementor has its own compact SEO panel and must not load metabox UI assets.
		if ( \SEOvaultSEO\Integrations\Elementor\Bootstrap::is_editor_request() ) {
			return;
		}

		// Don't enqueue metabox JS/CSS in disabled mode (fields don't exist).
		if ( ! \SEOvaultSEO\Compatibility\Detector::should_output_seo() ) {
			return;
		}

		wp_enqueue_style(
			'seovaultseo-metabox',
			SEOVAULT_SEO_URL . 'assets/css/seo-metabox.css',
			array(),
			SEOVAULT_SEO_VERSION . '-60'
		);

		wp_enqueue_media();

		wp_enqueue_script(
			'seovaultseo-metabox',
			SEOVAULT_SEO_URL . 'assets/js/seo-metabox.js',
			array( 'jquery', 'wp-i18n', 'wp-data', 'wp-blocks', 'wp-element' ),
			SEOVAULT_SEO_VERSION . '-59',
			true
		);

		wp_set_script_translations( 'seovaultseo-metabox', 'seovault-ai', SEOVAULT_SEO_DIR . 'languages/' );

		$title_limits    = Guidelines::get_title_limits();
		$desc_limits     = Guidelines::get_description_limits();
		$og_title_limits = Guidelines::get_og_title_limits();
		$og_desc_limits  = Guidelines::get_og_description_limits();

		// Get available template variables for the picker.
		$variables = \SEOvaultSEO\ReplaceVariables\Manager::get_instance()->get_variables_for_js();

		// Get auto-generated values for the current post (for JS auto-fill).
		$current_post_id     = get_the_ID();
		$js_auto_title       = '';
		$js_auto_description = '';
		if ( $current_post_id ) {
			$js_auto_title       = Formatter::get_effective_title( $current_post_id );
			$js_auto_description = Formatter::get_effective_description( $current_post_id );
		}

		wp_localize_script(
			'seovaultseo-metabox',
			'seovaultseo_metabox',
			\SEOvaultSEO\Core\I18n::merge_metabox_script_strings(
				array(
					'title_limits'     => $title_limits,
					'desc_limits'      => $desc_limits,
					'og_title_limits'  => $og_title_limits,
					'og_desc_limits'   => $og_desc_limits,
					'ajax_url'         => admin_url( 'admin-ajax.php' ),
					'nonce'            => wp_create_nonce( 'seovaultseo_metabox_nonce' ),
					'schema_nonce'     => wp_create_nonce( 'seovaultseo_schema_health' ),
					'schema_instances_nonce' => wp_create_nonce( \SEOvaultSEO\Schema\SchemaInstanceEndpoints::NONCE_ACTION ),
					'guided_form_schemas'    => \SEOvaultSEO\Schema\SchemaGuidedFormSchema::build_catalogue(),
					'post_id'          => $current_post_id ? (int) $current_post_id : 0,
					'content_hash'     => $current_post_id ? \SEOvaultSEO\Schema\SchemaInstanceEndpoints::post_sync_meta( (int) $current_post_id )['content_hash'] : '',
					'schema_setup_bootstrap' => self::get_schema_setup_bootstrap( $current_post_id ? (int) $current_post_id : 0 ),
					'variables'        => $variables,
					'auto_title'       => $js_auto_title,
					'auto_description' => $js_auto_description,
					'cache_notice'     => CacheCompatibilityNotice::get_localize_data(),
				)
			)
		);

		CacheCompatibilityNotice::enqueue();
	}

	/**
	 * Add SEO meta box
	 */
	public static function add_meta_box() {
		$post_types = get_post_types( array( 'public' => true ), 'names' );
		$excluded   = apply_filters( 'seovaultseo_excluded_post_types', array( 'attachment' ) );

		foreach ( $post_types as $post_type ) {
			if ( in_array( $post_type, $excluded, true ) ) {
				continue;
			}

			add_meta_box(
				'seovault_seo_meta_box',
				__( 'SEOvault AI', 'seovault-ai' ),
				array( __CLASS__, 'render_meta_box' ),
				$post_type,
				'side',
				'high'
			);
		}
	}

	/**
	 * Render meta box content
	 *
	 * @param \WP_Post $post Post object
	 */
	public static function render_meta_box( $post ) {
		// Disabled mode: render warning panel instead of fields.
		if ( ! \SEOvaultSEO\Compatibility\Detector::should_output_seo() ) {
			?>
			<div class="seovault-ai-meta-box seovault-ai-meta-box-disabled">
				<div class="seovaultseo-disabled-notice">
					<span class="dashicons dashicons-warning" style="color: #d63638; font-size: 24px;"></span>
					<p><strong><?php esc_html_e( 'SEOvault AI is disabled.', 'seovault-ai' ); ?></strong></p>
					<p><?php esc_html_e( 'SEO output is currently turned off. To use SEOvault AI, activate it in the settings.', 'seovault-ai' ); ?></p>
					<a href="<?php echo esc_url( admin_url( 'admin.php?page=seovault-ai-general' ) ); ?>" class="button button-primary">
						<?php esc_html_e( 'Go to Output Mode Settings', 'seovault-ai' ); ?>
					</a>
				</div>
			</div>
			<?php
			return;
		}

		wp_nonce_field( 'seovault_seo_meta_box', 'seovault_seo_meta_box_nonce' );

		$seo_title          = PostMeta::get_seo_title( $post->ID );
		$seo_description    = PostMeta::get_seo_description( $post->ID );
		$focus_keyword      = PostMeta::get_focus_keyword( $post->ID );
		$secondary_keywords = PostMeta::get_secondary_keywords( $post->ID );
		$canonical_url      = PostMeta::get_canonical_url( $post->ID );
		$noindex            = PostMeta::is_noindex( $post->ID );
		$nofollow           = PostMeta::is_nofollow( $post->ID );
		$og_title           = PostMeta::get_og_title( $post->ID );
		$og_description     = PostMeta::get_og_description( $post->ID );
		$og_image           = PostMeta::get_og_image( $post->ID );
		$redirect_url       = PostMeta::get_redirect_url( $post->ID );
		$redirect_type      = PostMeta::get_redirect_type( $post->ID );

		// Compute auto-generated (effective) values for placeholders.
		// These use the full fallback chain: custom meta → template → raw post data.
		// We call Formatter only when the user hasn't saved a custom value, to avoid
		// showing the saved value as both the value AND the placeholder.
		// In manual mode, Formatter returns empty — no auto-generation.
		$auto_title       = '';
		$auto_description = '';

		if ( empty( $seo_title ) ) {
			$auto_title = Formatter::get_effective_title( $post->ID );
		}

		if ( empty( $seo_description ) ) {
			$auto_description = Formatter::get_effective_description( $post->ID );
		}

		$is_manual = ! \SEOvaultSEO\Compatibility\Detector::is_auto_generation();

		// Run assessment for the current post.
		$assessment = SeoAssessment::assess_post( $post->ID );

		$title_limits    = Guidelines::get_title_limits();
		$desc_limits     = Guidelines::get_description_limits();
		$og_title_limits = Guidelines::get_og_title_limits();
		$og_desc_limits  = Guidelines::get_og_description_limits();

		?>
		<div class="seovault-ai-meta-box" data-post-id="<?php echo esc_attr( (string) $post->ID ); ?>">

			<?php CacheCompatibilityNotice::render( true ); ?>

			<?php self::render_assessment_panel( $assessment ); ?>

			<?php
			self::maybe_handle_schema_setup_request( (int) $post->ID );
			$schema_health = ( new PostSchemaValidationService() )->validate_saved( $post->ID );
			if ( is_array( $schema_health ) ) {
				$schema_health['post_id'] = (int) $post->ID;
			}
			echo SchemaHealthPanel::render( $schema_health ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Renderer escapes every dynamic value.
			echo SchemaHealthPanel::render_announcer(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static escaped markup.
			echo SchemaHealthPanel::render_suggestion_dialog_shell(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static escaped markup.
			echo SchemaReviewSurface::render_shell(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Shell is static escaped markup.
			echo SchemaAddPicker::render_shell( SchemaAddPicker::build_context( (int) $post->ID ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Renderer escapes.
			?>

			<div class="seovault-ai-field">
				<label for="seovault_seo_title">
					<?php esc_html_e( 'SEO Title', 'seovault-ai' ); ?>
					<span class="seovaultseo-char-counter" data-target="seovault_seo_title" data-min="<?php echo esc_attr( (string) $title_limits['ideal_min'] ); ?>" data-max="<?php echo esc_attr( (string) $title_limits['ideal_max'] ); ?>"></span>
				</label>
				<input
					type="text"
					id="seovault_seo_title"
					name="seovault_seo_title"
					value="<?php echo esc_attr( $seo_title ); ?>"
					class="large-text seovaultseo-counted"
					placeholder="<?php echo esc_attr( $is_manual ? __( 'Enter your SEO title', 'seovault-ai' ) : ( $auto_title ?: $post->post_title ) ); ?>"
					data-counter-target="title"
				/>
				<p class="description">
					<?php if ( $is_manual ) : ?>
						<?php esc_html_e( 'Enter a custom SEO title.', 'seovault-ai' ); ?>
					<?php else : ?>
						<?php esc_html_e( 'Leave empty to use the post title.', 'seovault-ai' ); ?>
					<?php endif; ?>
				</p>
			</div>

			<div class="seovault-ai-field">
				<label for="seovault_seo_description">
					<?php esc_html_e( 'SEO Description', 'seovault-ai' ); ?>
					<span class="seovaultseo-char-counter" data-target="seovault_seo_description" data-min="<?php echo esc_attr( (string) $desc_limits['ideal_min'] ); ?>" data-max="<?php echo esc_attr( (string) $desc_limits['ideal_max'] ); ?>"></span>
				</label>
				<textarea
					id="seovault_seo_description"
					name="seovault_seo_description"
					rows="3"
					class="large-text seovaultseo-counted"
					placeholder="<?php echo esc_attr( $is_manual ? __( 'Enter your SEO description', 'seovault-ai' ) : $auto_description ); ?>"
					data-counter-target="description"
				><?php echo esc_textarea( $seo_description ); ?></textarea>
				<p class="description">
					<?php if ( $is_manual ) : ?>
						<?php esc_html_e( 'Enter a custom SEO description.', 'seovault-ai' ); ?>
					<?php else : ?>
						<?php esc_html_e( 'Leave empty to auto-generate from content.', 'seovault-ai' ); ?>
					<?php endif; ?>
				</p>
			</div>

			<div class="seovault-ai-field">
				<label for="seovault_focus_keyword">
					<?php esc_html_e( 'Focus Keyword', 'seovault-ai' ); ?>
				</label>
				<input
					type="text"
					id="seovault_focus_keyword"
					name="seovault_focus_keyword"
					value="<?php echo esc_attr( $focus_keyword ); ?>"
					class="large-text"
				/>
				<button
					type="button"
					class="button button-secondary seovaultseo-suggest-keywords-btn"
					id="seovaultseo-suggest-keywords-btn"
				>
					<?php esc_html_e( 'Suggest Keywords', 'seovault-ai' ); ?>
				</button>
				<div class="seovaultseo-keyword-suggestions" id="seovaultseo-keyword-suggestions"></div>
			</div>

			<?php self::render_serp_preview( $post, $auto_title, $auto_description ); ?>

			<details class="seovault-ai-advanced">
				<summary><?php esc_html_e( 'Advanced', 'seovault-ai' ); ?></summary>

				<div class="seovault-ai-field">
					<label for="seovault_secondary_keywords">
						<?php esc_html_e( 'Secondary Keywords', 'seovault-ai' ); ?>
					</label>
					<input
						type="text"
						id="seovault_secondary_keywords"
						name="seovault_secondary_keywords"
						value="<?php echo esc_attr( $secondary_keywords ); ?>"
						class="large-text"
					/>
					<p class="description">
						<?php esc_html_e( 'Comma-separated list of secondary keywords.', 'seovault-ai' ); ?>
					</p>
				</div>

				<div class="seovault-ai-field">
					<label for="seovault_canonical_url">
						<?php esc_html_e( 'Canonical URL', 'seovault-ai' ); ?>
					</label>
					<input
						type="url"
						id="seovault_canonical_url"
						name="seovault_canonical_url"
						value="<?php echo esc_attr( $canonical_url ); ?>"
						class="large-text"
						placeholder="<?php echo esc_url( get_permalink( $post->ID ) ); ?>"
					/>
					<p class="description">
						<?php esc_html_e( 'Leave empty to use the permalink.', 'seovault-ai' ); ?>
					</p>
				</div>

				<div class="seovault-ai-field">
					<label>
						<input
							type="checkbox"
							id="seovault_robots_noindex"
							name="seovault_robots_noindex"
							value="1"
							<?php checked( $noindex, true ); ?>
						/>
						<?php esc_html_e( 'Noindex this page', 'seovault-ai' ); ?>
					</label>
				</div>

				<div class="seovault-ai-field">
					<label>
						<input
							type="checkbox"
							id="seovault_robots_nofollow"
							name="seovault_robots_nofollow"
							value="1"
							<?php checked( $nofollow, true ); ?>
						/>
						<?php esc_html_e( 'Nofollow this page', 'seovault-ai' ); ?>
					</label>
				</div>

				<div class="seovault-ai-field">
					<label for="seovault_redirect_url">
						<?php esc_html_e( 'Redirect URL', 'seovault-ai' ); ?>
					</label>
					<input
						type="url"
						id="seovault_redirect_url"
						name="seovault_redirect_url"
						value="<?php echo esc_attr( $redirect_url ); ?>"
						class="large-text"
						placeholder="https://example.com/new-page"
					/>
					<p class="description">
						<?php esc_html_e( 'Visitors will be redirected to this URL. Leave empty for no redirect.', 'seovault-ai' ); ?>
					</p>
				</div>

				<div class="seovault-ai-field">
					<label for="seovault_redirect_type">
						<?php esc_html_e( 'Redirect Type', 'seovault-ai' ); ?>
					</label>
					<select id="seovault_redirect_type" name="seovault_redirect_type" class="seovaultseo-select">
						<option value="301" <?php selected( $redirect_type, 301 ); ?>><?php esc_html_e( '301 - Permanent', 'seovault-ai' ); ?></option>
						<option value="302" <?php selected( $redirect_type, 302 ); ?>><?php esc_html_e( '302 - Temporary', 'seovault-ai' ); ?></option>
					</select>
				</div>
			</details>

			<details class="seovault-ai-og">
				<summary><?php esc_html_e( 'Open Graph', 'seovault-ai' ); ?></summary>

				<div class="seovault-ai-field">
					<label for="seovault_og_title">
						<?php esc_html_e( 'OG Title', 'seovault-ai' ); ?>
					<span class="seovaultseo-char-counter" data-target="seovault_og_title" data-min="<?php echo esc_attr( (string) $og_title_limits['ideal_min'] ); ?>" data-max="<?php echo esc_attr( (string) $og_title_limits['ideal_max'] ); ?>"></span>
					</label>
					<input
						type="text"
						id="seovault_og_title"
						name="seovault_og_title"
						value="<?php echo esc_attr( $og_title ); ?>"
						class="large-text seovaultseo-counted"
					placeholder="<?php echo esc_attr( $seo_title ?: ( $auto_title ?: $post->post_title ) ); ?>"
					data-counter-target="og_title"
					/>
					<p class="description">
						<?php esc_html_e( 'Leave empty to use the SEO title.', 'seovault-ai' ); ?>
					</p>
				</div>

				<div class="seovault-ai-field">
					<label for="seovault_og_description">
						<?php esc_html_e( 'OG Description', 'seovault-ai' ); ?>
					<span class="seovaultseo-char-counter" data-target="seovault_og_description" data-min="<?php echo esc_attr( (string) $og_desc_limits['ideal_min'] ); ?>" data-max="<?php echo esc_attr( (string) $og_desc_limits['ideal_max'] ); ?>"></span>
					</label>
					<textarea
						id="seovault_og_description"
						name="seovault_og_description"
						rows="3"
						class="large-text seovaultseo-counted"
					data-counter-target="og_description"
				><?php echo esc_textarea( $og_description ); ?></textarea>
					<p class="description">
						<?php esc_html_e( 'Leave empty to use the SEO description.', 'seovault-ai' ); ?>
					</p>
				</div>

				<div class="seovault-ai-field">
					<label for="seovault_og_image">
						<?php esc_html_e( 'OG Image URL', 'seovault-ai' ); ?>
					</label>
					<input
						type="url"
						id="seovault_og_image"
						name="seovault_og_image"
						value="<?php echo esc_attr( $og_image ); ?>"
						class="large-text"
					/>
					<p class="description">
						<?php esc_html_e( 'Leave empty to use the featured image or default social image.', 'seovault-ai' ); ?>
					</p>
				</div>
			</details>

			<details class="seovault-ai-schema">
				<summary><?php esc_html_e( 'Schema', 'seovault-ai' ); ?></summary>
				<?php
				// Phase 54: Advanced JSON-LD escape hatch (mode, preview, restore).
				echo SchemaCustomJsonEditor::render( (int) $post->ID ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Renderer escapes.
				?>
			</details>
		<div class="seovault-ai-field">
			<label>
				<input type="checkbox" name="seovault_hide_frontend_score" value="1" <?php checked( PostMeta::is_frontend_score_hidden( $post->ID ) ); ?> />
				<?php esc_html_e( 'Hide frontend SEO score badge for this post', 'seovault-ai' ); ?>
			</label>
		</div>

		<?php
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Hook name is a prefixed ExtensionApi public constant.
		$editor_panels = apply_filters( \SEOvaultSEO\Extension\ExtensionApi::FILTER_EDITOR_PANELS, array() );
		if ( is_array( $editor_panels ) ) {
			foreach ( $editor_panels as $panel ) {
				if ( ! is_array( $panel ) || ! isset( $panel['callback'] ) || ! is_callable( $panel['callback'] ) ) {
					continue;
				}
				call_user_func( $panel['callback'], $post, $panel );
			}
		}

		/**
		 * Fires at the end of the SEOvault post meta box for extension panels.
		 *
		 * @param \WP_Post $post Post being edited.
		 */
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Hook name is a prefixed ExtensionApi public constant.
		do_action( \SEOvaultSEO\Extension\ExtensionApi::ACTION_EDITOR_META_BOX_PANELS, $post );
		?>
		</div>
		<?php
	}

	/**
	 * Render the assessment panel with per-check status indicators.
	 *
	 * @param array $assessment Assessment result from SeoAssessment::assess_post().
	 */
	private static function render_assessment_panel( $assessment ) {
		$overall = $assessment['overall'];
		$checks  = $assessment['checks'];

		$overall_labels = array(
			'good'    => __( 'Good', 'seovault-ai' ),
			'okay'    => __( 'Okay', 'seovault-ai' ),
			'bad'     => __( 'Needs Work', 'seovault-ai' ),
			'unknown' => __( 'Unknown', 'seovault-ai' ),
		);

		$overall_label = $overall_labels[ $overall ] ?? $overall_labels['unknown'];

		$post_id = get_the_ID();
		$fs      = FrontendScore::get_instance();
		$score   = $post_id ? $fs->calculate_score( $post_id ) : 0;
		$rating  = $fs->get_rating( $score );
		?>
		<div class="seovaultseo-assessment-panel" id="seovaultseo-assessment-panel">
			<div class="seovaultseo-assessment-score-circle seovaultseo-assessment-score-<?php echo esc_attr( $rating ); ?>" id="seovaultseo-assessment-score-circle">
				<span class="seovaultseo-assessment-score-num" id="seovaultseo-assessment-score-num"><?php echo esc_html( (string) $score ); ?></span>
				<span class="seovaultseo-assessment-score-max">/ 100</span>
			</div>
			<div class="seovaultseo-assessment-overall seovaultseo-assessment-overall-<?php echo esc_attr( $overall ); ?>">
				<span class="seovaultseo-assessment-overall-dot"></span>
				<span class="seovaultseo-assessment-overall-label"><?php esc_html_e( 'SEO Status', 'seovault-ai' ); ?>:</span>
				<strong id="seovaultseo-assessment-overall-text"><?php echo esc_html( $overall_label ); ?></strong>
			</div>
			<ul class="seovaultseo-assessment-checks" id="seovaultseo-assessment-checks">
				<?php foreach ( $checks as $check ) : ?>
					<li class="seovaultseo-assessment-check seovaultseo-assessment-check-<?php echo esc_attr( $check['status'] ); ?>" data-check-id="<?php echo esc_attr( $check['id'] ); ?>">
						<span class="seovaultseo-assessment-check-icon"></span>
						<span class="seovaultseo-assessment-check-label"><?php echo esc_html( $check['label'] ); ?></span>
						<span class="seovaultseo-assessment-check-msg"><?php echo esc_html( $check['message'] ); ?></span>
					</li>
				<?php endforeach; ?>
			</ul>
		</div>
		<?php
	}

	/**
	 * Render the SERP (Search Engine Results Page) preview.
	 *
	 * Shows a Google-style preview of how the post will appear in search results.
	 * The preview updates live via JavaScript as the user types.
	 *
	 * @param \WP_Post $post              Post object.
	 * @param string   $auto_title        Auto-generated title (for placeholder).
	 * @param string   $auto_description  Auto-generated description (for placeholder).
	 */
	private static function render_serp_preview( $post, $auto_title, $auto_description ) {
		$permalink = get_permalink( $post->ID );
		$parsed    = wp_parse_url( $permalink );
		$domain    = $parsed['host'] ?? '';
		$path      = $parsed['path'] ?? '';

		$seo_title       = PostMeta::get_seo_title( $post->ID );
		$seo_description = PostMeta::get_seo_description( $post->ID );

		$preview_title       = ! empty( $seo_title ) ? $seo_title : $auto_title;
		$preview_description = ! empty( $seo_description ) ? $seo_description : $auto_description;

		?>
		<div class="seovaultseo-serp-preview" id="seovaultseo-serp-preview">
			<div class="seovaultseo-serp-preview-label">
				<?php esc_html_e( 'Search Preview', 'seovault-ai' ); ?>
			</div>
			<div class="seovaultseo-serp-preview-container">
				<div class="seovaultseo-serp-preview-mock">
					<div class="seovaultseo-serp-preview-url">
						<span class="seovaultseo-serp-preview-favicon"></span>
						<span class="seovaultseo-serp-preview-domain"><?php echo esc_html( $domain ); ?></span>
						<span class="seovaultseo-serp-preview-path"><?php echo esc_html( $path ); ?></span>
					</div>
					<div class="seovaultseo-serp-preview-title" id="seovaultseo-serp-preview-title">
						<?php echo esc_html( $preview_title ); ?>
					</div>
					<div class="seovaultseo-serp-preview-description" id="seovaultseo-serp-preview-description">
						<?php echo esc_html( $preview_description ); ?>
					</div>
				</div>
			</div>
		</div>
		<?php
	}

	/**
	 * Save meta box data
	 *
	 * @param int      $post_id Post ID
	 * @param \WP_Post $post Post object
	 */
	public static function save_meta_box( $post_id, $post ) {
		// Check nonce
		if ( ! isset( $_POST['seovault_seo_meta_box_nonce'] ) ) {
			return;
		}

		if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['seovault_seo_meta_box_nonce'] ) ), 'seovault_seo_meta_box' ) ) {
			return;
		}

		// Check autosave
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}
		if ( wp_is_post_revision( $post_id ) ) {
			return;
		}

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

		// Save SEO title
		if ( isset( $_POST['seovault_seo_title'] ) ) {
			$seo_title = Sanitizer::sanitize_title( sanitize_text_field( wp_unslash( $_POST['seovault_seo_title'] ) ) );
			PostMeta::set_seo_title( $post_id, $seo_title );
		}

		// Save SEO description
		if ( isset( $_POST['seovault_seo_description'] ) ) {
			$seo_description = Sanitizer::sanitize_description( sanitize_textarea_field( wp_unslash( $_POST['seovault_seo_description'] ) ) );
			PostMeta::set_seo_description( $post_id, $seo_description );
		}

		// Save focus keyword
		if ( isset( $_POST['seovault_focus_keyword'] ) ) {
			$focus_keyword = Sanitizer::sanitize_keyword( sanitize_text_field( wp_unslash( $_POST['seovault_focus_keyword'] ) ) );
			PostMeta::set_focus_keyword( $post_id, $focus_keyword );
		}

		// Save secondary keywords
		if ( isset( $_POST['seovault_secondary_keywords'] ) ) {
			$secondary_keywords = Sanitizer::sanitize_secondary_keywords( sanitize_text_field( wp_unslash( $_POST['seovault_secondary_keywords'] ) ) );
			PostMeta::set_secondary_keywords( $post_id, $secondary_keywords );
		}

		// Save canonical URL
		if ( isset( $_POST['seovault_canonical_url'] ) ) {
			$canonical_url = Sanitizer::sanitize_canonical_url( esc_url_raw( wp_unslash( $_POST['seovault_canonical_url'] ) ) );
			PostMeta::set_canonical_url( $post_id, $canonical_url );
		}

		// Save noindex
		$noindex = isset( $_POST['seovault_robots_noindex'] ) ? 1 : 0;
		PostMeta::set_noindex( $post_id, $noindex );

		// Save nofollow
		$nofollow = isset( $_POST['seovault_robots_nofollow'] ) ? 1 : 0;
		PostMeta::set_nofollow( $post_id, $nofollow );

		// Save redirect URL
		if ( isset( $_POST['seovault_redirect_url'] ) ) {
			$redirect_url = esc_url_raw( wp_unslash( $_POST['seovault_redirect_url'] ) );
			if ( ! empty( $redirect_url ) ) {
				PostMeta::set_redirect_url( $post_id, $redirect_url );
			} else {
				PostMeta::delete_redirect_url( $post_id );
			}
		}

		// Save redirect type
		if ( isset( $_POST['seovault_redirect_type'] ) ) {
			$redirect_type = (int) $_POST['seovault_redirect_type'];
			PostMeta::set_redirect_type( $post_id, $redirect_type );
		}

		// Save OG title
		if ( isset( $_POST['seovault_og_title'] ) ) {
			$og_title = Sanitizer::sanitize_og_title( sanitize_text_field( wp_unslash( $_POST['seovault_og_title'] ) ) );
			PostMeta::set_og_title( $post_id, $og_title );
		}

		// Save OG description
		if ( isset( $_POST['seovault_og_description'] ) ) {
			$og_description = Sanitizer::sanitize_og_description( sanitize_textarea_field( wp_unslash( $_POST['seovault_og_description'] ) ) );
			PostMeta::set_og_description( $post_id, $og_description );
		}

		// Save OG image
		if ( isset( $_POST['seovault_og_image'] ) ) {
			$og_image = Sanitizer::sanitize_og_image( esc_url_raw( wp_unslash( $_POST['seovault_og_image'] ) ) );
			PostMeta::set_og_image( $post_id, $og_image );
		}

		// Restore last-valid custom JSON; no-JS submit button).
		if ( ! empty( $_POST['seovault_schema_json_restore_last_valid'] ) ) {
			\SEOvaultSEO\Schema\SchemaCustomJson::restore_last_valid( $post_id );
		} elseif ( isset( $_POST['seovault_schema_json'] ) ) {
			// Save schema JSON (mode + confirmation aware).
			$raw_schema_json = is_string( $_POST['seovault_schema_json'] )
				? wp_strip_all_tags( wp_unslash( $_POST['seovault_schema_json'] ), true )
				: '';
			$limits          = ValidationLimits::get();
			if ( is_string( $raw_schema_json ) && strlen( $raw_schema_json ) <= $limits['max_json_bytes'] ) {
				$requested_mode = isset( $_POST['seovault_schema_json_mode'] )
					? sanitize_key( wp_unslash( $_POST['seovault_schema_json_mode'] ) )
					: '';
				$replace_confirm = ! empty( $_POST['seovault_schema_json_replace_confirm'] );
				\SEOvaultSEO\Schema\SchemaCustomJson::save(
					$post_id,
					$raw_schema_json,
					$requested_mode,
					$replace_confirm
				);
			} elseif ( is_string( $raw_schema_json ) && strlen( $raw_schema_json ) > $limits['max_json_bytes'] ) {
				\SEOvaultSEO\Schema\SchemaCustomJson::withhold_invalid( $post_id, $raw_schema_json );
			}
		}

		// Save frontend score visibility
		$hide_score = isset( $_POST['seovault_hide_frontend_score'] ) ? true : false;
		PostMeta::set_frontend_score_hidden( $post_id, $hide_score );
        // phpcs:enable
	}

	/**
	 * AJAX handler for live assessment updates as the user types in the meta box.
	 */
	public static function ajax_live_assessment() {
		check_ajax_referer( 'seovaultseo_metabox_nonce', 'nonce' );

		if ( ! current_user_can( 'edit_posts' ) ) {
			wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'seovault-ai' ) ) );
		}

		$title       = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '';
		$description = isset( $_POST['description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['description'] ) ) : '';
		$keyword     = isset( $_POST['keyword'] ) ? sanitize_text_field( wp_unslash( $_POST['keyword'] ) ) : '';
		$noindex     = ! empty( $_POST['noindex'] );
		$nofollow    = ! empty( $_POST['nofollow'] );
		$canonical   = isset( $_POST['canonical'] ) ? esc_url_raw( wp_unslash( $_POST['canonical'] ) ) : '';
		$post_id     = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0;
		$content_set = array_key_exists( 'content', $_POST );
		$live_title  = isset( $_POST['post_title'] ) ? sanitize_text_field( wp_unslash( $_POST['post_title'] ) ) : '';

		if ( $post_id && ! current_user_can( 'edit_post', $post_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'seovault-ai' ) ) );
		}

		$post            = $post_id ? get_post( $post_id ) : null;
		$fallback_title  = '' !== $live_title ? $live_title : ( $post ? $post->post_title : '' );
		$effective_title = '' !== $title ? $title : $fallback_title;

		$checks   = array();
		$checks[] = SeoAssessment::check_title( $effective_title, '' === $title );
		$checks[] = SeoAssessment::check_description( $description );
		$checks[] = SeoAssessment::check_keyword( $keyword, $effective_title, $description );
		$checks[] = SeoAssessment::check_indexing( $noindex );
		$checks[] = SeoAssessment::check_following( $nofollow );
		$checks[] = SeoAssessment::check_canonical( $canonical, $post_id );
		$checks[] = SeoAssessment::check_branding( $effective_title );

		if ( $content_set ) {
			$content  = isset( $_POST['content'] ) ? substr( wp_kses_post( wp_unslash( $_POST['content'] ) ), 0, 2000000 ) : '';
			$checks[] = SeoAssessment::check_content( $content, $keyword );
		}

		$redirect_url = isset( $_POST['redirect_url'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_url'] ) ) : '';
		$checks[]     = SeoAssessment::check_redirect( $redirect_url );

		$overall = self::compute_live_overall( $checks, $noindex );

		$fs     = FrontendScore::get_instance();
		$score  = $fs->calculate_score_from_checks( $checks );
		$rating = $fs->get_rating( $score );

		wp_send_json_success(
			array(
				'overall' => $overall,
				'checks'  => $checks,
				'score'   => $score,
				'rating'  => $rating,
			)
		);
	}

	/**
	 * AJAX handler for an explicit Schema Health refresh.
	 */
	public static function ajax_validate_post_schema() {
		if ( ! check_ajax_referer( 'seovaultseo_schema_health', 'nonce', false ) ) {
			wp_send_json_error(
				array(
					'code'    => 'nonce_expired',
					'message' => __( 'Your session expired. Refresh the editor and try again.', 'seovault-ai' ),
				),
				403
			);
		}

		$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
		$post    = $post_id ? get_post( $post_id ) : null;

		if ( ! $post || ! PostSchemaValidationService::is_supported_post_type( $post->post_type ) ) {
			wp_send_json_error(
				array(
					'code'    => 'invalid_post',
					'message' => __( 'This post cannot be checked.', 'seovault-ai' ),
				),
				400
			);
		}

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			wp_send_json_error(
				array(
					'code'    => 'forbidden',
					'message' => __( 'You do not have permission to check this post.', 'seovault-ai' ),
				),
				403
			);
		}

		if ( ! SchemaValidationFeatures::is_enabled( SchemaValidationFeatures::LOCAL ) ) {
			wp_send_json_error(
				array(
					'code'    => 'feature_disabled',
					'message' => __( 'Schema Health validation is not available.', 'seovault-ai' ),
				),
				400
			);
		}

		$service = new PostSchemaValidationService();
		if ( array_key_exists( 'custom_schema_json', $_POST ) ) {
			$custom_json = is_string( $_POST['custom_schema_json'] )
				? wp_strip_all_tags( wp_unslash( $_POST['custom_schema_json'] ), true )
				: '';
			$validation  = $service->validate_editor_input( $post_id, $custom_json );
		} else {
			$validation = $service->validate_saved( $post_id );
		}

		wp_send_json_success(
			array(
				'result' => $validation['result'],
				'html'   => SchemaHealthPanel::render( $validation ),
				'state'  => $validation['state'],
				'scope'  => $validation['scope'],
				'setup'  => isset( $validation['setup'] ) ? $validation['setup'] : array(),
				'build'  => isset( $validation['build'] ) ? $validation['build'] : array(),
				'content_suggestions' => isset( $validation['content_suggestions'] ) ? $validation['content_suggestions'] : array(),
			)
		);
	}

	/**
	 * AJAX handler for an explicit published-page Schema Health check.
	 *
	 * Manual only — never triggered from save_post or front-end rendering.
	 */
	public static function ajax_validate_live_schema() {
		if ( ! check_ajax_referer( 'seovaultseo_schema_health', 'nonce', false ) ) {
			wp_send_json_error(
				array(
					'code'    => 'nonce_expired',
					'message' => __( 'Your session expired. Refresh the editor and try again.', 'seovault-ai' ),
				),
				403
			);
		}

		$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
		$post    = $post_id ? get_post( $post_id ) : null;

		if ( ! $post || ! PostSchemaValidationService::is_supported_post_type( $post->post_type ) ) {
			wp_send_json_error(
				array(
					'code'    => 'invalid_post',
					'message' => __( 'This post cannot be checked.', 'seovault-ai' ),
				),
				400
			);
		}

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			wp_send_json_error(
				array(
					'code'    => 'forbidden',
					'message' => __( 'You do not have permission to check this post.', 'seovault-ai' ),
				),
				403
			);
		}

		if ( ! SchemaValidationFeatures::is_enabled( SchemaValidationFeatures::LIVE ) ) {
			wp_send_json_error(
				array(
					'code'    => 'feature_disabled',
					'message' => __( 'Published-page validation is not available.', 'seovault-ai' ),
				),
				400
			);
		}

		$local = ( new PostSchemaValidationService() )->validate_saved( $post_id );
		$live  = ( new SchemaLiveValidationService() )->validate( $post_id, true );

		wp_send_json_success(
			array(
				'result' => $local['result'],
				'live'   => $live,
				'html'   => SchemaHealthPanel::render( $local, $live ),
				'state'  => $local['state'],
				'scope'  => $local['scope'],
			)
		);
	}

	/**
	 * AJAX handler for the wide Review schema surface.
	 */
	public static function ajax_review_schema() {
		if ( ! check_ajax_referer( 'seovaultseo_schema_health', 'nonce', false ) ) {
			wp_send_json_error(
				array(
					'code'    => 'nonce_expired',
					'message' => __( 'Your session expired. Refresh the editor and try again.', 'seovault-ai' ),
				),
				403
			);
		}

		$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
		$post    = $post_id ? get_post( $post_id ) : null;

		if ( ! $post || ! PostSchemaValidationService::is_supported_post_type( $post->post_type ) ) {
			wp_send_json_error(
				array(
					'code'    => 'invalid_post',
					'message' => __( 'This post cannot be reviewed.', 'seovault-ai' ),
				),
				400
			);
		}

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			wp_send_json_error(
				array(
					'code'    => 'forbidden',
					'message' => __( 'You do not have permission to review this post.', 'seovault-ai' ),
				),
				403
			);
		}

		if ( ! SchemaValidationFeatures::is_enabled( SchemaValidationFeatures::LOCAL ) ) {
			wp_send_json_error(
				array(
					'code'    => 'feature_disabled',
					'message' => __( 'Schema Health validation is not available.', 'seovault-ai' ),
				),
				400
			);
		}

		$service = new PostSchemaValidationService();
		if ( array_key_exists( 'custom_schema_json', $_POST ) ) {
			$custom_json = is_string( $_POST['custom_schema_json'] )
				? wp_strip_all_tags( wp_unslash( $_POST['custom_schema_json'] ), true )
				: '';
			$validation  = $service->validate_editor_input( $post_id, $custom_json );
		} else {
			$validation = $service->validate_saved( $post_id );
		}

		$live = null;
		if ( SchemaValidationFeatures::is_enabled( SchemaValidationFeatures::LIVE ) && ! empty( $_POST['include_live'] ) ) {
			$live = ( new SchemaLiveValidationService() )->validate( $post_id, true );
		}

		$diagnostics = SchemaReviewSurface::build_diagnostics_payload( $validation, $live );

		wp_send_json_success(
			array(
				'html'         => SchemaReviewSurface::render_body( $validation, $live ),
				'diagnostics'  => $diagnostics,
				'state'        => $validation['state'],
				'scope'        => $validation['scope'],
				'result'       => $validation['result'],
				'setup'        => isset( $validation['setup'] ) ? $validation['setup'] : array(),
				'build'        => isset( $validation['build'] ) ? $validation['build'] : array(),
				'live'         => $live,
			)
		);
	}

	/**
	 * Guard shared by custom JSON editor AJAX endpoints.
	 *
	 * @return int Post ID on success (exits with JSON error otherwise).
	 */
	private static function require_schema_custom_json_ajax_post() {
		if ( ! check_ajax_referer( 'seovaultseo_schema_health', 'nonce', false ) ) {
			wp_send_json_error(
				array(
					'code'    => 'nonce_expired',
					'message' => __( 'Your session expired. Refresh the editor and try again.', 'seovault-ai' ),
				),
				403
			);
		}

		$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
		$post    = $post_id ? get_post( $post_id ) : null;

		if ( ! $post || ! PostSchemaValidationService::is_supported_post_type( $post->post_type ) ) {
			wp_send_json_error(
				array(
					'code'    => 'invalid_post',
					'message' => __( 'This post cannot be edited.', 'seovault-ai' ),
				),
				400
			);
		}

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			wp_send_json_error(
				array(
					'code'    => 'forbidden',
					'message' => __( 'You do not have permission to edit this post.', 'seovault-ai' ),
				),
				403
			);
		}

		return $post_id;
	}

	/**
	 * AJAX: pretty-print custom JSON via server parse bounds.
	 */
	public static function ajax_schema_custom_json_format() {
		$post_id = self::require_schema_custom_json_ajax_post();
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce + caps in require_schema_custom_json_ajax_post().
		$raw = isset( $_POST['custom_schema_json'] ) && is_string( $_POST['custom_schema_json'] )
			? wp_strip_all_tags( wp_unslash( $_POST['custom_schema_json'] ), true )
			: '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing
		$limits  = ValidationLimits::get();
		if ( strlen( $raw ) > (int) $limits['max_json_bytes'] ) {
			wp_send_json_error(
				array(
					'code'    => 'too_large',
					'message' => __( 'Custom JSON is too large to format.', 'seovault-ai' ),
				),
				400
			);
		}

		$result = \SEOvaultSEO\Schema\SchemaCustomJson::format( $raw );
		if ( empty( $result['ok'] ) ) {
			wp_send_json_error(
				array(
					'code'    => 'invalid',
					'message' => __( 'Fix JSON syntax before formatting.', 'seovault-ai' ),
					'issues'  => $result['issues'],
				),
				400
			);
		}

		wp_send_json_success(
			array(
				'json'    => $result['json'],
				'post_id' => $post_id,
			)
		);
	}

	/**
	 * AJAX: validate/preview custom JSON against the published-graph rules.
	 */
	public static function ajax_schema_custom_json_preview() {
		$post_id = self::require_schema_custom_json_ajax_post();
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce + caps in require_schema_custom_json_ajax_post().
		$raw  = isset( $_POST['custom_schema_json'] ) && is_string( $_POST['custom_schema_json'] )
			? wp_strip_all_tags( wp_unslash( $_POST['custom_schema_json'] ), true )
			: '';
		$mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing
		$limits = ValidationLimits::get();
		if ( strlen( $raw ) > (int) $limits['max_json_bytes'] ) {
			wp_send_json_error(
				array(
					'code'    => 'too_large',
					'message' => __( 'Custom JSON is too large to preview.', 'seovault-ai' ),
				),
				400
			);
		}

		$payload = SchemaCustomJsonEditor::build_preview_payload( $post_id, $raw, $mode );
		wp_send_json_success( $payload );
	}

	/**
	 * AJAX: restore last-valid custom JSON into saved meta and return editor value.
	 */
	public static function ajax_schema_custom_json_restore() {
		$post_id = self::require_schema_custom_json_ajax_post();
		$result  = \SEOvaultSEO\Schema\SchemaCustomJson::restore_last_valid( $post_id );
		if ( empty( $result['ok'] ) ) {
			wp_send_json_error(
				array(
					'code'    => (string) ( $result['message'] ?? 'restore_failed' ),
					'message' => __( 'No last-valid custom JSON is available to restore.', 'seovault-ai' ),
				),
				400
			);
		}

		$ux = \SEOvaultSEO\Schema\SchemaCustomJson::describe_ux_state( $post_id );
		wp_send_json_success(
			array(
				'json'                 => (string) ( $result['json'] ?? '' ),
				'mode'                 => (string) ( $result['mode'] ?? 'add' ),
				'ux'                   => $ux,
				'saved_fingerprint'    => SchemaCustomJsonEditor::fingerprint( (string) ( $result['json'] ?? '' ) ),
				'can_restore'          => ! empty( $ux['can_restore'] ),
				'replace_confirmed'    => 'replace' === ( $result['mode'] ?? '' ) && 'replace' === ( $result['action'] ?? '' ),
			)
		);
	}

	/**
	 * AJAX: dismiss a content-to-block suggestion for the current content hash.
	 */
	public static function ajax_content_suggestion_dismiss() {
		$post_id = self::require_schema_custom_json_ajax_post();
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce + caps in require_schema_custom_json_ajax_post().
		$kind = isset( $_POST['kind'] ) ? sanitize_key( wp_unslash( $_POST['kind'] ) ) : '';
		$hash = isset( $_POST['content_hash'] ) ? sanitize_text_field( wp_unslash( $_POST['content_hash'] ) ) : '';
		$sid  = isset( $_POST['suggestion_id'] ) ? sanitize_text_field( wp_unslash( $_POST['suggestion_id'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		if ( '' === $hash ) {
			$post = get_post( $post_id );
			$hash = $post ? hash( 'sha256', (string) $post->post_content ) : '';
		}

		$ok = \SEOvaultSEO\Schema\Content\SchemaContentSuggestionStore::dismiss( $post_id, $kind, $hash, $sid );
		if ( ! $ok ) {
			wp_send_json_error(
				array(
					'code'    => 'dismiss_failed',
					'message' => __( 'Could not dismiss that suggestion.', 'seovault-ai' ),
				),
				400
			);
		}

		$validation = ( new PostSchemaValidationService() )->validate_saved( $post_id, true );
		wp_send_json_success(
			array(
				'html'                => SchemaHealthPanel::render( $validation ),
				'content_suggestions' => isset( $validation['content_suggestions'] ) ? $validation['content_suggestions'] : array(),
			)
		);
	}

	/**
	 * AJAX: sanitize edited suggestion attributes for block creation (does not mutate post content or schema).
	 */
	public static function ajax_content_suggestion_confirm() {
		$post_id = self::require_schema_custom_json_ajax_post();
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce + caps in require_schema_custom_json_ajax_post().
		$kind      = isset( $_POST['kind'] ) ? sanitize_key( wp_unslash( $_POST['kind'] ) ) : '';
		$raw_attrs = isset( $_POST['attributes'] )
			? ( is_array( $_POST['attributes'] )
				? map_deep( wp_unslash( $_POST['attributes'] ), 'sanitize_text_field' )
				: wp_strip_all_tags( wp_unslash( (string) $_POST['attributes'] ), true ) )
			: '';
		// phpcs:enable WordPress.Security.NonceVerification.Missing
		$attributes = array();
		if ( is_string( $raw_attrs ) && '' !== $raw_attrs ) {
			$decoded = json_decode( $raw_attrs, true );
			if ( is_array( $decoded ) ) {
				$attributes = $decoded;
			}
		} elseif ( is_array( $raw_attrs ) ) {
			$attributes = $raw_attrs;
		}

		$result = \SEOvaultSEO\Schema\Content\SchemaContentSuggestionDetector::sanitize_confirmation( $kind, $attributes );
		if ( empty( $result['ok'] ) ) {
			wp_send_json_error(
				array(
					'code'    => 'incomplete',
					'message' => __( 'Edit the suggested values so the block is complete before creating it.', 'seovault-ai' ),
					'errors'  => $result['errors'],
				),
				400
			);
		}

		// Clear dismissal for this kind so a later content change can suggest again if needed.
		\SEOvaultSEO\Schema\Content\SchemaContentSuggestionStore::clear( $post_id, $kind );

		wp_send_json_success(
			array(
				'kind'       => $result['kind'],
				'block_name' => $result['block_name'],
				'attributes' => $result['attributes'],
				'preview'    => $result['preview'],
				'graph_unchanged' => true,
			)
		);
	}

	/**
	 * AJAX handler: resolve a post title through the Formatter to get
	 * the formatted SEO title (with template variables resolved).
	 *
	 * Sends back the formatted title and formatted description based on
	 * the current post title/content provided by the editor.
	 */
	public static function ajax_resolve_auto_values() {
		check_ajax_referer( 'seovaultseo_metabox_nonce', 'nonce' );

		if ( ! current_user_can( 'edit_posts' ) ) {
			wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'seovault-ai' ) ) );
		}

		$post_id    = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0;
		$post_title = isset( $_POST['post_title'] ) ? sanitize_text_field( wp_unslash( $_POST['post_title'] ) ) : '';
		$post_content = isset( $_POST['post_content'] ) ? wp_kses_post( wp_unslash( $_POST['post_content'] ) ) : '';
		$post_excerpt = isset( $_POST['post_excerpt'] ) ? sanitize_textarea_field( wp_unslash( $_POST['post_excerpt'] ) ) : '';

		if ( $post_id && ! current_user_can( 'edit_post', $post_id ) ) {
			wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'seovault-ai' ) ) );
		}

		// Build a temporary post object with the live editor values.
		$temp_post = new \WP_Post(
			(object) array(
				'ID'           => $post_id,
				'post_title'   => $post_title,
				'post_content' => $post_content,
				'post_excerpt' => $post_excerpt,
				'post_type'    => $post_id ? get_post_type( $post_id ) : 'post',
			)
		);

		// In manual mode, return empty strings — no auto-generation.
		if ( ! \SEOvaultSEO\Compatibility\Detector::is_auto_generation() ) {
			wp_send_json_success(
				array(
					'auto_title'       => '',
					'auto_description' => '',
				)
			);
		}

		// Resolve the title template with the live post title.
		$template   = \SEOVaultSEO\Core\Options::get_title_template();
		$auto_title = Formatter::format_title( $template, (array) $temp_post );

		// Resolve the description.
		if ( ! empty( $post_excerpt ) ) {
			$auto_description = Formatter::truncate(
				Formatter::clean_text( $post_excerpt ),
				160
			);
		} else {
			$description_source = Formatter::prepare_content_for_analysis( $post_content, $post_id );
			$auto_description   = Formatter::truncate(
				Formatter::clean_text( $description_source ),
				160
			);
		}

		wp_send_json_success(
			array(
				'auto_title'       => $auto_title,
				'auto_description' => $auto_description,
			)
		);
	}

	/**
	 * AJAX handler: suggest focus keywords based on post content.
	 *
	 * Uses KeywordExtractor to analyze post content and return
	 * keyword suggestions.
	 */
	public static function ajax_suggest_keywords() {
		check_ajax_referer( 'seovaultseo_metabox_nonce', 'nonce' );

		if ( ! current_user_can( 'edit_posts' ) ) {
			wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'seovault-ai' ) ) );
		}

		$post_id    = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0;
		$post_title = isset( $_POST['post_title'] ) ? sanitize_text_field( wp_unslash( $_POST['post_title'] ) ) : '';
		$post_content = isset( $_POST['post_content'] ) ? wp_kses_post( wp_unslash( $_POST['post_content'] ) ) : '';

		if ( $post_id ) {
			$post = get_post( $post_id );
			if ( $post && current_user_can( 'edit_post', $post_id ) ) {
				if ( empty( $post_content ) ) {
					$post_content = $post->post_content;
				}
				if ( empty( $post_title ) ) {
					$post_title = $post->post_title;
				}
			}
		}

		$content_for_analysis = Formatter::prepare_content_for_analysis( $post_content, $post_id );
		if ( empty( $content_for_analysis ) ) {
			wp_send_json_success( array( 'suggestions' => array() ) );
		}

		$suggestions = \SEOvaultSEO\Utils\KeywordExtractor::extract( $content_for_analysis, $post_title, 5 );

		wp_send_json_success(
			array(
				'suggestions' => $suggestions,
			)
		);
	}

	/**
	 * Compute overall status from checks (mirrors SeoAssessment::compute_overall).
	 *
	 * @param array $checks   Check results.
	 * @param bool  $noindex  Whether noindex is set.
	 * @return string
	 */
	private static function compute_live_overall( $checks, $noindex ) {
		if ( $noindex ) {
			return SeoAssessment::STATUS_BAD;
		}

		$has_bad  = false;
		$has_okay = false;

		foreach ( $checks as $check ) {
			if ( SeoAssessment::STATUS_BAD === $check['status'] ) {
				$has_bad = true;
			} elseif ( SeoAssessment::STATUS_OKAY === $check['status'] ) {
				$has_okay = true;
			}
		}

		if ( $has_bad ) {
			return SeoAssessment::STATUS_BAD;
		}
		if ( $has_okay ) {
			return SeoAssessment::STATUS_OKAY;
		}
		return SeoAssessment::STATUS_GOOD;
	}

	/**
	 * Handle no-JS schema setup remove redirects.
	 *
	 * @param int $post_id Post ID.
	 * @return void
	 */
	private static function maybe_handle_schema_setup_request( $post_id ) {
		$post_id = absint( $post_id );
		if ( $post_id <= 0 || empty( $_GET['seovault_schema_instance'] ) || empty( $_GET['seovault_schema_action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Verified below for mutating actions.
			return;
		}

		$action      = sanitize_key( wp_unslash( (string) $_GET['seovault_schema_action'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$instance_id = sanitize_text_field( wp_unslash( (string) $_GET['seovault_schema_instance'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( 'remove' !== $action || '' === $instance_id ) {
			return;
		}

		$nonce = isset( $_GET['seovault_schema_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['seovault_schema_nonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! $nonce || ! wp_verify_nonce( $nonce, self::schema_setup_nonce_action( $post_id ) ) ) {
			return;
		}
		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return;
		}

		\SEOvaultSEO\Schema\SchemaInstanceStorage::delete_instance(
			$post_id,
			$instance_id,
			array( 'skip_cap_check' => false )
		);

		$redirect = remove_query_arg(
			array(
				'seovault_schema_instance',
				'seovault_schema_action',
				'seovault_schema_focus',
				'seovault_schema_nonce',
			)
		);
		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Localize bootstrap for Complete/Edit deep links (JS opens the guided form).
	 *
	 * @param int $post_id Post ID.
	 * @return array<string,mixed>|null
	 */
	private static function get_schema_setup_bootstrap( $post_id ) {
		$post_id = absint( $post_id );
		if ( $post_id <= 0 || empty( $_GET['seovault_schema_instance'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only bootstrap for editor UI.
			return null;
		}
		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return null;
		}

		$instance_id = sanitize_text_field( wp_unslash( (string) $_GET['seovault_schema_instance'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$action      = isset( $_GET['seovault_schema_action'] ) ? sanitize_key( wp_unslash( (string) $_GET['seovault_schema_action'] ) ) : 'complete'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$focus       = isset( $_GET['seovault_schema_focus'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['seovault_schema_focus'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( '' === $instance_id || ! in_array( $action, array( 'complete', 'edit' ), true ) ) {
			return null;
		}

		$doc = \SEOvaultSEO\Schema\SchemaInstanceStorage::read_document( $post_id );
		foreach ( $doc['instances'] as $instance ) {
			if ( ! is_array( $instance ) || (string) ( $instance['id'] ?? '' ) !== $instance_id ) {
				continue;
			}
			return array(
				'instance_id' => $instance_id,
				'action'      => $action,
				'type'        => (string) ( $instance['type'] ?? '' ),
				'focus_field' => $focus,
				'values'      => isset( $instance['fields'] ) && is_array( $instance['fields'] ) ? $instance['fields'] : array(),
				'source_meta' => isset( $instance['source_meta'] ) && is_array( $instance['source_meta'] ) ? $instance['source_meta'] : array(),
			);
		}

		return null;
	}

	/**
	 * Nonce action for no-JS setup mutations.
	 *
	 * @param int $post_id Post ID.
	 * @return string
	 */
	public static function schema_setup_nonce_action( $post_id ) {
		return 'seovault_schema_setup_' . absint( $post_id );
	}
}
