<?php
/**
 * SEO Agent API Controller.
 *
 * Handles SEO analysis requests by fetching page content and
 * using AI to evaluate SEO, performance, and accessibility.
 *
 * @package Antimanual
 * @since   1.6.0
 */

namespace Antimanual\Api;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use Antimanual\AIProvider;

/**
 * SEO Agent REST API controller.
 */
class SeoAgentController {
	/**
	 * Cache prefix for full AI audit responses.
	 *
	 * @var string
	 */
	private const CACHE_PREFIX = 'atml_seo_agent_cache_';

	/**
	 * Cache lifetime for full AI audits (seconds).
	 *
	 * @var int
	 */
	private const CACHE_TTL = 12 * HOUR_IN_SECONDS;

	/**
	 * Post meta key: enforce loading="lazy" in rendered post content.
	 *
	 * @var string
	 */
	private const META_FORCE_LAZY_LOADING = '_atml_seo_force_lazy_loading';

	/**
	 * Post meta key: enforce rel="noopener noreferrer" on target="_blank" links.
	 *
	 * @var string
	 */
	private const META_FORCE_NOOPENER = '_atml_seo_force_noopener';

	/**
	 * Transient key prefix for per-post cannibalization feature cache.
	 *
	 * @var string
	 */
	private const CANNIBAL_CACHE_PREFIX = 'atml_cannibal_feat_';

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_filter( 'the_content', array( $this, 'apply_runtime_content_auto_fixes' ), 99 );
		add_action( 'wp_head', array( $this, 'inject_saved_schema' ), 20 );

		// Invalidate the cannibalization feature cache when a post changes.
		add_action( 'save_post', array( $this, 'invalidate_cannibal_cache' ), 10, 1 );
		add_action( 'deleted_post', array( $this, 'invalidate_cannibal_cache' ), 10, 1 );
	}

	/**
	 * Drop the cached cannibalization feature bundle for a post.
	 *
	 * @param int $post_id Post ID.
	 */
	public function invalidate_cannibal_cache( $post_id ): void {
		$post_id = (int) $post_id;
		if ( $post_id <= 0 ) {
			return;
		}
		delete_transient( self::CANNIBAL_CACHE_PREFIX . $post_id );
	}

	/**
	 * Post meta key: stored JSON-LD schema payload(s) injected into <head>.
	 *
	 * Historically held a single JSON-LD string; now holds a JSON-encoded
	 * array of { "type": string, "jsonld": string } entries. Legacy payloads
	 * are migrated transparently on read.
	 *
	 * @var string
	 */
	private const META_SCHEMA_JSON = '_atml_seo_schema_jsonld';

	/**
	 * Register REST routes for the SEO Agent.
	 *
	 * @param string $namespace REST namespace.
	 */
	public function register_routes( string $namespace ) {
		register_rest_route(
			$namespace,
			'/seo-agent/analyze',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'analyze' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 120,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/auto-fix',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'auto_fix' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 120,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/bulk-audit',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'bulk_audit' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 300,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/bulk-auto-fix',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'bulk_auto_fix' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 300,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/site-audit',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'site_audit' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 300,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/content-freshness',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'content_freshness' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'args'                => array(
					'post_type' => array(
						'default'           => 'post',
						'sanitize_callback' => 'sanitize_key',
						'validate_callback' => function ( $value ) {
							return is_string( $value ) && in_array( $value, [ 'post', 'page' ], true );
						},
					),
					'per_page'  => array(
						'default'           => 50,
						'sanitize_callback' => 'absint',
					),
					'page'      => array(
						'default'           => 1,
						'sanitize_callback' => 'absint',
					),
					'sort'      => array(
						'default'           => 'score_asc',
						'sanitize_callback' => 'sanitize_key',
						'validate_callback' => function ( $value ) {
							return in_array( $value, [ 'score_asc', 'score_desc', 'modified_asc', 'modified_desc' ], true );
						},
					),
				),
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/suggest-variants',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'suggest_variants' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 120,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/apply-variant',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'apply_variant' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 60,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/schema/detect',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'schema_detect' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 60,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/schema/generate',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'schema_generate' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 120,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/schema/save',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'schema_save' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 60,
			)
		);

		// Lightweight read of saved schemas for a post — avoids a remote HTML
		// fetch when we only need the persisted list (used to populate the
		// editor on post selection, preventing accidental overwrite).
		register_rest_route(
			$namespace,
			'/seo-agent/schema/list',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'schema_list' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 30,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/cannibalization',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'cannibalization_check' ),
				'permission_callback' => function () {
					if ( ! current_user_can( 'manage_options' ) ) {
						return new \WP_Error(
							'rest_forbidden',
							__( 'Unauthorized.', 'antimanual' ),
							array( 'status' => 403 )
						);
					}
					if ( ! \atml_is_pro_max() ) {
						return new \WP_Error(
							'antimanual_pro_max_required',
							__( 'Pro Max plan required.', 'antimanual' ),
							array( 'status' => 403 )
						);
					}
					return true;
				},
				'args'                => array(
					'post_types' => array(
						'sanitize_callback' => function ( $value ) {
							if ( ! is_array( $value ) ) {
								return array( 'post' );
							}
							$allowed = array( 'post', 'page' );
							$clean   = array_values(
								array_unique(
									array_filter(
										array_map( 'sanitize_key', $value ),
										fn( $v ) => in_array( $v, $allowed, true )
									)
								)
							);
							return empty( $clean ) ? array( 'post' ) : $clean;
						},
					),
					'similarity_threshold' => array(
						'sanitize_callback' => fn( $v ) => max( 0.1, min( 0.95, (float) $v ) ),
					),
					'limit' => array(
						'sanitize_callback' => fn( $v ) => min( 500, max( 10, absint( $v ) ?: 200 ) ),
					),
				),
				'timeout'             => 300,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/content-brief',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'content_brief' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 180,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/freshness/apply',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'freshness_apply' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
				'timeout'             => 180,
			)
		);

	}

	/**
	 * Apply runtime content fixes for posts that opted into safe auto-fixes.
	 *
	 * This avoids writing attributes directly into block markup, which can
	 * trigger Gutenberg validation mismatch warnings.
	 *
	 * @param string $content The rendered post content.
	 * @return string
	 */
	public function apply_runtime_content_auto_fixes( string $content ): string {
		if ( is_admin() || '' === trim( $content ) ) {
			return $content;
		}

		$post_id = (int) get_the_ID();
		global $post;
		if ( $post_id <= 0 && $post instanceof \WP_Post ) {
			$post_id = (int) $post->ID;
		}
		if ( $post_id <= 0 ) {
			$post_id = get_queried_object_id();
		}

		if ( $post_id <= 0 ) {
			return $content;
		}

		$force_lazy     = '1' === get_post_meta( $post_id, self::META_FORCE_LAZY_LOADING, true );
		$force_noopener = '1' === get_post_meta( $post_id, self::META_FORCE_NOOPENER, true );

		if ( ! $force_lazy && ! $force_noopener ) {
			return $content;
		}

		$updated = $content;
		if ( $force_lazy ) {
			$updated = $this->force_lazy_loading_attributes( $updated );
		}
		if ( $force_noopener ) {
			$updated = $this->force_noopener_attributes( $updated );
		}

		return $updated;
	}

	/**
	 * Analyze a URL for SEO issues.
	 *
	 * Fetches the page HTML, sends it to the AI provider for
	 * comprehensive SEO analysis, and returns structured results.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function analyze( $request ) {
		// Enforce Pro Max plan requirement.
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'The SEO Agent requires the Pro Max plan. Please upgrade to access this feature.', 'antimanual' ),
				),
				403
			);
		}

		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();

		$url           = esc_url_raw( trim( $payload['url'] ?? '' ) );
		$analysis_mode = sanitize_key( $payload['mode'] ?? 'full' );
		$analysis_mode = in_array( $analysis_mode, array( 'quick', 'full' ), true ) ? $analysis_mode : 'full';
		$force_refresh = ! empty( $payload['force_refresh'] );

		if ( empty( $url ) || ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'Please provide a valid URL.', 'antimanual' ),
				)
			);
		}

		// Fetch the page HTML content.
		// For local posts that aren't publicly accessible (draft, pending,
		// private, future), build HTML directly from the post object to
		// avoid the 404 that wp_remote_get would return.
		$post_id       = intval( $payload['post_id'] ?? 0 );
		$local_post_id = $post_id > 0 ? $post_id : url_to_postid( $url );
		$local_post    = $local_post_id > 0 ? get_post( $local_post_id ) : null;

		if ( $local_post instanceof \WP_Post && 'publish' !== $local_post->post_status ) {
			$html = $this->build_html_from_post( $local_post, $url );
		} else {
			$html = $this->fetch_page_html( $url );
		}

		if ( is_wp_error( $html ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => $html->get_error_message(),
				)
			);
		}

		$timestamp = gmdate( 'c' );

		// Quick mode skips AI usage and runs deterministic HTML checks only.
		if ( 'quick' === $analysis_mode ) {
			$result = $this->build_quick_audit( $url, $html );

			if ( is_wp_error( $result ) ) {
				return rest_ensure_response(
					array(
						'success' => false,
						'message' => $result->get_error_message(),
					)
				);
			}

			$result['analysis_mode'] = 'quick';
			$result['cached']        = false;
			$result['analyzed_at']   = $timestamp;

			return rest_ensure_response(
				array(
					'success' => true,
					'data'    => $result,
				)
			);
		}

		$cache_key = $this->get_cache_key( $url );
		if ( ! $force_refresh ) {
			$cached_result = get_transient( $cache_key );
			if ( is_array( $cached_result ) ) {
				$cached_result['analysis_mode'] = 'full';
				$cached_result['cached']        = true;

				if ( empty( $cached_result['analyzed_at'] ) ) {
					$cached_result['analyzed_at'] = $timestamp;
				}

				return rest_ensure_response(
					array(
						'success' => true,
						'data'    => $cached_result,
					)
				);
			}
		}

		// Strip inline script and style content before truncating so that
		// body content (e.g. H1 tags) is not cut off by the token limit.
		// ld+json scripts are kept because they carry structured-data info.
		$html_for_ai = $html;
		$strip_dom   = new \DOMDocument();
		$strip_prev  = libxml_use_internal_errors( true );
		$strip_loaded = $strip_dom->loadHTML( '<?xml encoding="utf-8" ?>' . $html, LIBXML_NOERROR | LIBXML_NOWARNING );
		libxml_clear_errors();
		libxml_use_internal_errors( $strip_prev );

		if ( $strip_loaded ) {
			$strip_xpath = new \DOMXPath( $strip_dom );
			// XPath translate() is used for case-insensitive matching (XPath 1.0 has no lower-case()).
			$lc_alphabet    = 'abcdefghijklmnopqrstuvwxyz';
			$uc_alphabet    = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
			$non_ld_scripts = $strip_xpath->query(
				"//script[not(contains(translate(@type,'{$uc_alphabet}','{$lc_alphabet}'),'application/ld+json'))]"
			);
			if ( $non_ld_scripts instanceof \DOMNodeList ) {
				foreach ( iterator_to_array( $non_ld_scripts ) as $node ) {
					if ( $node->parentNode ) {
						$node->parentNode->removeChild( $node );
					}
				}
			}

			$style_nodes = $strip_xpath->query( '//style' );
			if ( $style_nodes instanceof \DOMNodeList ) {
				foreach ( iterator_to_array( $style_nodes ) as $node ) {
					if ( $node->parentNode ) {
						$node->parentNode->removeChild( $node );
					}
				}
			}

			$html_for_ai = $strip_dom->saveHTML() ?: $html;
		}

		// Truncate HTML to avoid exceeding token limits.
		$max_chars    = 15000;
		$html_excerpt = mb_strlen( $html_for_ai ) > $max_chars
			? mb_substr( $html_for_ai, 0, $max_chars ) . "\n<!-- TRUNCATED -->"
			: $html_for_ai;

		// Build the AI prompt.
		$system_prompt = $this->build_system_prompt();
		$user_prompt   = $this->build_user_prompt( $url, $html_excerpt );

		$messages = array(
			array(
				'role'    => 'system',
				'content' => $system_prompt,
			),
			array(
				'role'    => 'user',
				'content' => $user_prompt,
			),
		);

		$reply_data = AIProvider::get_reply( $messages );

		if ( isset( $reply_data['error'] ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => $reply_data['error'],
				)
			);
		}

		$reply = is_array( $reply_data ) && isset( $reply_data['reply'] ) ? $reply_data['reply'] : (string) $reply_data;

		if ( empty( $reply ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'AI analysis failed.', 'antimanual' ),
				)
			);
		}

		// Parse the JSON response from the AI.
		$result = $this->parse_ai_response( $reply, $url );

		if ( is_wp_error( $result ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				)
			);
		}

		$result['analysis_mode'] = 'full';
		$result['cached']        = false;
		$result['analyzed_at']   = $timestamp;

		set_transient( $cache_key, $result, self::CACHE_TTL );

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $result,
			)
		);
	}

	/**
	 * Automatically fix common on-page SEO issues for a local post/page.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response
	 */
	public function auto_fix( $request ) {
		// Enforce Pro Max plan requirement.
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'The SEO Agent requires the Pro Max plan. Please upgrade to access this feature.', 'antimanual' ),
				),
				403
			);
		}

		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();

		$post_id = intval( $payload['post_id'] ?? 0 );
		$url     = esc_url_raw( trim( $payload['url'] ?? '' ) );

		if ( $post_id <= 0 && ! empty( $url ) ) {
			$post_id = url_to_postid( $url );
		}

		if ( $post_id <= 0 ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'A valid internal post/page is required for auto-fix.', 'antimanual' ),
				)
			);
		}

		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'Selected post could not be found.', 'antimanual' ),
				)
			);
		}

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'You are not allowed to edit this post.', 'antimanual' ),
				)
			);
		}

		$applied_fixes     = array();
		$skipped_fixes     = array();
		$title_updated     = false;
		$meta_updated      = false;
		$content_updated   = false;
		$meta_keys_updated = 0;

		$current_title       = trim( (string) $post->post_title );
		$seo_meta_desc       = $this->get_seo_meta_description( $post );
		$has_seo_meta        = '' !== $seo_meta_desc;
		$current_description = $has_seo_meta ? $seo_meta_desc : trim( (string) $post->post_excerpt );
		$current_content     = (string) $post->post_content;
		$current_slug        = (string) $post->post_name;

		// Truncate content for the AI prompt to stay within token limits.
		$max_content_chars = 8000;
		$content_excerpt   = mb_strlen( $current_content ) > $max_content_chars
			? mb_substr( $current_content, 0, $max_content_chars ) . "\n<!-- TRUNCATED -->"
			: $current_content;

		$ai_prompt = "You are an expert SEO optimizer. Analyze the following WordPress post and fix its SEO issues.\n\n"
			. "Current Title: {$current_title}\n"
			. "Current Meta Description: {$current_description}\n"
			. "Current URL Slug: {$current_slug}\n\n"
			. "Post Content:\n"
			. "```html\n"
			. $content_excerpt
			. "\n```\n\n"
			. "You MUST respond with ONLY valid JSON (no markdown, no code fences) in this exact structure:\n"
			. "{\n"
			. "  \"focus_keyword\": \"<the single most relevant keyword or key phrase for this content>\",\n"
			. "  \"title\": \"<optimized SEO title, 50-60 characters, include the focus keyword near the beginning>\",\n"
			. "  \"meta_description\": \"<optimized meta description, 150-160 characters, compelling, include focus keyword naturally>\",\n"
			. "  \"slug\": \"<optimized URL slug, lowercase, hyphen-separated, 3-5 words, include focus keyword>\",\n"
			. "  \"content_fixes\": {\n"
			. "    \"alt_texts\": [{\"selector\": \"<img identifier>\", \"alt\": \"<descriptive alt text>\"}],\n"
			. "    \"summary\": \"<brief summary of content improvements made>\"\n"
			. "  },\n"
			. "  \"changes_made\": [\"<list of specific changes>\"]\n"
			. "}\n\n"
			. "Rules:\n"
			. "- Identify the primary focus keyword from the content and use it consistently.\n"
			. "- If the title is already well-optimized, return it unchanged.\n"
			. "- If the meta description is already good, return it unchanged.\n"
			. "- If the slug is already clean and keyword-rich, return it unchanged.\n"
			. "- The slug must be lowercase, use hyphens, contain no stop words, and be 3-5 words max.\n"
			. "- Generate meaningful, descriptive alt text for images that lack them.\n"
			. "- Keep the same language as the original content.\n"
			. "- Make changes that genuinely improve SEO, don't change things unnecessarily.";

		$messages = array(
			array(
				'role'    => 'system',
				'content' => 'You are an SEO optimization expert. You fix on-page SEO issues for WordPress posts. Always respond with valid JSON only.',
			),
			array(
				'role'    => 'user',
				'content' => $ai_prompt,
			),
		);

		$reply_data = AIProvider::get_reply( $messages );

		if ( isset( $reply_data['error'] ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => $reply_data['error'],
				)
			);
		}

		$reply = is_array( $reply_data ) && isset( $reply_data['reply'] ) ? $reply_data['reply'] : (string) $reply_data;

		if ( empty( $reply ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'AI auto-fix failed. Please try again.', 'antimanual' ),
				)
			);
		}

		// Parse AI response.
		$reply = preg_replace( '/^```(?:json)?\s*/i', '', trim( $reply ) );
		$reply = preg_replace( '/\s*```$/', '', $reply );
		$ai_data = json_decode( $reply, true );

		if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $ai_data ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'Failed to parse AI fix response. Please try again.', 'antimanual' ),
				)
			);
		}

		$update_payload = array( 'ID' => $post_id );
		$slug_updated   = false;

		// Extract focus keyword identified by AI.
		$focus_keyword = sanitize_text_field( $ai_data['focus_keyword'] ?? '' );

		// Apply title fix.
		$ai_title = sanitize_text_field( $ai_data['title'] ?? '' );
		if ( ! empty( $ai_title ) && $ai_title !== $current_title ) {
			$update_payload['post_title'] = $ai_title;
			$title_updated                = true;
			$applied_fixes[]              = __( 'Title tag optimized by AI.', 'antimanual' );
		} else {
			$skipped_fixes[] = __( 'Title tag did not need changes.', 'antimanual' );
		}

		// Apply meta description fix.
		$ai_description = sanitize_text_field( $ai_data['meta_description'] ?? '' );
		if ( ! empty( $ai_description ) && $ai_description !== $current_description ) {
			if ( $ai_description !== trim( (string) $post->post_excerpt ) ) {
				$update_payload['post_excerpt'] = $ai_description;
			}
			$meta_updated    = true;
			$applied_fixes[] = __( 'Meta description optimized by AI.', 'antimanual' );
		} else {
			$skipped_fixes[] = __( 'Meta description did not need changes.', 'antimanual' );
		}

		// Apply slug fix.
		$ai_slug = sanitize_title( $ai_data['slug'] ?? '' );
		if ( ! empty( $ai_slug ) && $ai_slug !== $current_slug ) {
			$update_payload['post_name'] = $ai_slug;
			$slug_updated                = true;
			$applied_fixes[]             = __( 'URL slug optimized by AI.', 'antimanual' );
		} else {
			$skipped_fixes[] = __( 'URL slug did not need changes.', 'antimanual' );
		}

		// Apply content fixes (alt texts, lazy loading, noopener).
		$content_changes_applied = false;
		$content_for_runtime     = $current_content;

		// Apply AI-suggested alt texts.
		$alt_texts = $ai_data['content_fixes']['alt_texts'] ?? array();
		if ( ! empty( $alt_texts ) && is_array( $alt_texts ) ) {
			$modified_content = $current_content;
			$alt_count        = 0;
			foreach ( $alt_texts as $alt_fix ) {
				if ( empty( $alt_fix['alt'] ) ) {
					continue;
				}
				$alt_value = esc_attr( sanitize_text_field( $alt_fix['alt'] ) );
				// Add alt to images missing it.
				$modified_content = preg_replace(
					'/<img([^>]*?)alt\s*=\s*["\'][\s]*["\']([^>]*?)>/i',
					'<img$1alt="' . $alt_value . '"$2>',
					$modified_content,
					1,
					$count
				);
				$alt_count += $count;
			}
			if ( $alt_count > 0 ) {
				$update_payload['post_content'] = $modified_content;
				$content_updated                = true;
				$content_changes_applied        = true;
				$content_for_runtime            = $modified_content;
				$applied_fixes[]                = sprintf(
					/* translators: %d: number of alt texts added */
					__( '%d image alt text(s) added by AI.', 'antimanual' ),
					$alt_count
				);
			}
		}

		// Apply basic content fixes (lazy loading, noopener) via existing helpers.
		$content_result = $this->apply_content_auto_fixes( $content_for_runtime, $post_id );
		if ( ! empty( $content_result['changed'] ) ) {
			$update_payload['post_content'] = $content_result['content'];
			$content_updated                = true;
			$content_changes_applied        = true;
			$content_for_runtime            = (string) $content_result['content'];
			$applied_fixes[]                = __( 'Image and link attributes optimized.', 'antimanual' );
		}

		if ( count( $update_payload ) > 1 ) {
			$updated = wp_update_post( $update_payload, true );
			if ( is_wp_error( $updated ) ) {
				return rest_ensure_response(
					array(
						'success' => false,
						'message' => $updated->get_error_message(),
					)
				);
			}
		}

		// Runtime fixes for lazy loading and noopener.
		$runtime_scan_content = $this->render_post_content_for_scan( $post, $content_for_runtime );

		$lazy_candidates = $this->count_missing_lazy_attributes( $runtime_scan_content );
		if ( $lazy_candidates > 0 && $this->enable_runtime_content_fix_flag( $post_id, self::META_FORCE_LAZY_LOADING ) ) {
			$content_updated         = true;
			$content_changes_applied = true;
			$applied_fixes[]         = __( 'Lazy-loading enforcement enabled.', 'antimanual' );
		}

		$noopener_candidates = $this->count_missing_noopener_attributes( $runtime_scan_content );
		if ( $noopener_candidates > 0 && $this->enable_runtime_content_fix_flag( $post_id, self::META_FORCE_NOOPENER ) ) {
			$content_updated         = true;
			$content_changes_applied = true;
			$applied_fixes[]         = __( 'Link security attributes added.', 'antimanual' );
		}

		if ( ! $content_changes_applied ) {
			$skipped_fixes[] = __( 'No content fixes needed.', 'antimanual' );
		}

		// Update SEO plugin meta keys.
		if ( $title_updated || $meta_updated || ! empty( $focus_keyword ) ) {
			$meta_keys_updated = $this->update_existing_seo_meta_keys(
				$post_id,
				$title_updated ? $ai_title : '',
				$meta_updated ? $ai_description : '',
				$focus_keyword
			);
			if ( ! empty( $focus_keyword ) ) {
				$applied_fixes[] = __( 'Focus keyword set for SEO plugins.', 'antimanual' );
			}
		}

		$post_url = get_permalink( $post_id ) ?: $url;
		$this->clear_cached_analyses( array_filter( array( $url, $post_url ) ) );

		if ( empty( $applied_fixes ) ) {
			$applied_fixes[] = __( 'No automatic fixes were necessary for this content.', 'antimanual' );
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'post_id'           => $post_id,
					'url'               => esc_url( $post_url ),
					'focus_keyword'     => $focus_keyword,
					'applied_fixes'     => array_values( array_unique( array_map( 'sanitize_text_field', $applied_fixes ) ) ),
					'skipped_fixes'     => array_values( array_unique( array_map( 'sanitize_text_field', $skipped_fixes ) ) ),
					'updated_fields'    => array(
						'title'            => (bool) $title_updated,
						'meta_description' => (bool) $meta_updated,
						'slug'             => (bool) $slug_updated,
						'content'          => (bool) $content_updated,
					),
					'content_changes'   => array(
						'alt_added'      => count( $alt_texts ),
						'lazy_added'     => $lazy_candidates ?? 0,
						'noopener_added' => $noopener_candidates ?? 0,
					),
					'meta_keys_updated' => $meta_keys_updated,
				),
			)
		);
	}

	/**
	 * Run quick SEO audits on multiple posts at once.
	 *
	 * Accepts an array of post IDs and returns per-post scores
	 * and issue summaries. Only quick (no-AI) audits are used
	 * to keep bulk operations fast and cost-free.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response
	 */
	public function bulk_audit( $request ) {
		// Enforce Pro Max plan requirement.
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'The SEO Agent requires the Pro Max plan. Please upgrade to access this feature.', 'antimanual' ),
				),
				403
			);
		}

		$payload  = json_decode( $request->get_body(), true );
		$payload  = is_array( $payload ) ? $payload : array();
		$post_ids = isset( $payload['post_ids'] ) && is_array( $payload['post_ids'] )
			? array_map( 'intval', $payload['post_ids'] )
			: array();

		$analysis_mode = sanitize_key( $payload['mode'] ?? 'quick' );
		$analysis_mode = in_array( $analysis_mode, array( 'quick', 'full' ), true ) ? $analysis_mode : 'quick';

		$post_ids = array_values( array_unique( array_filter( $post_ids, fn( $id ) => $id > 0 ) ) );

		if ( empty( $post_ids ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'No valid post IDs provided.', 'antimanual' ),
				)
			);
		}

		if ( count( $post_ids ) > 50 ) {
			$post_ids = array_slice( $post_ids, 0, 50 );
		}

		$results   = array();
		$timestamp = gmdate( 'c' );

		foreach ( $post_ids as $post_id ) {
			$post = get_post( $post_id );
			if ( ! $post instanceof \WP_Post ) {
				$results[] = array(
					'post_id' => $post_id,
					'status'  => 'error',
					'message' => __( 'Post not found.', 'antimanual' ),
				);
				continue;
			}

			$url = get_permalink( $post_id );
			if ( empty( $url ) ) {
				$results[] = array(
					'post_id' => $post_id,
					'title'   => $post->post_title,
					'status'  => 'error',
					'message' => __( 'Could not resolve URL for this post.', 'antimanual' ),
				);
				continue;
			}

			// Build HTML directly from the post to avoid loopback HTTP requests
			// that can time out when PHP workers are limited.
			$html = $this->build_html_from_post( $post, $url );
			if ( is_wp_error( $html ) ) {
				$results[] = array(
					'post_id' => $post_id,
					'title'   => $post->post_title,
					'url'     => $url,
					'status'  => 'error',
					'message' => $html->get_error_message(),
				);
				continue;
			}

			$audit = $this->run_single_audit( $url, $html, $analysis_mode );
			if ( is_wp_error( $audit ) ) {
				$results[] = array(
					'post_id' => $post_id,
					'title'   => $post->post_title,
					'url'     => $url,
					'status'  => 'error',
					'message' => $audit->get_error_message(),
				);
				continue;
			}

			$issue_counts = array(
				'critical' => 0,
				'warning'  => 0,
				'info'     => 0,
				'passed'   => 0,
			);
			foreach ( $audit['issues'] as $issue ) {
				$sev = $issue['severity'] ?? 'info';
				if ( isset( $issue_counts[ $sev ] ) ) {
					++$issue_counts[ $sev ];
				}
			}

			$results[] = array(
				'post_id'             => $post_id,
				'title'               => $post->post_title,
				'url'                 => esc_url( $url ),
				'edit_link'           => get_edit_post_link( $post_id, 'raw' ) ?: '',
				'status'              => 'success',
				'seo_score'           => $audit['seo_score'],
				'performance_score'   => $audit['performance_score'],
				'accessibility_score' => $audit['accessibility_score'],
				'issue_counts'        => $issue_counts,
				'issues'              => $audit['issues'],
				'top_issue'           => ! empty( $audit['issues'] ) ? $audit['issues'][0]['title'] : '',
				'analyzed_at'         => $timestamp,
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'results'     => $results,
					'total'       => count( $results ),
					'analyzed_at' => $timestamp,
				),
			)
		);
	}

	/**
	 * Run a full site SEO audit based on filtering criteria.
	 *
	 * Accepts post_types (array), post_statuses (array), and check_duplicates
	 * flag. Queries matching posts directly and runs quick audits
	 * without requiring the user to load / select posts first.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response
	 */
	public function site_audit( $request ) {
		// Enforce Pro Max plan requirement.
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'The SEO Agent requires the Pro Max plan. Please upgrade to access this feature.', 'antimanual' ),
				),
				403
			);
		}

		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();

		$raw_types = isset( $payload['post_types'] ) && is_array( $payload['post_types'] )
			? $payload['post_types']
			: array( 'post' );

		$post_types = array_values(
			array_unique(
				array_filter(
					array_map( 'sanitize_key', $raw_types )
				)
			)
		);

		if ( empty( $post_types ) ) {
			$post_types = array( 'post' );
		}

		$raw_statuses = isset( $payload['post_statuses'] ) && is_array( $payload['post_statuses'] )
			? $payload['post_statuses']
			: array( 'publish' );

		$analysis_mode = sanitize_key( $payload['mode'] ?? 'quick' );
		$analysis_mode = in_array( $analysis_mode, array( 'quick', 'full' ), true ) ? $analysis_mode : 'quick';

		$check_duplicates = ! empty( $payload['check_duplicates'] );

		// Map frontend status names to WP query statuses.
		$status_map = array(
			'publish'            => 'publish',
			'draft'              => 'draft',
			'private'            => 'private',
			'password-protected' => 'publish', // Password-protected posts are published with a password.
		);

		$wp_statuses = array();
		$need_password_filter = false;
		foreach ( $raw_statuses as $status ) {
			$status = sanitize_key( $status );
			if ( 'password-protected' === $status ) {
				$need_password_filter = true;
			}
			if ( isset( $status_map[ $status ] ) ) {
				$wp_statuses[] = $status_map[ $status ];
			}
		}

		if ( empty( $wp_statuses ) ) {
			$wp_statuses = array( 'publish' );
		}

		$wp_statuses = array_unique( $wp_statuses );

		$query_args = array(
			'post_type'      => $post_types,
			'post_status'    => $wp_statuses,
			'posts_per_page' => 100,
			'orderby'        => 'title',
			'order'          => 'ASC',
		);

		// If only password-protected is selected, filter to posts with passwords.
		if ( $need_password_filter && count( $raw_statuses ) === 1 ) {
			$query_args['has_password'] = true;
		}

		$query = new \WP_Query( $query_args );
		$posts = $query->posts;

		if ( empty( $posts ) ) {
			return rest_ensure_response(
				array(
					'success' => true,
					'data'    => array(
						'results'     => array(),
						'duplicates'  => array(),
						'total'       => 0,
						'analyzed_at' => gmdate( 'c' ),
					),
				)
			);
		}

		// If only password-protected is checked alongside others, filter post-query.
		if ( $need_password_filter && count( $raw_statuses ) > 1 ) {
			// Keep all matched posts (password-protected are included because 'publish' is in the status list).
		} elseif ( $need_password_filter && count( $raw_statuses ) === 1 ) {
			// Already filtered via has_password.
		}

		$results   = array();
		$timestamp = gmdate( 'c' );

		foreach ( $posts as $post ) {
			if ( ! $post instanceof \WP_Post ) {
				continue;
			}

			$url = get_permalink( $post->ID );
			if ( empty( $url ) ) {
				$results[] = array(
					'post_id' => $post->ID,
					'title'   => $post->post_title,
					'status'  => 'error',
					'message' => __( 'Could not resolve URL for this post.', 'antimanual' ),
				);
				continue;
			}

			// Build HTML directly from the post to avoid loopback HTTP requests
			// that can time out when PHP workers are limited.
			$html = $this->build_html_from_post( $post, $url );
			if ( is_wp_error( $html ) ) {
				$results[] = array(
					'post_id' => $post->ID,
					'title'   => $post->post_title,
					'url'     => $url,
					'status'  => 'error',
					'message' => $html->get_error_message(),
				);
				continue;
			}

			$audit = $this->run_single_audit( $url, $html, $analysis_mode );
			if ( is_wp_error( $audit ) ) {
				$results[] = array(
					'post_id' => $post->ID,
					'title'   => $post->post_title,
					'url'     => $url,
					'status'  => 'error',
					'message' => $audit->get_error_message(),
				);
				continue;
			}

			$issue_counts = array(
				'critical' => 0,
				'warning'  => 0,
				'info'     => 0,
				'passed'   => 0,
			);
			foreach ( $audit['issues'] as $issue ) {
				$sev = $issue['severity'] ?? 'info';
				if ( isset( $issue_counts[ $sev ] ) ) {
					++$issue_counts[ $sev ];
				}
			}

			$results[] = array(
				'post_id'             => $post->ID,
				'title'               => $post->post_title,
				'url'                 => esc_url( $url ),
				'edit_link'           => get_edit_post_link( $post->ID, 'raw' ) ?: '',
				'post_status'         => $post->post_status,
				'status'              => 'success',
				'seo_score'           => $audit['seo_score'],
				'performance_score'   => $audit['performance_score'],
				'accessibility_score' => $audit['accessibility_score'],
				'issue_counts'        => $issue_counts,
				'issues'              => $audit['issues'],
				'top_issue'           => ! empty( $audit['issues'] ) ? $audit['issues'][0]['title'] : '',
				'analyzed_at'         => $timestamp,
			);
		}

		// Duplicate content detection.
		$duplicates = array();
		if ( $check_duplicates && ! empty( $posts ) ) {
			$duplicates = $this->detect_duplicate_content( $posts );
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'results'     => $results,
					'duplicates'  => $duplicates,
					'total'       => count( $results ),
					'analyzed_at' => $timestamp,
				),
			)
		);
	}

	/**
	 * Detect duplicate content across a set of posts.
	 *
	 * Checks for duplicate titles, meta descriptions, and slugs.
	 * Returns groups of posts that share the same value.
	 *
	 * @param \WP_Post[] $posts Array of post objects.
	 * @return array Array of duplicate groups.
	 */
	private function detect_duplicate_content( array $posts ): array {
		$title_map       = array();
		$description_map = array();
		$slug_map        = array();

		foreach ( $posts as $post ) {
			if ( ! $post instanceof \WP_Post ) {
				continue;
			}

			$post_data = array(
				'post_id'   => $post->ID,
				'title'     => $post->post_title,
				'url'       => get_permalink( $post->ID ) ?: '',
				'edit_link' => get_edit_post_link( $post->ID, 'raw' ) ?: '',
			);

			// Check titles.
			$title_key = mb_strtolower( trim( $post->post_title ) );
			if ( '' !== $title_key ) {
				$title_hash = md5( $title_key );
				if ( ! isset( $title_map[ $title_hash ] ) ) {
					$title_map[ $title_hash ] = array();
				}
				$title_map[ $title_hash ][] = array_merge( $post_data, array( 'value' => $post->post_title ) );
			}

			// Check meta descriptions.
			$seo_desc = $this->get_seo_meta_description( $post );
			$desc     = '' !== $seo_desc ? $seo_desc : trim( (string) $post->post_excerpt );
			if ( '' !== $desc ) {
				$desc_key  = mb_strtolower( $desc );
				$desc_hash = md5( $desc_key );
				if ( ! isset( $description_map[ $desc_hash ] ) ) {
					$description_map[ $desc_hash ] = array();
				}
				$description_map[ $desc_hash ][] = array_merge( $post_data, array( 'value' => $desc ) );
			}

			// Check slugs.
			$slug = $post->post_name;
			if ( '' !== $slug ) {
				$slug_key = mb_strtolower( $slug );
				if ( ! isset( $slug_map[ $slug_key ] ) ) {
					$slug_map[ $slug_key ] = array();
				}
				$slug_map[ $slug_key ][] = array_merge( $post_data, array( 'value' => $slug ) );
			}
		}

		$duplicates = array();

		foreach ( $title_map as $hash => $group ) {
			if ( count( $group ) > 1 ) {
				$duplicates[] = array(
					'title_hash'    => $hash,
					'matched_field' => 'title',
					'posts'         => $group,
				);
			}
		}

		foreach ( $description_map as $hash => $group ) {
			if ( count( $group ) > 1 ) {
				$duplicates[] = array(
					'title_hash'    => $hash,
					'matched_field' => 'meta_description',
					'posts'         => $group,
				);
			}
		}

		foreach ( $slug_map as $key => $group ) {
			if ( count( $group ) > 1 ) {
				$duplicates[] = array(
					'title_hash'    => md5( $key ),
					'matched_field' => 'slug',
					'posts'         => $group,
				);
			}
		}

		return $duplicates;
	}

	/**
	 * Apply auto-fix to multiple posts at once.
	 *
	 * Accepts an array of post IDs and applies the same fixes
	 * as the single auto_fix endpoint to each one.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response
	 */
	public function bulk_auto_fix( $request ) {
		// Enforce Pro Max plan requirement.
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'The SEO Agent requires the Pro Max plan. Please upgrade to access this feature.', 'antimanual' ),
				),
				403
			);
		}

		$payload  = json_decode( $request->get_body(), true );
		$payload  = is_array( $payload ) ? $payload : array();
		$post_ids = isset( $payload['post_ids'] ) && is_array( $payload['post_ids'] )
			? array_map( 'intval', $payload['post_ids'] )
			: array();

		$post_ids = array_values( array_unique( array_filter( $post_ids, fn( $id ) => $id > 0 ) ) );

		if ( empty( $post_ids ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'No valid post IDs provided.', 'antimanual' ),
				)
			);
		}

		if ( count( $post_ids ) > 50 ) {
			$post_ids = array_slice( $post_ids, 0, 50 );
		}

		$results        = array();
		$total_fixed    = 0;
		$total_skipped  = 0;

		foreach ( $post_ids as $post_id ) {
			$post = get_post( $post_id );
			if ( ! $post instanceof \WP_Post || ! current_user_can( 'edit_post', $post_id ) ) {
				$results[] = array(
					'post_id' => $post_id,
					'status'  => 'error',
					'message' => __( 'Post not found or no permission.', 'antimanual' ),
				);
				continue;
			}

			$applied_fixes = array();
			$skipped_fixes = array();
			$title_updated     = false;
			$meta_updated      = false;
			$content_updated   = false;
			$meta_keys_updated = 0;

			$current_title       = trim( (string) $post->post_title );
			$seo_meta_desc       = $this->get_seo_meta_description( $post );
			$has_seo_meta        = '' !== $seo_meta_desc;
			$current_description = $has_seo_meta ? $seo_meta_desc : trim( (string) $post->post_excerpt );
			$current_content     = (string) $post->post_content;
			$current_slug        = (string) $post->post_name;

			// Truncate content for AI prompt.
			$max_content_chars = 8000;
			$content_excerpt   = mb_strlen( $current_content ) > $max_content_chars
				? mb_substr( $current_content, 0, $max_content_chars ) . "\n<!-- TRUNCATED -->"
				: $current_content;

			$ai_prompt = "You are an expert SEO optimizer. Analyze this WordPress post and fix its SEO issues.\n\n"
				. "Current Title: {$current_title}\n"
				. "Current Meta Description: {$current_description}\n"
				. "Current URL Slug: {$current_slug}\n\n"
				. "Post Content:\n"
				. "```html\n"
				. $content_excerpt
				. "\n```\n\n"
				. "Respond with ONLY valid JSON (no markdown, no code fences):\n"
				. "{\n"
				. "  \"focus_keyword\": \"<the single most relevant keyword or key phrase>\",\n"
				. "  \"title\": \"<optimized SEO title, 50-60 characters>\",\n"
				. "  \"meta_description\": \"<optimized meta description, 150-160 characters>\",\n"
				. "  \"slug\": \"<optimized URL slug, lowercase, hyphen-separated, 3-5 words>\",\n"
				. "  \"changes_made\": [\"<list of changes>\"]\n"
				. "}\n\n"
				. "Rules:\n"
				. "- Identify the primary focus keyword and use it in title, description, and slug.\n"
				. "- If the title is already well-optimized, return it unchanged.\n"
				. "- If the meta description is already good, return it unchanged.\n"
				. "- If the slug is already clean and keyword-rich, return it unchanged.\n"
				. "- Keep the same language as the original content.";

			$messages = array(
				array(
					'role'    => 'system',
					'content' => 'You are an SEO optimization expert. Fix on-page SEO issues. Respond with valid JSON only.',
				),
				array(
					'role'    => 'user',
					'content' => $ai_prompt,
				),
			);

			$reply_data = AIProvider::get_reply( $messages );

			if ( isset( $reply_data['error'] ) ) {
				$results[] = array(
					'post_id' => $post_id,
					'title'   => $post->post_title,
					'status'  => 'error',
					'message' => $reply_data['error'],
				);
				continue;
			}

			$reply = is_array( $reply_data ) && isset( $reply_data['reply'] ) ? $reply_data['reply'] : (string) $reply_data;

			if ( empty( $reply ) ) {
				$results[] = array(
					'post_id' => $post_id,
					'title'   => $post->post_title,
					'status'  => 'error',
					'message' => __( 'AI auto-fix failed.', 'antimanual' ),
				);
				continue;
			}

			// Parse AI response.
			$reply   = preg_replace( '/^```(?:json)?\s*/i', '', trim( $reply ) );
			$reply   = preg_replace( '/\s*```$/', '', $reply );
			$ai_data = json_decode( $reply, true );

			if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $ai_data ) ) {
				$results[] = array(
					'post_id' => $post_id,
					'title'   => $post->post_title,
					'status'  => 'error',
					'message' => __( 'Failed to parse AI response.', 'antimanual' ),
				);
				continue;
			}

			$update_payload = array( 'ID' => $post_id );

			// Apply title fix.
			$ai_title = sanitize_text_field( $ai_data['title'] ?? '' );
			if ( ! empty( $ai_title ) && $ai_title !== $current_title ) {
				$update_payload['post_title'] = $ai_title;
				$title_updated                = true;
				$applied_fixes[]              = __( 'Title tag optimized by AI.', 'antimanual' );
			} else {
				$skipped_fixes[] = __( 'Title tag did not need changes.', 'antimanual' );
			}

			// Apply meta description fix.
			$ai_description = sanitize_text_field( $ai_data['meta_description'] ?? '' );
			if ( ! empty( $ai_description ) && $ai_description !== $current_description ) {
				if ( $ai_description !== trim( (string) $post->post_excerpt ) ) {
					$update_payload['post_excerpt'] = $ai_description;
				}
				$meta_updated    = true;
				$applied_fixes[] = __( 'Meta description optimized by AI.', 'antimanual' );
			} else {
				$skipped_fixes[] = __( 'Meta description did not need changes.', 'antimanual' );
			}

			// Apply slug fix.
			$ai_slug = sanitize_title( $ai_data['slug'] ?? '' );
			if ( ! empty( $ai_slug ) && $ai_slug !== $current_slug ) {
				$update_payload['post_name'] = $ai_slug;
				$applied_fixes[]             = __( 'URL slug optimized by AI.', 'antimanual' );
			} else {
				$skipped_fixes[] = __( 'URL slug did not need changes.', 'antimanual' );
			}

			// Apply content fixes via existing helpers.
			$content_changes_applied = false;
			$content_for_runtime     = $current_content;
			$content_result          = $this->apply_content_auto_fixes( $current_content, $post_id );
			if ( ! empty( $content_result['changed'] ) ) {
				$update_payload['post_content'] = $content_result['content'];
				$content_updated                = true;
				$content_changes_applied        = true;
				$content_for_runtime            = (string) $content_result['content'];
				$applied_fixes[]                = __( 'Image and link attributes optimized.', 'antimanual' );
			}

			if ( count( $update_payload ) > 1 ) {
				$updated = wp_update_post( $update_payload, true );
				if ( is_wp_error( $updated ) ) {
					$results[] = array(
						'post_id' => $post_id,
						'title'   => $post->post_title,
						'status'  => 'error',
						'message' => $updated->get_error_message(),
					);
					continue;
				}
			}

			$runtime_scan_content = $this->render_post_content_for_scan( $post, $content_for_runtime );

			$lazy_candidates = $this->count_missing_lazy_attributes( $runtime_scan_content );
			if ( $lazy_candidates > 0 && $this->enable_runtime_content_fix_flag( $post_id, self::META_FORCE_LAZY_LOADING ) ) {
				$content_updated         = true;
				$content_changes_applied = true;
				$applied_fixes[]         = __( 'Lazy-loading enforcement enabled.', 'antimanual' );
			}

			$noopener_candidates = $this->count_missing_noopener_attributes( $runtime_scan_content );
			if ( $noopener_candidates > 0 && $this->enable_runtime_content_fix_flag( $post_id, self::META_FORCE_NOOPENER ) ) {
				$content_updated         = true;
				$content_changes_applied = true;
				$applied_fixes[]         = __( 'Link security attributes added.', 'antimanual' );
			}

			if ( ! $content_changes_applied ) {
				$skipped_fixes[] = __( 'No content fixes needed.', 'antimanual' );
			}

			// Extract and save focus keyword.
			$focus_keyword = sanitize_text_field( $ai_data['focus_keyword'] ?? '' );

			if ( $title_updated || $meta_updated || ! empty( $focus_keyword ) ) {
				$meta_keys_updated = $this->update_existing_seo_meta_keys(
					$post_id,
					$title_updated ? $ai_title : '',
					$meta_updated ? $ai_description : '',
					$focus_keyword
				);
				if ( ! empty( $focus_keyword ) ) {
					$applied_fixes[] = __( 'Focus keyword set for SEO plugins.', 'antimanual' );
				}
			}

			$post_url = get_permalink( $post_id );
			if ( $post_url ) {
				$this->clear_cached_analyses( array( $post_url ) );
			}

			$fixes_count = ( $title_updated ? 1 : 0 ) + ( $meta_updated ? 1 : 0 ) + ( $content_updated ? 1 : 0 );
			$total_fixed  += $fixes_count;
			$total_skipped += count( $skipped_fixes );

			$results[] = array(
				'post_id'       => $post_id,
				'title'         => $post->post_title,
				'url'           => esc_url( $post_url ?: '' ),
				'status'        => 'success',
				'fixes_applied' => count( $applied_fixes ),
				'fixes_skipped' => count( $skipped_fixes ),
				'applied_fixes' => $applied_fixes,
				'updated_fields' => array(
					'title'            => $title_updated,
					'meta_description' => $meta_updated,
					'content'          => $content_updated,
				),
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'results'       => $results,
					'total'         => count( $results ),
					'total_fixed'   => $total_fixed,
					'total_skipped' => $total_skipped,
				),
			)
		);
	}

	/**
	 * Run a single SEO audit using either the quick deterministic method or full AI analysis.
	 *
	 * @param string $url  The URL to audit.
	 * @param string $html The fetched HTML content.
	 * @param string $mode 'quick' for deterministic checks, 'full' for AI analysis.
	 * @return array|\WP_Error The audit result or error.
	 */
	private function run_single_audit( string $url, string $html, string $mode = 'quick' ) {
		if ( 'full' === $mode ) {
			// Truncate HTML to avoid exceeding token limits.
			$max_chars    = 15000;
			$html_excerpt = mb_strlen( $html ) > $max_chars
				? mb_substr( $html, 0, $max_chars ) . "\n<!-- TRUNCATED -->"
				: $html;

			$system_prompt = $this->build_system_prompt();
			$user_prompt   = $this->build_user_prompt( $url, $html_excerpt );

			$messages = array(
				array(
					'role'    => 'system',
					'content' => $system_prompt,
				),
				array(
					'role'    => 'user',
					'content' => $user_prompt,
				),
			);

			$reply_data = AIProvider::get_reply( $messages );

			if ( isset( $reply_data['error'] ) ) {
				return new \WP_Error(
					'ai_failed',
					$reply_data['error']
				);
			}

			$reply = is_array( $reply_data ) && isset( $reply_data['reply'] ) ? $reply_data['reply'] : (string) $reply_data;

			if ( empty( $reply ) ) {
				return new \WP_Error( 'ai_failed', __( 'AI analysis failed.', 'antimanual' ) );
			}

			return $this->parse_ai_response( $reply, $url );
		}

		return $this->build_quick_audit( $url, $html );
	}

	/**
	 * Fetch page HTML content via HTTP.
	 *
	 * @param string $url The URL to fetch.
	 * @return string|\WP_Error The HTML content or error.
	 */
	private function fetch_page_html( string $url ) {
		$response = wp_remote_get(
			$url,
			array(
				'timeout'    => 30,
				'user-agent' => 'Mozilla/5.0 (compatible; AntimanualSEOBot/1.0)',
				'sslverify'  => false,
			)
		);

		if ( is_wp_error( $response ) ) {
			return new \WP_Error(
				'fetch_failed',
				sprintf(
					/* translators: %s: error message */
					__( 'Could not fetch the page: %s', 'antimanual' ),
					$response->get_error_message()
				)
			);
		}

		$status_code = wp_remote_retrieve_response_code( $response );

		if ( $status_code < 200 || $status_code >= 400 ) {
			return new \WP_Error(
				'http_error',
				sprintf(
					/* translators: %d: HTTP status code */
					__( 'Page returned HTTP status %d.', 'antimanual' ),
					$status_code
				)
			);
		}

		return wp_remote_retrieve_body( $response );
	}

	/**
	 * Build a full HTML document from a WP_Post object.
	 *
	 * This avoids loopback HTTP requests (wp_remote_get to the same server)
	 * that often time out in local/limited-worker environments.
	 * The generated HTML mirrors what the theme would output, including
	 * title, meta description, canonical URL, and rendered post content.
	 *
	 * @param \WP_Post $post The post to render.
	 * @param string   $url  The permalink for canonical/meta tags.
	 * @return string|\WP_Error The rendered HTML document or error.
	 */
	private function build_html_from_post( \WP_Post $post, string $url ) {
		$content = (string) $post->post_content;
		if ( '' === trim( $content ) && '' === trim( (string) $post->post_title ) ) {
			return new \WP_Error(
				'empty_post',
				__( 'Post has no content or title to audit.', 'antimanual' )
			);
		}

		// Render post content through WordPress filters (shortcodes, blocks, etc.).
		$rendered_content = $this->render_post_content_for_scan( $post, $content );

		// Apply runtime fixes if they were previously enabled.
		if ( '1' === get_post_meta( $post->ID, self::META_FORCE_LAZY_LOADING, true ) ) {
			$rendered_content = $this->force_lazy_loading_attributes( $rendered_content );
		}
		if ( '1' === get_post_meta( $post->ID, self::META_FORCE_NOOPENER, true ) ) {
			$rendered_content = $this->force_noopener_attributes( $rendered_content );
		}

		// Build the title the same way WordPress would.
		$title = trim( (string) $post->post_title );
		$site_name = get_bloginfo( 'name' );
		if ( '' !== $site_name ) {
			$title .= ' - ' . $site_name;
		}

		// Get meta description from SEO plugins or excerpt.
		$meta_description = $this->get_seo_meta_description( $post );
		if ( '' === $meta_description ) {
			$meta_description = trim( (string) $post->post_excerpt );
		}

		// Get the document language.
		$lang = get_bloginfo( 'language' );
		if ( '' === $lang ) {
			$lang = 'en-US';
		}

		// Build a complete HTML document for the audit engine.
		$html = '<!DOCTYPE html>' . "\n";
		$html .= '<html lang="' . esc_attr( $lang ) . '">' . "\n";
		$html .= '<head>' . "\n";
		$html .= '<meta charset="UTF-8">' . "\n";
		$html .= '<meta name="viewport" content="width=device-width, initial-scale=1">' . "\n";
		$html .= '<title>' . esc_html( $title ) . '</title>' . "\n";

		if ( '' !== $meta_description ) {
			$html .= '<meta name="description" content="' . esc_attr( $meta_description ) . '">' . "\n";
		}

		$html .= '<link rel="canonical" href="' . esc_url( $url ) . '">' . "\n";

		// Add Open Graph tags if available.
		$html .= '<meta property="og:title" content="' . esc_attr( $post->post_title ) . '">' . "\n";
		$html .= '<meta property="og:url" content="' . esc_url( $url ) . '">' . "\n";
		$html .= '<meta property="og:type" content="article">' . "\n";
		if ( '' !== $meta_description ) {
			$html .= '<meta property="og:description" content="' . esc_attr( $meta_description ) . '">' . "\n";
		}

		// Featured image.
		$thumbnail_id = get_post_thumbnail_id( $post->ID );
		if ( $thumbnail_id ) {
			$img_url = wp_get_attachment_url( $thumbnail_id );
			if ( $img_url ) {
				$html .= '<meta property="og:image" content="' . esc_url( $img_url ) . '">' . "\n";
			}
		}

		$html .= '</head>' . "\n";
		$html .= '<body>' . "\n";
		$html .= '<h1>' . esc_html( $post->post_title ) . '</h1>' . "\n";
		$html .= '<div class="entry-content">' . "\n";
		$html .= $rendered_content . "\n";
		$html .= '</div>' . "\n";
		$html .= '</body>' . "\n";
		$html .= '</html>';

		return $html;
	}

	/**
	 * Build the system prompt for AI SEO analysis.
	 *
	 * @return string The system prompt.
	 */
	private function build_system_prompt(): string {
		return "You are an expert SEO auditor and web performance analyst. Your task is to analyze a webpage's HTML and provide a comprehensive SEO audit.\n\n"
			. "You MUST respond with ONLY valid JSON (no markdown, no code fences, no explanation) in this exact structure:\n\n"
			. "{\n"
			. "  \"seo_score\": <number 0-100>,\n"
			. "  \"performance_score\": <number 0-100>,\n"
			. "  \"accessibility_score\": <number 0-100>,\n"
			. "  \"title\": \"<extracted page title>\",\n"
			. "  \"meta_description\": \"<extracted meta description>\",\n"
			. "  \"issues\": [\n"
			. "    {\n"
			. "      \"title\": \"<short issue title>\",\n"
			. "      \"description\": \"<detailed explanation and how to fix>\",\n"
			. "      \"severity\": \"<critical|warning|info|passed>\",\n"
			. "      \"category\": \"<On-Page SEO|Technical SEO|Performance|Accessibility|Security|Mobile>\"\n"
			. "    }\n"
			. "  ],\n"
			. "  \"recommendations\": [\n"
			. "    \"<actionable recommendation string>\"\n"
			. "  ]\n"
			. "}\n\n"
			. "Analysis criteria:\n"
			. "1. **On-Page SEO**: Title tag (length, keywords), meta description (length, relevance), heading hierarchy (H1-H6), image alt text, internal/external links, keyword density, canonical tags.\n"
			. "2. **Technical SEO**: Schema markup, Open Graph tags, Twitter cards, robots meta, sitemap reference, hreflang, URL structure.\n"
			. "3. **Performance**: Inline styles/scripts, render-blocking resources, image optimization hints, lazy loading, CSS/JS minification indicators.\n"
			. "4. **Accessibility**: ARIA labels, form labels, color contrast hints, keyboard navigation, semantic HTML, lang attribute.\n"
			. "5. **Security**: HTTPS usage, mixed content hints, CSP headers indication.\n"
			. "6. **Mobile**: Viewport meta tag, responsive design indicators, touch target sizes.\n\n"
			. "Include at least 5-10 issues covering different categories. Include both problems and passed checks. Provide 3-5 actionable top recommendations.";
	}

	/**
	 * Build the user prompt with the page data.
	 *
	 * @param string $url  The analyzed URL.
	 * @param string $html The page HTML content.
	 * @return string The user prompt.
	 */
	private function build_user_prompt( string $url, string $html ): string {
		return "Analyze the following page for SEO issues:\n\n"
			. "URL: {$url}\n\n"
			. "HTML Content:\n"
			. "```html\n"
			. $html
			. "\n```\n\n"
			. "Provide a complete SEO audit as JSON.";
	}

	/**
	 * Parse the AI response into structured data.
	 *
	 * @param string $reply The raw AI response.
	 * @param string $url   The analyzed URL.
	 * @return array|\WP_Error Parsed result or error.
	 */
	private function parse_ai_response( string $reply, string $url ) {
		// Strip markdown code fences if present.
		$reply = preg_replace( '/^```(?:json)?\s*/i', '', trim( $reply ) );
		$reply = preg_replace( '/\s*```$/', '', $reply );

		$data = json_decode( $reply, true );

		if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
			return new \WP_Error(
				'parse_failed',
				__( 'Failed to parse the AI analysis response. Please try again.', 'antimanual' )
			);
		}

		// Sanitize and validate the response structure.
		return array(
			'url'                 => esc_url( $url ),
			'seo_score'           => $this->clamp_score( $data['seo_score'] ?? 0 ),
			'performance_score'   => $this->clamp_score( $data['performance_score'] ?? 0 ),
			'accessibility_score' => $this->clamp_score( $data['accessibility_score'] ?? 0 ),
			'title'               => sanitize_text_field( $data['title'] ?? '' ),
			'meta_description'    => sanitize_text_field( $data['meta_description'] ?? '' ),
			'issues'              => $this->sanitize_issues( $data['issues'] ?? array() ),
			'recommendations'     => $this->sanitize_recommendations( $data['recommendations'] ?? array() ),
		);
	}

	/**
	 * Extract and decode a JSON array from an AIProvider::get_reply() result.
	 *
	 * Handles the common reply-extraction, markdown-fence stripping, and
	 * json_decode validation shared by AI endpoints that expect array responses.
	 *
	 * @param mixed $reply_data Raw return value from AIProvider::get_reply().
	 * @return array|\WP_Error Decoded array on success, WP_Error on failure.
	 */
	private function parse_ai_json_reply( $reply_data ) {
		if ( isset( $reply_data['error'] ) ) {
			return new \WP_Error( 'ai_error', (string) $reply_data['error'] );
		}

		$reply = is_array( $reply_data ) && isset( $reply_data['reply'] )
			? $reply_data['reply']
			: (string) $reply_data;

		$reply = preg_replace( '/^```(?:json)?\s*/i', '', trim( $reply ) );
		$reply = preg_replace( '/\s*```$/', '', (string) $reply );

		$parsed = json_decode( trim( (string) $reply ), true );

		if ( ! is_array( $parsed ) ) {
			return new \WP_Error(
				'ai_parse_failed',
				__( 'AI returned an unexpected format. Please try again.', 'antimanual' )
			);
		}

		return $parsed;
	}

	/**
	 * Build a deterministic, no-AI SEO audit from raw HTML.
	 *
	 * @param string $url  URL being analyzed.
	 * @param string $html Raw HTML content.
	 * @return array|\WP_Error
	 */
	private function build_quick_audit( string $url, string $html ) {
		$dom = new \DOMDocument();

		$libxml_previous = libxml_use_internal_errors( true );
		$loaded          = $dom->loadHTML(
			'<?xml encoding="utf-8" ?>' . $html,
			LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $libxml_previous );

		if ( ! $loaded ) {
			return new \WP_Error(
				'html_parse_failed',
				__( 'Unable to parse the page HTML for quick analysis.', 'antimanual' )
			);
		}

		$xpath = new \DOMXPath( $dom );
		$host  = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );

		// For internal post/page URLs, scope fixable content checks (img/link)
		// to rendered post content so audit findings align with auto-fix scope.
		$content_scope_xpath = $xpath;
		$scoped_content      = $this->get_scoped_content_for_internal_audit( $url );
		if ( '' !== $scoped_content ) {
			$scope_dom       = new \DOMDocument();
			$scoped_previous = libxml_use_internal_errors( true );
			$scope_loaded    = $scope_dom->loadHTML(
				'<?xml encoding="utf-8" ?><html><body>' . $scoped_content . '</body></html>',
				LIBXML_NOERROR | LIBXML_NOWARNING
			);
			libxml_clear_errors();
			libxml_use_internal_errors( $scoped_previous );

			if ( $scope_loaded ) {
				$content_scope_xpath = new \DOMXPath( $scope_dom );
			}
		}

		$issues = array();
		$scores = array(
			'seo'           => 100,
			'performance'   => 100,
			'accessibility' => 100,
		);

		$add_issue = function ( string $title, string $description, string $severity, string $category ) use ( &$issues, &$scores ) {
			$severity = in_array( $severity, array( 'critical', 'warning', 'info', 'passed' ), true ) ? $severity : 'info';
			$issues[] = array(
				'title'       => sanitize_text_field( $title ),
				'description' => sanitize_text_field( $description ),
				'severity'    => $severity,
				'category'    => sanitize_text_field( $category ),
			);

			if ( 'passed' === $severity ) {
				return;
			}

			$penalty_by_severity = array(
				'critical' => 24,
				'warning'  => 10,
				'info'     => 4,
			);

			$penalty = $penalty_by_severity[ $severity ] ?? 0;

			if ( in_array( $category, array( 'Performance' ), true ) ) {
				$scores['performance'] -= $penalty;
				return;
			}

			if ( in_array( $category, array( 'Accessibility', 'Mobile' ), true ) ) {
				$scores['accessibility'] -= $penalty;
				return;
			}

			$scores['seo'] -= $penalty;
		};

		$title_nodes = $dom->getElementsByTagName( 'title' );
		$title       = $title_nodes->length > 0 ? trim( $title_nodes->item( 0 )->textContent ) : '';
		$title_len   = mb_strlen( $title );

		if ( empty( $title ) ) {
			$add_issue(
				__( 'Missing title tag', 'antimanual' ),
				__( 'Add a unique title tag around 50 to 60 characters and include the primary keyword near the beginning.', 'antimanual' ),
				'critical',
				'On-Page SEO'
			);
		} elseif ( $title_len < 30 || $title_len > 60 ) {
			$add_issue(
				__( 'Title length can be improved', 'antimanual' ),
				sprintf(
					/* translators: %d: title length */
					__( 'Current title length is %d characters. Keep it between 30 and 60 for better SERP visibility.', 'antimanual' ),
					$title_len
				),
				'warning',
				'On-Page SEO'
			);
		} else {
			$add_issue(
				__( 'Title length is in range', 'antimanual' ),
				__( 'The title tag length is within a healthy SEO range.', 'antimanual' ),
				'passed',
				'On-Page SEO'
			);
		}

		$meta_description = '';
		$meta_desc_nodes  = $xpath->query( "//meta[translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='description']" );
		if ( $meta_desc_nodes && $meta_desc_nodes->length > 0 ) {
			$meta_description = trim( (string) $meta_desc_nodes->item( 0 )->getAttribute( 'content' ) );
		}
		$meta_len = mb_strlen( $meta_description );

		if ( empty( $meta_description ) ) {
			$add_issue(
				__( 'Missing meta description', 'antimanual' ),
				__( 'Add a compelling meta description (120 to 160 characters) to improve click-through rates.', 'antimanual' ),
				'warning',
				'On-Page SEO'
			);
		} elseif ( $meta_len < 120 || $meta_len > 160 ) {
			$add_issue(
				__( 'Meta description length can be improved', 'antimanual' ),
				sprintf(
					/* translators: %d: meta description length */
					__( 'Current meta description is %d characters. Aim for 120 to 160 characters.', 'antimanual' ),
					$meta_len
				),
				'info',
				'On-Page SEO'
			);
		} else {
			$add_issue(
				__( 'Meta description length is in range', 'antimanual' ),
				__( 'Meta description length is suitable for most search snippets.', 'antimanual' ),
				'passed',
				'On-Page SEO'
			);
		}

		$h1_nodes = $xpath->query( '//h1' );
		$h1_count = $h1_nodes instanceof \DOMNodeList ? $h1_nodes->length : 0;
		if ( 0 === $h1_count ) {
			$add_issue(
				__( 'Missing H1 heading', 'antimanual' ),
				__( 'Add a single descriptive H1 heading to clarify the page topic for search engines and users.', 'antimanual' ),
				'critical',
				'On-Page SEO'
			);
		} elseif ( $h1_count > 1 ) {
			$add_issue(
				__( 'Multiple H1 headings detected', 'antimanual' ),
				sprintf(
					/* translators: %d: number of H1 tags */
					__( 'Found %d H1 tags. Use one primary H1 and structure the rest with H2/H3 headings.', 'antimanual' ),
					$h1_count
				),
				'warning',
				'On-Page SEO'
			);
		} else {
			$add_issue(
				__( 'Heading hierarchy starts correctly', 'antimanual' ),
				__( 'Exactly one H1 tag was found.', 'antimanual' ),
				'passed',
				'On-Page SEO'
			);
		}

		$canonical_nodes = $xpath->query( "//link[translate(@rel,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='canonical']" );
		$canonical       = '';
		if ( $canonical_nodes && $canonical_nodes->length > 0 ) {
			$canonical = trim( (string) $canonical_nodes->item( 0 )->getAttribute( 'href' ) );
		}

		if ( empty( $canonical ) ) {
			$add_issue(
				__( 'Canonical URL missing', 'antimanual' ),
				__( 'Add a canonical tag to reduce duplicate-content risk and consolidate ranking signals.', 'antimanual' ),
				'warning',
				'Technical SEO'
			);
		} else {
			$add_issue(
				__( 'Canonical URL detected', 'antimanual' ),
				__( 'Canonical tag is present.', 'antimanual' ),
				'passed',
				'Technical SEO'
			);
		}

		$robots_nodes = $xpath->query( "//meta[translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='robots']" );
		$robots       = '';
		if ( $robots_nodes && $robots_nodes->length > 0 ) {
			$robots = strtolower( trim( (string) $robots_nodes->item( 0 )->getAttribute( 'content' ) ) );
		}

		if ( ! empty( $robots ) && false !== strpos( $robots, 'noindex' ) ) {
			$add_issue(
				__( 'Page is set to noindex', 'antimanual' ),
				__( 'The robots meta tag includes noindex, which prevents this page from appearing in search results.', 'antimanual' ),
				'critical',
				'Technical SEO'
			);
		} else {
			$add_issue(
				__( 'No blocking noindex directive detected', 'antimanual' ),
				__( 'No noindex directive was found in the robots meta tag.', 'antimanual' ),
				'passed',
				'Technical SEO'
			);
		}

		$img_nodes           = $content_scope_xpath->query( '//img' );
		$total_images        = $img_nodes instanceof \DOMNodeList ? $img_nodes->length : 0;
		$images_without_alt  = 0;
		$images_without_lazy = 0;
		if ( $img_nodes ) {
			foreach ( $img_nodes as $img_node ) {
				$alt = trim( (string) $img_node->getAttribute( 'alt' ) );
				if ( '' === $alt ) {
					++$images_without_alt;
				}

				$loading = strtolower( trim( (string) $img_node->getAttribute( 'loading' ) ) );
				if ( 'lazy' !== $loading ) {
					++$images_without_lazy;
				}
			}
		}

		if ( $total_images > 0 ) {
			$missing_alt_ratio = $images_without_alt / $total_images;
			if ( 0 === $images_without_alt ) {
				$add_issue(
					__( 'Images have alt text', 'antimanual' ),
					__( 'All images include alt text.', 'antimanual' ),
					'passed',
					'Accessibility'
				);
			} elseif ( $missing_alt_ratio >= 0.5 ) {
				$add_issue(
					__( 'Many images are missing alt text', 'antimanual' ),
					sprintf(
						/* translators: 1: missing alt count, 2: total image count */
						__( '%1$d of %2$d images are missing alt text. Add descriptive alt attributes for accessibility and image SEO.', 'antimanual' ),
						$images_without_alt,
						$total_images
					),
					'critical',
					'Accessibility'
				);
			} else {
				$add_issue(
					__( 'Some images are missing alt text', 'antimanual' ),
					sprintf(
						/* translators: 1: missing alt count, 2: total image count */
						__( '%1$d of %2$d images are missing alt text.', 'antimanual' ),
						$images_without_alt,
						$total_images
					),
					'warning',
					'Accessibility'
				);
			}

			if ( $total_images >= 3 && $images_without_lazy >= 3 ) {
				$add_issue(
					__( 'Images are not consistently lazy-loaded', 'antimanual' ),
					sprintf(
						/* translators: 1: images without lazy loading, 2: total image count */
						__( '%1$d of %2$d images do not use loading=\"lazy\".', 'antimanual' ),
						$images_without_lazy,
						$total_images
					),
					'warning',
					'Performance'
				);
			} else {
				$add_issue(
					__( 'Image lazy-loading looks healthy', 'antimanual' ),
					__( 'Most images use native lazy loading.', 'antimanual' ),
					'passed',
					'Performance'
				);
			}
		} else {
			$add_issue(
				__( 'No images found to audit', 'antimanual' ),
				__( 'No image tags were found on this page.', 'antimanual' ),
				'info',
				'On-Page SEO'
			);
		}

		$viewport_nodes = $xpath->query( "//meta[translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='viewport']" );
		if ( $viewport_nodes && $viewport_nodes->length > 0 ) {
			$add_issue(
				__( 'Viewport meta tag detected', 'antimanual' ),
				__( 'Viewport is configured for mobile rendering.', 'antimanual' ),
				'passed',
				'Mobile'
			);
		} else {
			$add_issue(
				__( 'Missing viewport meta tag', 'antimanual' ),
				__( 'Add a viewport meta tag to improve mobile rendering and usability.', 'antimanual' ),
				'warning',
				'Mobile'
			);
		}

		$doc_element = $dom->documentElement;
		$lang        = null !== $doc_element ? trim( strtolower( (string) $doc_element->getAttribute( 'lang' ) ) ) : '';
		if ( '' === $lang ) {
			$add_issue(
				__( 'Missing html lang attribute', 'antimanual' ),
				__( 'Set the html lang attribute (for example, lang=\"en\") to improve accessibility and indexing context.', 'antimanual' ),
				'warning',
				'Accessibility'
			);
		} else {
			$add_issue(
				__( 'Language attribute detected', 'antimanual' ),
				sprintf(
					/* translators: %s: language code */
					__( 'The html lang attribute is set to "%s".', 'antimanual' ),
					$lang
				),
				'passed',
				'Accessibility'
			);
		}

		$json_ld_nodes = $xpath->query( "//script[translate(@type,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='application/ld+json']" );
		if ( $json_ld_nodes && $json_ld_nodes->length > 0 ) {
			$add_issue(
				__( 'Structured data detected', 'antimanual' ),
				__( 'JSON-LD schema markup appears to be present.', 'antimanual' ),
				'passed',
				'Technical SEO'
			);
		} else {
			$add_issue(
				__( 'No JSON-LD structured data detected', 'antimanual' ),
				__( 'Consider adding schema markup to improve rich result eligibility.', 'antimanual' ),
				'warning',
				'Technical SEO'
			);
		}

		$og_title_nodes = $xpath->query( "//meta[translate(@property,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='og:title']" );
		$og_desc_nodes  = $xpath->query( "//meta[translate(@property,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='og:description']" );
		$og_image_nodes = $xpath->query( "//meta[translate(@property,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz')='og:image']" );
		if ( $og_title_nodes && $og_title_nodes->length > 0 && $og_desc_nodes && $og_desc_nodes->length > 0 ) {
			$add_issue(
				__( 'Open Graph tags detected', 'antimanual' ),
				__( 'Open Graph title and description tags are present.', 'antimanual' ),
				'passed',
				'Technical SEO'
			);
		} else {
			$add_issue(
				__( 'Open Graph tags are incomplete', 'antimanual' ),
				__( 'Add og:title and og:description tags for better social sharing previews.', 'antimanual' ),
				'info',
				'Technical SEO'
			);
		}

		// --- OG image check ---
		if ( ! $og_image_nodes || 0 === $og_image_nodes->length ) {
			$add_issue(
				__( 'Missing Open Graph image', 'antimanual' ),
				__( 'Add an og:image tag to control how the page looks when shared on social platforms. A 1200×630px image is recommended.', 'antimanual' ),
				'warning',
				'Technical SEO'
			);
		} else {
			$add_issue(
				__( 'Open Graph image detected', 'antimanual' ),
				__( 'An og:image tag is present for social media previews.', 'antimanual' ),
				'passed',
				'Technical SEO'
			);
		}

		$scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) );
		if ( 'https' !== $scheme ) {
			$add_issue(
				__( 'URL is not using HTTPS', 'antimanual' ),
				__( 'Serve the page over HTTPS to improve trust and search ranking signals.', 'antimanual' ),
				'critical',
				'Security'
			);
		} else {
			$add_issue(
				__( 'Page uses HTTPS', 'antimanual' ),
				__( 'The URL is served over HTTPS.', 'antimanual' ),
				'passed',
				'Security'
			);
		}

		$links              = $content_scope_xpath->query( '//a[@href]' );
		$internal_links     = 0;
		$external_links     = 0;
		$unsafe_blank_links = 0;
			if ( $links ) {
				foreach ( $links as $link ) {
					$href = trim( (string) $link->getAttribute( 'href' ) );
					if ( '' === $href || 0 === strpos( $href, '#' ) || 0 === strpos( $href, 'javascript:' ) ) {
						continue;
					}

					$scheme = strtolower( (string) wp_parse_url( $href, PHP_URL_SCHEME ) );
					if ( in_array( $scheme, array( 'mailto', 'tel' ), true ) ) {
						continue;
					}

					$is_external = $this->is_external_link_href( $href, $host );
					if ( $is_external ) {
						++$external_links;
					} else {
						++$internal_links;
					}

					$target = strtolower( trim( (string) $link->getAttribute( 'target' ) ) );
					$rel    = strtolower( trim( (string) $link->getAttribute( 'rel' ) ) );
					if ( $is_external && '_blank' === $target && false === strpos( $rel, 'noopener' ) ) {
						++$unsafe_blank_links;
					}
				}
			}

		if ( $unsafe_blank_links > 0 ) {
			$add_issue(
				__( 'External links opened in new tabs need rel=\"noopener\"', 'antimanual' ),
				sprintf(
					/* translators: %d: unsafe links count */
					__( '%d links using target=\"_blank\" are missing rel=\"noopener\".', 'antimanual' ),
					$unsafe_blank_links
				),
				'warning',
				'Security'
			);
		} else {
			$add_issue(
				__( 'New-tab link security looks good', 'antimanual' ),
				__( 'No insecure target=\"_blank\" links were detected.', 'antimanual' ),
				'passed',
				'Security'
			);
		}

		$html_size_bytes = strlen( $html );
		if ( $html_size_bytes > 3000000 ) {
			$add_issue(
				__( 'HTML payload is very large', 'antimanual' ),
				__( 'The page HTML is very large, which can hurt load performance. Consider reducing DOM size and inline markup.', 'antimanual' ),
				'critical',
				'Performance'
			);
		} elseif ( $html_size_bytes > 1500000 ) {
			$add_issue(
				__( 'HTML payload is larger than ideal', 'antimanual' ),
				__( 'Large HTML payloads can slow down first render. Consider simplifying the markup.', 'antimanual' ),
				'warning',
				'Performance'
			);
		} else {
			$add_issue(
				__( 'HTML payload size is reasonable', 'antimanual' ),
				__( 'HTML size appears to be within a reasonable range.', 'antimanual' ),
				'passed',
				'Performance'
			);
		}

		// --- Content length / thin content ---
		$body_nodes   = $xpath->query( '//body' );
		$body_text    = $body_nodes && $body_nodes->length > 0 ? trim( $body_nodes->item( 0 )->textContent ) : '';
		$word_count   = str_word_count( $body_text );

		if ( $word_count < 300 ) {
			$add_issue(
				__( 'Thin content detected', 'antimanual' ),
				sprintf(
					/* translators: %d: word count */
					__( 'The page contains only %d words. Aim for at least 300 words of useful, original content to satisfy user intent.', 'antimanual' ),
					$word_count
				),
				$word_count < 100 ? 'critical' : 'warning',
				'On-Page SEO'
			);
		} else {
			$add_issue(
				__( 'Content length is sufficient', 'antimanual' ),
				sprintf(
					/* translators: %d: word count */
					__( 'The page has %d words, which is above the recommended minimum.', 'antimanual' ),
					$word_count
				),
				'passed',
				'On-Page SEO'
			);
		}

		// --- Text-to-HTML ratio ---
		$text_html_ratio = $this->compute_text_html_ratio( $html, $body_text );
		if ( 'poor' === $text_html_ratio['grade'] ) {
			$add_issue(
				__( 'Low text-to-HTML ratio', 'antimanual' ),
				sprintf(
					/* translators: %s: ratio percentage */
					__( 'Text-to-HTML ratio is %s%%. A ratio below 10%% suggests bloated markup. Reduce inline styles, scripts, and unnecessary wrappers.', 'antimanual' ),
					$text_html_ratio['ratio_pct']
				),
				'warning',
				'Performance'
			);
		} elseif ( 'average' === $text_html_ratio['grade'] ) {
			$add_issue(
				__( 'Text-to-HTML ratio is average', 'antimanual' ),
				sprintf(
					/* translators: %s: ratio percentage */
					__( 'Text-to-HTML ratio is %s%%. Above 25%% is ideal.', 'antimanual' ),
					$text_html_ratio['ratio_pct']
				),
				'info',
				'Performance'
			);
		} else {
			$add_issue(
				__( 'Text-to-HTML ratio is healthy', 'antimanual' ),
				sprintf(
					/* translators: %s: ratio percentage */
					__( 'Text-to-HTML ratio is %s%%, which is good.', 'antimanual' ),
					$text_html_ratio['ratio_pct']
				),
				'passed',
				'Performance'
			);
		}

		// --- Readability scores ---
		$readability = $this->compute_readability_scores( $body_text );
		$fre = $readability['flesch_reading_ease'];

		if ( $word_count >= 100 ) {
			if ( $fre < 30 ) {
				$add_issue(
					__( 'Content is very difficult to read', 'antimanual' ),
					sprintf(
						/* translators: %s: Flesch score */
						__( 'Flesch Reading Ease score is %s (college level). Use shorter sentences and simpler words to improve readability.', 'antimanual' ),
						$fre
					),
					'warning',
					'On-Page SEO'
				);
			} elseif ( $fre >= 60 ) {
				$add_issue(
					__( 'Content readability is good', 'antimanual' ),
					sprintf(
						/* translators: %s: Flesch score */
						__( 'Flesch Reading Ease score is %s, suitable for a wide audience.', 'antimanual' ),
						$fre
					),
					'passed',
					'On-Page SEO'
				);
			} else {
				$add_issue(
					__( 'Content readability could be improved', 'antimanual' ),
					sprintf(
						/* translators: %s: Flesch score */
						__( 'Flesch Reading Ease score is %s. Try simplifying some sentences for a broader audience.', 'antimanual' ),
						$fre
					),
					'info',
					'On-Page SEO'
				);
			}

			if ( $readability['long_sentences'] > 0 ) {
				$add_issue(
					__( 'Long sentences detected', 'antimanual' ),
					sprintf(
						/* translators: %d: number of long sentences */
						__( '%d sentence(s) exceed 35 words. Break them up for easier scanning.', 'antimanual' ),
						$readability['long_sentences']
					),
					'info',
					'On-Page SEO'
				);
			}
		}

		// --- Internal links ---
		if ( 0 === $internal_links ) {
			$add_issue(
				__( 'No internal links found', 'antimanual' ),
				__( 'No internal links were found. Use the Internal Linking tool (Pro Max → Internal Linking) to automatically add contextual internal links.', 'antimanual' ),
				'warning',
				'On-Page SEO'
			);
		} elseif ( $internal_links < 2 ) {
			$add_issue(
				__( 'Few internal links', 'antimanual' ),
				sprintf(
					/* translators: %d: internal link count */
					__( 'Only %d internal link found. Use the Internal Linking tool (Pro Max → Internal Linking) to add more contextual links.', 'antimanual' ),
					$internal_links
				),
				'info',
				'On-Page SEO'
			);
		} else {
			$add_issue(
				__( 'Internal linking looks healthy', 'antimanual' ),
				sprintf(
					/* translators: %d: internal link count */
					__( 'The page has %d internal links.', 'antimanual' ),
					$internal_links
				),
				'passed',
				'On-Page SEO'
			);
		}

		// --- External / outbound links ---
		if ( 0 === $external_links ) {
			$add_issue(
				__( 'No external links found', 'antimanual' ),
				__( 'Add 1\u20133 outbound links to authoritative, relevant sources. This signals content quality and helps establish topical context.', 'antimanual' ),
				'info',
				'On-Page SEO'
			);
		} else {
			$add_issue(
				__( 'Outbound links detected', 'antimanual' ),
				sprintf(
					/* translators: %d: external link count */
					__( 'The page has %d outbound links.', 'antimanual' ),
					$external_links
				),
				'passed',
				'On-Page SEO'
			);
		}

		// --- Heading hierarchy (H2 / H3 structure) ---
		$h2_nodes = $xpath->query( '//h2' );
		$h2_count = $h2_nodes instanceof \DOMNodeList ? $h2_nodes->length : 0;

		$h3_nodes = $xpath->query( '//h3' );
		$h3_count = $h3_nodes instanceof \DOMNodeList ? $h3_nodes->length : 0;

		$h4_nodes = $xpath->query( '//h4' );
		$h4_count = $h4_nodes instanceof \DOMNodeList ? $h4_nodes->length : 0;

		// Collect H2 text for keyword density analysis.
		$h2_texts = [];
		if ( $h2_nodes ) {
			foreach ( $h2_nodes as $h2_node ) {
				$h2_texts[] = trim( $h2_node->textContent );
			}
		}

		if ( 0 === $h2_count && $word_count > 300 ) {
			$add_issue(
				__( 'No H2 sub-headings found', 'antimanual' ),
				__( 'Use H2 headings to break long content into scannable sections. This improves readability and helps search engines understand content structure.', 'antimanual' ),
				'warning',
				'On-Page SEO'
			);
		} elseif ( $h2_count > 0 ) {
			$add_issue(
				__( 'Content uses sub-headings', 'antimanual' ),
				sprintf(
					/* translators: %d: H2 count */
					__( 'Found %d H2 sub-heading(s) for content structure.', 'antimanual' ),
					$h2_count
				),
				'passed',
				'On-Page SEO'
			);
		}

		// --- Heading hierarchy skips (H1 → H3 without H2, etc.) ---
		$all_headings = [];
		for ( $level = 1; $level <= 6; $level++ ) {
			$h_nodes = $xpath->query( '//h' . $level );
			if ( $h_nodes ) {
				foreach ( $h_nodes as $h_node ) {
					$all_headings[] = $level;
				}
			}
		}
		sort( $all_headings );

		$heading_skip_found = false;
		for ( $i = 1; $i < count( $all_headings ); $i++ ) {
			if ( $all_headings[ $i ] - $all_headings[ $i - 1 ] > 1 ) {
				$heading_skip_found = true;
				break;
			}
		}

		if ( $heading_skip_found ) {
			$add_issue(
				__( 'Heading hierarchy has level skips', 'antimanual' ),
				__( 'Headings should follow a logical order (H1 → H2 → H3) without skipping levels. This helps screen readers and search engines understand content structure.', 'antimanual' ),
				'info',
				'On-Page SEO'
			);
		} elseif ( count( $all_headings ) > 1 ) {
			$add_issue(
				__( 'Heading hierarchy is correct', 'antimanual' ),
				__( 'Headings follow a logical order without skipping levels.', 'antimanual' ),
				'passed',
				'On-Page SEO'
			);
		}

		// --- URL / slug length ---
		$url_path    = trim( (string) wp_parse_url( $url, PHP_URL_PATH ), '/' );
		$slug_length = mb_strlen( $url_path );

		if ( $slug_length > 75 ) {
			$add_issue(
				__( 'URL slug is too long', 'antimanual' ),
				sprintf(
					/* translators: %d: slug length */
					__( 'The URL path is %d characters. Keep slugs short, descriptive, and under 75 characters for best SEO performance.', 'antimanual' ),
					$slug_length
				),
				'warning',
				'On-Page SEO'
			);
		} else {
			$add_issue(
				__( 'URL slug length is good', 'antimanual' ),
				__( 'The URL path length is within a healthy range.', 'antimanual' ),
				'passed',
				'On-Page SEO'
			);
		}

		// --- Rank Math focus keyword checks ---
		// Detect focus keyword from Rank Math, Yoast, or AIOSEO meta.
		$focus_keyword = '';
		$focus_post_id = url_to_postid( $url );
		if ( $focus_post_id > 0 ) {
			$fk_meta_keys = [
				'rank_math_focus_keyword',
				'_yoast_wpseo_focuskw',
				'_atml_focus_keyword',
			];
			foreach ( $fk_meta_keys as $fk_key ) {
				$fk_val = get_post_meta( $focus_post_id, $fk_key, true );
				if ( is_string( $fk_val ) && '' !== trim( $fk_val ) ) {
					$focus_keyword = strtolower( trim( $fk_val ) );
					break;
				}
			}
		}

		if ( '' !== $focus_keyword ) {
			$lc_title    = strtolower( $title );
			$lc_meta     = strtolower( $meta_description );
			$lc_url_path = strtolower( $url_path );

			// Focus keyword in SEO title.
			if ( false !== strpos( $lc_title, $focus_keyword ) ) {
				$add_issue(
					__( 'Focus keyword found in SEO title', 'antimanual' ),
					sprintf(
						/* translators: %s: focus keyword */
						__( 'The focus keyword "%s" appears in the SEO title.', 'antimanual' ),
						$focus_keyword
					),
					'passed',
					'On-Page SEO'
				);
			} else {
				$add_issue(
					__( 'Focus keyword missing from SEO title', 'antimanual' ),
					sprintf(
						/* translators: %s: focus keyword */
						__( 'Add the focus keyword "%s" to the SEO title to improve search visibility.', 'antimanual' ),
						$focus_keyword
					),
					'critical',
					'On-Page SEO'
				);
			}

			// Focus keyword in meta description.
			if ( '' !== $lc_meta && false !== strpos( $lc_meta, $focus_keyword ) ) {
				$add_issue(
					__( 'Focus keyword found in meta description', 'antimanual' ),
					__( 'The focus keyword appears in the meta description.', 'antimanual' ),
					'passed',
					'On-Page SEO'
				);
			} else {
				$add_issue(
					__( 'Focus keyword missing from meta description', 'antimanual' ),
					sprintf(
						/* translators: %s: focus keyword */
						__( 'Include the focus keyword "%s" in your meta description for better relevance signaling.', 'antimanual' ),
						$focus_keyword
					),
					'warning',
					'On-Page SEO'
				);
			}

			// Focus keyword in URL.
			$fk_slug = str_replace( ' ', '-', $focus_keyword );
			if ( false !== strpos( $lc_url_path, $fk_slug ) || false !== strpos( $lc_url_path, str_replace( '-', '', $fk_slug ) ) ) {
				$add_issue(
					__( 'Focus keyword found in URL', 'antimanual' ),
					__( 'The focus keyword appears in the URL slug.', 'antimanual' ),
					'passed',
					'On-Page SEO'
				);
			} else {
				$add_issue(
					__( 'Focus keyword missing from URL', 'antimanual' ),
					sprintf(
						/* translators: %s: focus keyword */
						__( 'Include the focus keyword "%s" in the URL slug for improved search rankings.', 'antimanual' ),
						$focus_keyword
					),
					'warning',
					'On-Page SEO'
				);
			}

			// Focus keyword at the beginning of content (first 10%).
			$body_text_lc    = strtolower( $body_text );
			$content_10_pct  = mb_substr( $body_text_lc, 0, max( 200, (int) ( mb_strlen( $body_text_lc ) * 0.10 ) ) );
			if ( false !== strpos( $content_10_pct, $focus_keyword ) ) {
				$add_issue(
					__( 'Focus keyword appears in the beginning of content', 'antimanual' ),
					__( 'The focus keyword is present in the first 10% of the content.', 'antimanual' ),
					'passed',
					'On-Page SEO'
				);
			} else {
				$add_issue(
					__( 'Focus keyword not in the beginning of content', 'antimanual' ),
					sprintf(
						/* translators: %s: focus keyword */
						__( 'Place the focus keyword "%s" in the first paragraph or opening section of your content.', 'antimanual' ),
						$focus_keyword
					),
					'critical',
					'On-Page SEO'
				);
			}

			// Focus keyword found in overall content.
			if ( false !== strpos( $body_text_lc, $focus_keyword ) ) {
				$add_issue(
					__( 'Focus keyword found in content', 'antimanual' ),
					__( 'The focus keyword is used in the page content.', 'antimanual' ),
					'passed',
					'On-Page SEO'
				);
			} else {
				$add_issue(
					__( 'Focus keyword not found in content', 'antimanual' ),
					sprintf(
						/* translators: %s: focus keyword */
						__( 'The focus keyword "%s" was not found in the content. Use it naturally throughout the text.', 'antimanual' ),
						$focus_keyword
					),
					'critical',
					'On-Page SEO'
				);
			}

			// Focus keyword in subheadings (H2/H3).
			$fk_in_subheading = false;
			$subheading_nodes = $xpath->query( '//h2|//h3' );
			if ( $subheading_nodes ) {
				foreach ( $subheading_nodes as $sh_node ) {
					if ( false !== strpos( strtolower( $sh_node->textContent ), $focus_keyword ) ) {
						$fk_in_subheading = true;
						break;
					}
				}
			}

			if ( $fk_in_subheading ) {
				$add_issue(
					__( 'Focus keyword found in subheading', 'antimanual' ),
					__( 'The focus keyword appears in at least one H2/H3 subheading.', 'antimanual' ),
					'passed',
					'On-Page SEO'
				);
			} else {
				$add_issue(
					__( 'Focus keyword not in any subheading', 'antimanual' ),
					sprintf(
						/* translators: %s: focus keyword */
						__( 'Include the focus keyword "%s" in at least one H2 or H3 subheading.', 'antimanual' ),
						$focus_keyword
					),
					'warning',
					'On-Page SEO'
				);
			}
		} elseif ( $focus_post_id > 0 ) {
			$add_issue(
				__( 'No focus keyword set', 'antimanual' ),
				__( 'Set a focus keyword in your SEO plugin to enable keyword-specific SEO analysis. Use Auto Fix to automatically identify and set one.', 'antimanual' ),
				'critical',
				'On-Page SEO'
			);
		}

		// --- Image dimension attributes (CLS prevention) ---
		$images_missing_dimensions = 0;
		if ( $img_nodes ) {
			foreach ( $img_nodes as $img_node ) {
				$width  = trim( (string) $img_node->getAttribute( 'width' ) );
				$height = trim( (string) $img_node->getAttribute( 'height' ) );
				if ( '' === $width || '' === $height ) {
					++$images_missing_dimensions;
				}
			}
		}

		if ( $total_images > 0 && $images_missing_dimensions > 0 ) {
			$add_issue(
				__( 'Images missing width/height attributes', 'antimanual' ),
				sprintf(
					/* translators: 1: missing count, 2: total count */
					__( '%1$d of %2$d images lack explicit width and height attributes, which can cause layout shifts (CLS).', 'antimanual' ),
					$images_missing_dimensions,
					$total_images
				),
				$images_missing_dimensions >= $total_images ? 'warning' : 'info',
				'Performance'
			);
		} elseif ( $total_images > 0 ) {
			$add_issue(
				__( 'Images have dimension attributes', 'antimanual' ),
				__( 'All images include explicit width and height attributes, preventing layout shifts.', 'antimanual' ),
				'passed',
				'Performance'
			);
		}

		// --- Focus keyword diversity in title ---
		if ( ! empty( $title ) ) {
			$title_words  = array_filter( str_word_count( strtolower( $title ), 1 ) );
			$stop_words   = array( 'a', 'an', 'the', 'and', 'or', 'of', 'in', 'on', 'at', 'to', 'for', 'is', 'it', 'by', 'with', 'from', 'as', 'are', 'was', 'be' );
			$title_words  = array_diff( $title_words, $stop_words );
			$unique_words = array_unique( $title_words );

			if ( count( $title_words ) > 0 && count( $title_words ) !== count( $unique_words ) ) {
				$add_issue(
					__( 'Title contains repeated words', 'antimanual' ),
					__( 'The title tag has repeated words. Use diverse, descriptive keywords for a stronger search presence.', 'antimanual' ),
					'info',
					'On-Page SEO'
				);
			}
		}

		$recommendations = array();
		foreach ( $issues as $issue ) {
			if ( ! in_array( $issue['severity'], array( 'critical', 'warning' ), true ) ) {
				continue;
			}
			$recommendations[] = $issue['description'];
		}

		if ( empty( $recommendations ) ) {
			$recommendations[] = __( 'No critical blockers found. Keep monitoring title, metadata, and performance as content changes.', 'antimanual' );
		}

		$recommendations = array_slice( array_values( array_unique( $recommendations ) ), 0, 5 );

		// Sort by severity so critical and warnings appear first.
		$severity_order = array(
			'critical' => 0,
			'warning'  => 1,
			'info'     => 2,
			'passed'   => 3,
		);
		usort(
			$issues,
			function ( $a, $b ) use ( $severity_order ) {
				$a_rank = $severity_order[ $a['severity'] ] ?? 99;
				$b_rank = $severity_order[ $b['severity'] ] ?? 99;
				return $a_rank - $b_rank;
			}
		);

		// --- Compute enhanced analysis data ---
		$kw_density_data = $this->compute_keyword_density(
			$body_text,
			$focus_keyword,
			$title,
			$url_path,
			$h2_texts ?? []
		);

		// Keyword stuffing check.
		if ( $kw_density_data['stuffing_warning'] ) {
			$add_issue(
				__( 'Keyword stuffing detected', 'antimanual' ),
				sprintf(
					/* translators: %s: density percentage */
					__( 'Keyword density is %s%% which exceeds 3%%. This can appear spammy to search engines. Use the keyword naturally.', 'antimanual' ),
					$kw_density_data['density_pct']
				),
				'warning',
				'On-Page SEO'
			);
		}

		$page_stats_data = [
			'h1_count'           => $h1_count,
			'h2_count'           => $h2_count,
			'h3_count'           => $h3_count ?? 0,
			'h4_count'           => $h4_count ?? 0,
			'image_count'        => $total_images,
			'images_without_alt' => $images_without_alt,
			'internal_links'     => $internal_links,
			'external_links'     => $external_links,
			'word_count'         => $word_count,
			'slug_length'        => $slug_length,
			'html_size_kb'       => round( $html_size_bytes / 1024, 1 ),
			'focus_keyword'      => $focus_keyword,
		];

		$content_score_data = $this->compute_content_score(
			$page_stats_data,
			$readability ?? [],
			$title,
			$meta_description,
			(float) $kw_density_data['density_pct'],
			$h3_count ?? 0
		);

		return array(
			'url'                 => esc_url( $url ),
			'seo_score'           => $this->clamp_score( $scores['seo'] ),
			'performance_score'   => $this->clamp_score( $scores['performance'] ),
			'accessibility_score' => $this->clamp_score( $scores['accessibility'] ),
			'title'               => sanitize_text_field( $title ),
			'meta_description'    => sanitize_text_field( $meta_description ),
			'issues'              => $issues,
			'recommendations'     => $this->sanitize_recommendations( $recommendations ),
			'page_stats'          => $page_stats_data,
			'readability'         => $readability ?? [],
			'content_score'       => $content_score_data,
			'keyword_density'     => $kw_density_data,
			'text_html_ratio'     => $text_html_ratio ?? [ 'ratio_pct' => 0, 'grade' => 'average' ],
		);
	}

	/**
	 * Determine whether a title needs auto-fix based on SEO length heuristics.
	 *
	 * @param string $title Post title.
	 * @return bool
	 */
	private function title_needs_fix( string $title ): bool {
		$length = mb_strlen( trim( $title ) );
		return $length < 30 || $length > 60;
	}

	/**
	 * Determine whether a meta description needs auto-fix.
	 *
	 * @param string $description Meta description.
	 * @return bool
	 */
	private function meta_description_needs_fix( string $description ): bool {
		$length = mb_strlen( trim( $description ) );
		return $length < 120 || $length > 160;
	}

	/**
	 * Build an SEO-friendly title candidate.
	 *
	 * @param \WP_Post $post Post object.
	 * @return string
	 */
	private function build_auto_title( \WP_Post $post ): string {
		$title = trim( (string) $post->post_title );

		if ( '' === $title ) {
			if ( preg_match( '/<h1[^>]*>(.*?)<\/h1>/is', (string) $post->post_content, $matches ) ) {
				$title = trim( wp_strip_all_tags( $matches[1] ?? '' ) );
			}
		}

		if ( '' === $title ) {
			$title = trim( (string) get_the_title( $post->ID ) );
		}

		if ( '' === $title ) {
			$title = sprintf(
				/* translators: %d: post id */
				__( 'Page %d', 'antimanual' ),
				$post->ID
			);
		}

		$site_name = trim( (string) get_bloginfo( 'name' ) );
		$candidate = $title;
		if ( mb_strlen( $candidate ) < 30 && ! empty( $site_name ) && false === stripos( $candidate, $site_name ) ) {
			$candidate = $candidate . ' | ' . $site_name;
		}

		if ( mb_strlen( $candidate ) < 30 ) {
			$candidate .= ' - ' . __( 'Complete Guide', 'antimanual' );
		}

		return $this->truncate_plain_text( $candidate, 60 );
	}

	/**
	 * Build an SEO-friendly meta description candidate.
	 *
	 * @param \WP_Post $post         Post object.
	 * @param string   $title_fallback Title fallback.
	 * @return string
	 */
	private function build_auto_meta_description( \WP_Post $post, string $title_fallback = '' ): string {
		$raw_text = trim( wp_strip_all_tags( strip_shortcodes( (string) $post->post_content ) ) );
		$raw_text = preg_replace( '/\s+/u', ' ', $raw_text );
		$raw_text = trim( (string) $raw_text );

		if ( '' === $raw_text ) {
			$raw_text = trim( $title_fallback );
		}

		if ( '' === $raw_text ) {
			return '';
		}

		$description = $this->truncate_plain_text( $raw_text, 160 );

		if ( mb_strlen( $description ) < 120 ) {
			$suffix = __( 'Read more on our site.', 'antimanual' );
			if ( ! empty( $title_fallback ) && false === stripos( $description, $title_fallback ) ) {
				$suffix = sprintf(
					/* translators: %s: title */
					__( 'Learn more about %s.', 'antimanual' ),
					$title_fallback
				);
			}

			$description = trim( $description . ' ' . $suffix );
			$description = $this->truncate_plain_text( $description, 160 );
		}

		if ( mb_strlen( $description ) < 120 ) {
			$site_name = trim( (string) get_bloginfo( 'name' ) );
			if ( ! empty( $site_name ) && false === stripos( $description, $site_name ) ) {
				$description = trim( $description . ' | ' . $site_name );
				$description = $this->truncate_plain_text( $description, 160 );
			}
		}

		return $description;
	}

	/**
	 * Apply safe content-level SEO fixes to img and anchor tags.
	 *
	 * Uses AI to generate descriptive alt text for images missing it.
	 * Falls back to filename-based alt text if the AI call fails.
	 *
	 * @param string $content Post content.
	 * @param int    $post_id Post ID.
	 * @return array
	 */
	private function apply_content_auto_fixes( string $content, int $post_id ): array {
		$updated = $content;
		$stats   = array(
			'alt_added'      => 0,
			'lazy_added'     => 0,
			'noopener_added' => 0,
		);

		// --- Pass 1: collect images that need alt text ---
		$images_needing_alt = array();
		$image_index        = 0;
		preg_replace_callback(
			'/<img\b[^>]*\/?>/i',
			function ( $matches ) use ( &$images_needing_alt, &$image_index ) {
				$tag = $matches[0];
				$alt = trim( $this->get_tag_attribute( $tag, 'alt' ) );
				if ( '' === $alt ) {
					$src = $this->get_tag_attribute( $tag, 'src' );
					$images_needing_alt[ $image_index ] = $src;
				}
				++$image_index;
				return $tag;
			},
			$updated
		);

		// --- Generate AI alt texts in a single batch call ---
		$ai_alt_texts = array();
		if ( ! empty( $images_needing_alt ) ) {
			$ai_alt_texts = $this->generate_ai_alt_texts( $images_needing_alt, $post_id );
		}

		// --- Pass 2: apply alt text ---
		// Note: loading="lazy" is NOT added here because WordPress handles it
		// server-side. Adding it to the saved post_content causes Gutenberg
		// block validation errors ("Attempt recovery" button).
		$image_index = 0;
		$updated     = preg_replace_callback(
			'/<img\b[^>]*\/?>/i',
			function ( $matches ) use ( &$stats, &$image_index, $post_id, $ai_alt_texts ) {
				$tag = $matches[0];

				$alt = trim( $this->get_tag_attribute( $tag, 'alt' ) );
				if ( '' === $alt ) {
					$alt_text = $ai_alt_texts[ $image_index ] ?? $this->build_alt_text_from_src(
						$this->get_tag_attribute( $tag, 'src' ),
						$post_id,
						$image_index + 1
					);
					$tag = $this->upsert_tag_attribute( $tag, 'alt', $alt_text );
					++$stats['alt_added'];
				}

				++$image_index;
				return $tag;
			},
			$updated
		);

		$updated = preg_replace_callback(
			'/<a\b[^>]*>/i',
			function ( $matches ) use ( &$stats ) {
				$tag    = $matches[0];
				$target = strtolower( trim( $this->get_tag_attribute( $tag, 'target' ) ) );
				$href   = trim( $this->get_tag_attribute( $tag, 'href' ) );

				if ( '_blank' !== $target ) {
					return $tag;
				}
				if ( ! $this->is_external_link_href( $href ) ) {
					return $tag;
				}

				$current_rel = trim( strtolower( $this->get_tag_attribute( $tag, 'rel' ) ) );
				$tokens      = array_values( array_filter( preg_split( '/\s+/', $current_rel ?: '' ) ) );
				$original    = $tokens;

				if ( ! in_array( 'noopener', $tokens, true ) ) {
					$tokens[] = 'noopener';
				}
				if ( ! in_array( 'noreferrer', $tokens, true ) ) {
					$tokens[] = 'noreferrer';
				}

				if ( $tokens !== $original ) {
					$tag = $this->upsert_tag_attribute( $tag, 'rel', implode( ' ', $tokens ) );
					++$stats['noopener_added'];
				}

				return $tag;
			},
			$updated
		);

		// Sync Gutenberg block comment JSON with modified HTML attributes.
		$updated = $this->sync_block_comment_attributes( $updated );

		return array(
			'content' => $updated,
			'changed' => $updated !== $content,
			'stats'   => $stats,
		);
	}

	/**
	 * Resolve rendered post content for internal URL quick-audit checks.
	 *
	 * @param string $url URL being audited.
	 * @return string
	 */
	private function get_scoped_content_for_internal_audit( string $url ): string {
		$post_id = url_to_postid( $url );
		if ( $post_id <= 0 ) {
			return '';
		}

		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return '';
		}

		$content = (string) $post->post_content;
		if ( '' === trim( $content ) ) {
			return '';
		}

		$scoped = $this->render_post_content_for_scan( $post, $content );

		if ( '1' === get_post_meta( $post_id, self::META_FORCE_LAZY_LOADING, true ) ) {
			$scoped = $this->force_lazy_loading_attributes( $scoped );
		}

		if ( '1' === get_post_meta( $post_id, self::META_FORCE_NOOPENER, true ) ) {
			$scoped = $this->force_noopener_attributes( $scoped );
		}

		return $scoped;
	}

	/**
	 * Render post content in the context of a specific post.
	 *
	 * @param \WP_Post $scan_post Post object for rendering context.
	 * @param string   $content Raw post content.
	 * @return string
	 */
	private function render_post_content_for_scan( \WP_Post $scan_post, string $content ): string {
		$content = (string) $content;
		if ( '' === trim( $content ) ) {
			return '';
		}

		global $post;
		$previous_post = $post instanceof \WP_Post ? $post : null;
		$post          = $scan_post;

		if ( function_exists( 'setup_postdata' ) ) {
			setup_postdata( $scan_post );
		}

		$rendered = apply_filters( 'the_content', $content );

		if ( $previous_post instanceof \WP_Post ) {
			$post = $previous_post;
			if ( function_exists( 'setup_postdata' ) ) {
				setup_postdata( $previous_post );
			}
		} elseif ( function_exists( 'wp_reset_postdata' ) ) {
			wp_reset_postdata();
		}

		if ( is_string( $rendered ) && '' !== trim( $rendered ) ) {
			return $rendered;
		}

		return $content;
	}

	/**
	 * Determine whether an href points to an external host.
	 *
	 * @param string $href      Link href.
	 * @param string $base_host Optional host to compare against.
	 * @return bool
	 */
	private function is_external_link_href( string $href, string $base_host = '' ): bool {
		$href = trim( $href );
		if ( '' === $href || 0 === strpos( $href, '#' ) || 0 === strpos( $href, 'javascript:' ) ) {
			return false;
		}

		$scheme = strtolower( (string) wp_parse_url( $href, PHP_URL_SCHEME ) );
		if ( in_array( $scheme, array( 'mailto', 'tel' ), true ) ) {
			return false;
		}

		$link_host = strtolower( (string) wp_parse_url( $href, PHP_URL_HOST ) );
		if ( '' === $link_host ) {
			return false;
		}

		$base_host = strtolower( trim( $base_host ) );
		if ( '' === $base_host ) {
			$base_host = strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) );
		}

		if ( '' === $base_host ) {
			return true;
		}

		return $link_host !== $base_host;
	}

	/**
	 * Enable a runtime content fix flag for a post.
	 *
	 * @param int    $post_id  Post ID.
	 * @param string $meta_key Meta key to set.
	 * @return bool True when the flag changed.
	 */
	private function enable_runtime_content_fix_flag( int $post_id, string $meta_key ): bool {
		$current = (string) get_post_meta( $post_id, $meta_key, true );
		if ( '1' === $current ) {
			return false;
		}

		update_post_meta( $post_id, $meta_key, '1' );
		return true;
	}

	/**
	 * Count img tags that do not explicitly use loading="lazy".
	 *
	 * @param string $content HTML content.
	 * @return int
	 */
	private function count_missing_lazy_attributes( string $content ): int {
		$missing = 0;

		preg_replace_callback(
			'/<img\b[^>]*\/?>/i',
			function ( $matches ) use ( &$missing ) {
				$loading = strtolower( trim( $this->get_tag_attribute( $matches[0], 'loading' ) ) );
				if ( 'lazy' !== $loading ) {
					++$missing;
				}
				return $matches[0];
			},
			$content
		);

		return $missing;
	}

	/**
	 * Count target="_blank" links missing rel="noopener".
	 *
	 * @param string $content HTML content.
	 * @return int
	 */
	private function count_missing_noopener_attributes( string $content ): int {
		$missing = 0;

		preg_replace_callback(
			'/<a\b[^>]*>/i',
			function ( $matches ) use ( &$missing ) {
				$target = strtolower( trim( $this->get_tag_attribute( $matches[0], 'target' ) ) );
				$href   = trim( $this->get_tag_attribute( $matches[0], 'href' ) );
				$rel    = strtolower( trim( $this->get_tag_attribute( $matches[0], 'rel' ) ) );
				if ( '_blank' === $target && $this->is_external_link_href( $href ) && false === strpos( $rel, 'noopener' ) ) {
					++$missing;
				}
				return $matches[0];
			},
			$content
		);

		return $missing;
	}

	/**
	 * Force loading="lazy" on rendered img tags.
	 *
	 * @param string $content HTML content.
	 * @return string
	 */
	private function force_lazy_loading_attributes( string $content ): string {
		return preg_replace_callback(
			'/<img\b[^>]*\/?>/i',
			function ( $matches ) {
				$tag     = $matches[0];
				$loading = strtolower( trim( $this->get_tag_attribute( $tag, 'loading' ) ) );
				if ( 'lazy' === $loading ) {
					return $tag;
				}

				return $this->upsert_tag_attribute( $tag, 'loading', 'lazy' );
			},
			$content
		) ?? $content;
	}

	/**
	 * Force rel="noopener noreferrer" for target="_blank" links.
	 *
	 * @param string $content HTML content.
	 * @return string
	 */
	private function force_noopener_attributes( string $content ): string {
		return preg_replace_callback(
			'/<a\b[^>]*>/i',
			function ( $matches ) {
				$tag    = $matches[0];
				$target = strtolower( trim( $this->get_tag_attribute( $tag, 'target' ) ) );
				$href   = trim( $this->get_tag_attribute( $tag, 'href' ) );

				if ( '_blank' !== $target ) {
					return $tag;
				}
				if ( ! $this->is_external_link_href( $href ) ) {
					return $tag;
				}

				$current_rel = trim( strtolower( $this->get_tag_attribute( $tag, 'rel' ) ) );
				$tokens      = array_values( array_filter( preg_split( '/\s+/', $current_rel ?: '' ) ) );

				if ( ! in_array( 'noopener', $tokens, true ) ) {
					$tokens[] = 'noopener';
				}
				if ( ! in_array( 'noreferrer', $tokens, true ) ) {
					$tokens[] = 'noreferrer';
				}

				return $this->upsert_tag_attribute( $tag, 'rel', implode( ' ', $tokens ) );
			},
			$content
		) ?? $content;
	}

	/**
	 * Sync Gutenberg block comment JSON with actual img tag attributes.
	 *
	 * WordPress Gutenberg blocks store metadata in HTML comments
	 * (e.g. <!-- wp:image {"id":123} -->). When we modify the img tag
	 * attributes directly, the block comment JSON must also be updated
	 * to prevent the editor from showing an "Attempt recovery" button.
	 *
	 * @param string $content Post content with potentially modified img tags.
	 * @return string Content with synced block comments.
	 */
	private function sync_block_comment_attributes( string $content ): string {
		return preg_replace_callback(
			'/(?P<comment><!-- wp:image\s+(?P<json>\{(?:[^{}]|\{[^{}]*\})*\})\s*-->)(?P<inner>.*?)(?P<close><!-- \/wp:image -->)/is',
			function ( $matches ) {
				$comment_json = $matches['json'];
				$inner        = $matches['inner'];
				$close        = $matches['close'];

				// Extract the alt attribute from the img tag inside this block.
				if ( ! preg_match( '/<img\b[^>]*\/?>/i', $inner, $img_match ) ) {
					return $matches[0];
				}

				$img_alt = $this->get_tag_attribute( $img_match[0], 'alt' );
				$attrs   = json_decode( $comment_json, true );

				if ( ! is_array( $attrs ) ) {
					return $matches[0];
				}

				$changed = false;

				// Sync alt text.
				$current_alt = $attrs['alt'] ?? '';
				if ( $img_alt !== $current_alt ) {
					if ( '' === $img_alt ) {
						unset( $attrs['alt'] );
					} else {
						$attrs['alt'] = $img_alt;
					}
					$changed = true;
				}

				if ( ! $changed ) {
					return $matches[0];
				}

				$new_json = wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
				return '<!-- wp:image ' . $new_json . ' -->' . $inner . $close;
			},
			$content
		) ?? $content;
	}

	/**
	 * Generate AI-powered alt texts for images in a single batch request.
	 *
	 * Sends image URLs and post context to the AI provider, which returns
	 * descriptive, SEO-friendly alt text for each image.
	 *
	 * @param array $images  Associative array of image_index => src URL.
	 * @param int   $post_id Post ID for context.
	 * @return array Associative array of image_index => alt text string.
	 */
	private function generate_ai_alt_texts( array $images, int $post_id ): array {
		$post_title = trim( (string) get_the_title( $post_id ) );
		$context    = ! empty( $post_title ) ? $post_title : 'Untitled post';

		$image_lines = array();
		foreach ( $images as $index => $src ) {
			$image_lines[] = $index . '. ' . esc_url( $src );
		}
		$image_list = implode( "\n", $image_lines );

		$instructions = 'You are an SEO specialist that writes concise, descriptive image alt text. '
			. 'Alt text should be clear, specific, and under 125 characters. '
			. 'Do not start with "Image of" or "Photo of". '
			. 'Be descriptive about what the image likely shows based on its filename and the page context. '
			. 'Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation.';

		$user_content = 'Generate alt text for the following images on a page titled "' . $context . '".' . "\n\n"
			. 'Images:' . "\n" . $image_list . "\n\n"
			. 'Return a JSON object mapping each image index (as string key) to its alt text string. '
			. 'Example format: {"0": "Sunset over a calm lake", "1": "Team collaborating in a meeting room"}';

		$reply_data = AIProvider::get_reply( $user_content, '', $instructions );

		if ( isset( $reply_data['error'] ) || ( ! is_string( $reply_data ) && ! isset( $reply_data['reply'] ) ) ) {
			return array();
		}

		$reply = is_array( $reply_data ) && isset( $reply_data['reply'] ) ? $reply_data['reply'] : (string) $reply_data;

		$reply = trim( $reply );

		// Strip markdown code fences the AI may wrap around the JSON.
		if ( preg_match( '/```(?:json)?\s*([\s\S]*?)\s*```/', $reply, $fence_match ) ) {
			$reply = trim( $fence_match[1] );
		}

		$parsed = json_decode( $reply, true );

		if ( ! is_array( $parsed ) ) {
			return array();
		}

		$result = array();
		foreach ( $parsed as $key => $value ) {
			$idx = intval( $key );
			if ( isset( $images[ $idx ] ) && is_string( $value ) && '' !== trim( $value ) ) {
				$result[ $idx ] = sanitize_text_field( trim( $value ) );
			}
		}

		return $result;
	}

	/**
	 * Build a fallback alt text string from image src.
	 *
	 * Used when AI alt text generation is unavailable or fails.
	 *
	 * @param string $src         Image src URL.
	 * @param int    $post_id     Post ID.
	 * @param int    $image_index Image index fallback.
	 * @return string
	 */
	private function build_alt_text_from_src( string $src, int $post_id, int $image_index ): string {
		$fallback = sprintf(
			/* translators: %d: image number */
			__( 'Image %d', 'antimanual' ),
			$image_index
		);

		$src = trim( $src );
		if ( '' === $src ) {
			return $fallback;
		}

		$src_without_query = strtok( $src, '?' );
		$src_without_query = is_string( $src_without_query ) ? $src_without_query : $src;

		$path = (string) wp_parse_url( $src_without_query, PHP_URL_PATH );
		$file = basename( $path );
		$stem = pathinfo( $file, PATHINFO_FILENAME );
		$stem = preg_replace( '/[-_]+/', ' ', (string) $stem );
		$stem = preg_replace( '/\s+/', ' ', (string) $stem );
		$stem = trim( (string) $stem );

		if ( '' === $stem ) {
			$post_title = trim( (string) get_the_title( $post_id ) );
			if ( '' !== $post_title ) {
				return sanitize_text_field( $post_title );
			}
			return $fallback;
		}

		return sanitize_text_field( ucwords( $stem ) );
	}

	/**
	 * Get an HTML tag attribute value.
	 *
	 * @param string $tag  HTML tag text.
	 * @param string $attr Attribute name.
	 * @return string
	 */
	private function get_tag_attribute( string $tag, string $attr ): string {
		$attr_escaped = preg_quote( $attr, '/' );

		if ( preg_match( '/\b' . $attr_escaped . '\s*=\s*([\'"])(.*?)\1/i', $tag, $matches ) ) {
			return html_entity_decode( $matches[2], ENT_QUOTES, get_bloginfo( 'charset' ) ?: 'UTF-8' );
		}

		if ( preg_match( '/\b' . $attr_escaped . '\s*=\s*([^\s>"\']+)/i', $tag, $matches ) ) {
			return (string) $matches[1];
		}

		return '';
	}

	/**
	 * Upsert an HTML tag attribute value.
	 *
	 * @param string $tag   HTML tag text.
	 * @param string $attr  Attribute name.
	 * @param string $value Attribute value.
	 * @return string
	 */
	private function upsert_tag_attribute( string $tag, string $attr, string $value ): string {
		$attr_escaped = preg_quote( $attr, '/' );
		$replacement  = sprintf( '%s="%s"', $attr, esc_attr( $value ) );

		if ( preg_match( '/\b' . $attr_escaped . '\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', $tag ) ) {
			return (string) preg_replace(
				'/\b' . $attr_escaped . '\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i',
				$replacement,
				$tag,
				1
			);
		}

		if ( preg_match( '/\/>\s*$/', $tag ) ) {
			return (string) preg_replace( '/\/>\s*$/', ' ' . $replacement . ' />', $tag, 1 );
		}

		if ( preg_match( '/>\s*$/', $tag ) ) {
			return (string) preg_replace( '/>\s*$/', ' ' . $replacement . '>', $tag, 1 );
		}

		return $tag . ' ' . $replacement;
	}

	/**
	 * Truncate plain text without breaking words abruptly.
	 *
	 * @param string $text      Text to truncate.
	 * @param int    $max_chars Max chars.
	 * @return string
	 */
	private function truncate_plain_text( string $text, int $max_chars ): string {
		$text = trim( preg_replace( '/\s+/u', ' ', $text ) ?? '' );
		if ( mb_strlen( $text ) <= $max_chars ) {
			return $text;
		}

		$slice      = trim( mb_substr( $text, 0, $max_chars ) );
		$last_space = mb_strrpos( $slice, ' ' );
		if ( false !== $last_space && $last_space > (int) floor( $max_chars * 0.6 ) ) {
			$slice = mb_substr( $slice, 0, $last_space );
		}

		return trim( $slice );
	}

	/**
	 * Update common SEO plugin meta keys when available.
	 *
	 * @param int    $post_id       Post ID.
	 * @param string $title         SEO title.
	 * @param string $description   SEO description.
	 * @param string $focus_keyword Focus keyword.
	 * @return int Number of meta keys updated.
	 */
	private function update_existing_seo_meta_keys( int $post_id, string $title, string $description, string $focus_keyword = '' ): int {
		$meta_keys = [];

		if ( '' !== $title ) {
			$meta_keys['_yoast_wpseo_title']     = $title;
			$meta_keys['rank_math_title']        = $title;
			$meta_keys['_aioseo_title']          = $title;
			$meta_keys['_seopress_titles_title'] = $title;
		}

		if ( '' !== $description ) {
			$meta_keys['_yoast_wpseo_metadesc']  = $description;
			$meta_keys['rank_math_description']  = $description;
			$meta_keys['_aioseo_description']    = $description;
			$meta_keys['_seopress_titles_desc']  = $description;
		}

		if ( '' !== $focus_keyword ) {
			$meta_keys['rank_math_focus_keyword'] = $focus_keyword;
			$meta_keys['_yoast_wpseo_focuskw']   = $focus_keyword;
			$meta_keys['_atml_focus_keyword']     = $focus_keyword;
		}

		// Detect which SEO plugin is active so we can create meta keys
		// even when they don't exist yet (e.g. "missing meta description").
		$active_prefixes = $this->get_active_seo_meta_prefixes();

		$updated_count = 0;
		foreach ( $meta_keys as $meta_key => $meta_value ) {
			$key_belongs_to_active = empty( $active_prefixes ) || $this->meta_key_matches_prefixes( $meta_key, $active_prefixes );

			// Always upsert _atml_ keys since they belong to our own plugin.
			$is_own_key = 0 === strpos( $meta_key, '_atml_' );

			if ( metadata_exists( 'post', $post_id, $meta_key ) ) {
				update_post_meta( $post_id, $meta_key, sanitize_text_field( $meta_value ) );
				++$updated_count;
			} elseif ( $key_belongs_to_active || $is_own_key ) {
				add_post_meta( $post_id, $meta_key, sanitize_text_field( $meta_value ), true );
				++$updated_count;
			}
		}

		return $updated_count;
	}

	/**
	 * Get the current meta description from SEO plugin meta only.
	 *
	 * Returns the value from the first active SEO plugin meta key
	 * that has a non-empty value, or empty string if none found.
	 *
	 * @param \WP_Post $post Post object.
	 * @return string The SEO plugin meta description, or empty string.
	 */
	private function get_seo_meta_description( \WP_Post $post ): string {
		$seo_meta_keys = array(
			'rank_math_description',
			'_yoast_wpseo_metadesc',
			'_aioseo_description',
			'_seopress_titles_desc',
		);

		foreach ( $seo_meta_keys as $meta_key ) {
			$value = get_post_meta( $post->ID, $meta_key, true );
			if ( is_string( $value ) && '' !== trim( $value ) ) {
				return trim( $value );
			}
		}

		return '';
	}

	/**
	 * Detect which SEO plugins are active and return their meta key prefixes.
	 *
	 * @return string[] Array of meta key prefixes for active SEO plugins.
	 */
	private function get_active_seo_meta_prefixes(): array {
		$prefixes = array();

		if ( defined( 'RANK_MATH_VERSION' ) || class_exists( '\\RankMath\\RankMath' ) ) {
			$prefixes[] = 'rank_math_';
		}

		if ( defined( 'WPSEO_VERSION' ) ) {
			$prefixes[] = '_yoast_wpseo_';
		}

		if ( defined( 'AIOSEO_VERSION' ) || defined( 'AIOSEO_PHP_VERSION_DIR' ) ) {
			$prefixes[] = '_aioseo_';
		}

		if ( defined( 'SEOPRESS_VERSION' ) ) {
			$prefixes[] = '_seopress_';
		}

		return $prefixes;
	}

	/**
	 * Check whether a meta key belongs to any of the given SEO plugin prefixes.
	 *
	 * @param string   $meta_key Meta key to check.
	 * @param string[] $prefixes Active SEO plugin prefixes.
	 * @return bool
	 */
	private function meta_key_matches_prefixes( string $meta_key, array $prefixes ): bool {
		foreach ( $prefixes as $prefix ) {
			if ( 0 === strpos( $meta_key, $prefix ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Clear cached SEO analysis results for a list of URLs.
	 *
	 * @param array $urls URLs to clear.
	 * @return void
	 */
	private function clear_cached_analyses( array $urls ): void {
		foreach ( $urls as $url ) {
			$url = esc_url_raw( trim( (string) $url ) );
			if ( empty( $url ) ) {
				continue;
			}
			delete_transient( $this->get_cache_key( $url ) );
		}
	}

	/**
	 * Build a transient cache key for a URL.
	 *
	 * @param string $url URL to hash.
	 * @return string
	 */
	private function get_cache_key( string $url ): string {
		return self::CACHE_PREFIX . md5( strtolower( trim( $url ) ) );
	}

	/**
	 * Clamp a score value between 0 and 100.
	 *
	 * @param mixed $value The score value.
	 * @return int The clamped score.
	 */
	private function clamp_score( $value ): int {
		return max( 0, min( 100, intval( $value ) ) );
	}

	/**
	 * Sanitize the issues array from the AI response.
	 *
	 * @param array $issues Raw issues array.
	 * @return array Sanitized issues.
	 */
	private function sanitize_issues( array $issues ): array {
		$valid_severities = array( 'critical', 'warning', 'info', 'passed' );
		$sanitized        = array();

		foreach ( $issues as $issue ) {
			if ( ! is_array( $issue ) ) {
				continue;
			}

			$severity = sanitize_text_field( $issue['severity'] ?? 'info' );
			if ( ! in_array( $severity, $valid_severities, true ) ) {
				$severity = 'info';
			}

			$sanitized[] = array(
				'title'       => sanitize_text_field( $issue['title'] ?? '' ),
				'description' => sanitize_text_field( $issue['description'] ?? '' ),
				'severity'    => $severity,
				'category'    => sanitize_text_field( $issue['category'] ?? 'General' ),
			);
		}

		// Sort by severity: critical first, then warning, info, passed.
		$severity_order = array_flip( $valid_severities );
		usort(
			$sanitized,
			function ( $a, $b ) use ( $severity_order ) {
				return ( $severity_order[ $a['severity'] ] ?? 99 ) - ( $severity_order[ $b['severity'] ] ?? 99 );
			}
		);

		return $sanitized;
	}

	/**
	 * Sanitize the recommendations array from the AI response.
	 *
	 * @param array $recommendations Raw recommendations array.
	 * @return array Sanitized recommendations.
	 */
	private function sanitize_recommendations( array $recommendations ): array {
		$sanitized = array();

		foreach ( $recommendations as $rec ) {
			if ( is_string( $rec ) && ! empty( trim( $rec ) ) ) {
				$sanitized[] = sanitize_text_field( $rec );
			}
		}

		return $sanitized;
	}

	/* ================================================================
	 * Phase 1 — Enhanced audit helpers (readability, content score,
	 *           keyword density, text-to-HTML ratio).
	 * ================================================================ */

	/**
	 * Count syllables in an English word (heuristic).
	 *
	 * Uses vowel-group counting with adjustments for silent-e
	 * and common suffixes. Accurate enough for readability formulas.
	 *
	 * @param string $word Single English word.
	 * @return int Estimated syllable count (minimum 1).
	 */
	private function count_syllables_en( string $word ): int {
		$word = strtolower( trim( $word ) );

		if ( mb_strlen( $word ) <= 2 ) {
			return 1;
		}

		$count = preg_match_all( '/[aeiouy]+/', $word );

		// Silent-e at end.
		if ( preg_match( '/e$/', $word ) && ! preg_match( '/le$/', $word ) ) {
			--$count;
		}

		return max( $count, 1 );
	}

	/**
	 * Compute readability scores from plain text.
	 *
	 * Returns Flesch Reading Ease, Flesch-Kincaid Grade Level,
	 * Gunning Fog Index, and sentence structure stats.
	 *
	 * @param string $text Plain body text (no HTML).
	 * @return array{flesch_reading_ease: float, flesch_kincaid_grade: float, gunning_fog: float, avg_sentence_length: float, total_words: int, total_sentences: int, complex_words_pct: float, long_sentences: int, grade_label: string}
	 */
	private function compute_readability_scores( string $text ): array {
		$defaults = [
			'flesch_reading_ease'  => 0.0,
			'flesch_kincaid_grade' => 0.0,
			'gunning_fog'          => 0.0,
			'avg_sentence_length'  => 0.0,
			'total_words'          => 0,
			'total_sentences'      => 0,
			'complex_words_pct'    => 0.0,
			'long_sentences'       => 0,
			'grade_label'          => '',
		];

		// Split into sentences.
		$sentences = preg_split( '/[.!?]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
		$sentences = array_filter(
			array_map( 'trim', $sentences ),
			fn( $s ) => mb_strlen( $s ) > 3
		);

		if ( empty( $sentences ) ) {
			return $defaults;
		}

		// Extract words.
		preg_match_all( '/\b[a-zA-Z]+\b/', $text, $word_matches );
		$words = $word_matches[0] ?? [];

		$num_sentences = count( $sentences );
		$num_words     = count( $words );

		if ( 0 === $num_words || 0 === $num_sentences ) {
			return $defaults;
		}

		$total_syllables = 0;
		$complex_words   = 0;
		foreach ( $words as $w ) {
			$syl = $this->count_syllables_en( $w );
			$total_syllables += $syl;
			if ( $syl >= 3 ) {
				++$complex_words;
			}
		}

		$asl = $num_words / $num_sentences;
		$asw = $total_syllables / $num_words;

		// Flesch-Kincaid Grade Level.
		$fk_grade = 0.39 * $asl + 11.8 * $asw - 15.59;

		// Flesch Reading Ease (0-100 scale).
		$fre = 206.835 - 1.015 * $asl - 84.6 * $asw;

		// Gunning Fog Index.
		$fog = 0.4 * ( $asl + 100 * ( $complex_words / $num_words ) );

		// Long sentences (>35 words).
		$long_sentences = 0;
		foreach ( $sentences as $s ) {
			preg_match_all( '/\b\w+\b/', $s, $sw );
			if ( count( $sw[0] ?? [] ) > 35 ) {
				++$long_sentences;
			}
		}

		// Grade label.
		$grade_label = __( 'Standard', 'antimanual' );
		if ( $fk_grade <= 6 ) {
			$grade_label = __( 'Easy (6th grade)', 'antimanual' );
		} elseif ( $fk_grade <= 8 ) {
			$grade_label = __( 'Fairly Easy (8th grade)', 'antimanual' );
		} elseif ( $fk_grade <= 10 ) {
			$grade_label = __( 'Standard (10th grade)', 'antimanual' );
		} elseif ( $fk_grade <= 12 ) {
			$grade_label = __( 'Fairly Difficult (12th grade)', 'antimanual' );
		} else {
			$grade_label = __( 'Difficult (college level)', 'antimanual' );
		}

		return [
			'flesch_reading_ease'  => round( max( 0, min( 100, $fre ) ), 1 ),
			'flesch_kincaid_grade' => round( max( 0, $fk_grade ), 1 ),
			'gunning_fog'          => round( max( 0, $fog ), 1 ),
			'avg_sentence_length'  => round( $asl, 1 ),
			'total_words'          => $num_words,
			'total_sentences'      => $num_sentences,
			'complex_words_pct'    => round( $complex_words / $num_words * 100, 1 ),
			'long_sentences'       => $long_sentences,
			'grade_label'          => $grade_label,
		];
	}

	/**
	 * Compute a weighted content SEO score (0-100).
	 *
	 * Scoring factors and weights:
	 *   - Title contains keyword (15 pts)
	 *   - Meta description contains keyword (10 pts)
	 *   - H1 contains keyword (10 pts)
	 *   - Keyword density 0.5-2.5% (10 pts)
	 *   - Word count 800+ / 1500+ (15 pts)
	 *   - Readability FK grade 6-12 (10 pts)
	 *   - Heading structure ≥2 H2s + ≥1 H3 (10 pts)
	 *   - Internal links ≥3 (10 pts)
	 *   - External links ≥1 (5 pts)
	 *   - Meta description exists (5 pts)
	 *
	 * @param array  $page_stats  Page statistics from quick audit.
	 * @param array  $readability Readability scores.
	 * @param string $title       Page title.
	 * @param string $meta_desc   Meta description.
	 * @param float  $kw_density  Keyword density percentage.
	 * @param int    $h3_count    Number of H3 headings.
	 * @return array{score: int, breakdown: array}
	 */
	private function compute_content_score(
		array $page_stats,
		array $readability,
		string $title,
		string $meta_desc,
		float $kw_density,
		int $h3_count
	): array {
		$score     = 0;
		$breakdown = [];
		$fk        = $page_stats['focus_keyword'] ?? '';

		// Title contains keyword (15 pts).
		if ( '' !== $fk && false !== stripos( $title, $fk ) ) {
			$score += 15;
			$breakdown[] = [ 'label' => __( 'Title contains keyword', 'antimanual' ), 'points' => 15, 'max' => 15 ];
		} else {
			$pts = '' === $fk ? 0 : 0;
			$breakdown[] = [ 'label' => __( 'Title contains keyword', 'antimanual' ), 'points' => $pts, 'max' => 15 ];
		}

		// Meta description contains keyword (10 pts).
		if ( '' !== $fk && '' !== $meta_desc && false !== stripos( $meta_desc, $fk ) ) {
			$score += 10;
			$breakdown[] = [ 'label' => __( 'Meta description contains keyword', 'antimanual' ), 'points' => 10, 'max' => 10 ];
		} else {
			$breakdown[] = [ 'label' => __( 'Meta description contains keyword', 'antimanual' ), 'points' => 0, 'max' => 10 ];
		}

		// H1 contains keyword (10 pts) — check via page_stats h1_count > 0 and focus keyword presence.
		// We don't have the H1 text here, so award if focus keyword is in title (good proxy for H1).
		if ( '' !== $fk && false !== stripos( $title, $fk ) ) {
			$score += 10;
			$breakdown[] = [ 'label' => __( 'H1 contains keyword', 'antimanual' ), 'points' => 10, 'max' => 10 ];
		} else {
			$breakdown[] = [ 'label' => __( 'H1 contains keyword', 'antimanual' ), 'points' => 0, 'max' => 10 ];
		}

		// Keyword density 0.5-2.5% (10 pts).
		if ( $kw_density >= 0.5 && $kw_density <= 2.5 ) {
			$score += 10;
			$breakdown[] = [ 'label' => sprintf( __( 'Keyword density %.1f%%', 'antimanual' ), $kw_density ), 'points' => 10, 'max' => 10 ];
		} elseif ( $kw_density > 0 ) {
			$score += 5;
			$breakdown[] = [ 'label' => sprintf( __( 'Keyword density %.1f%%', 'antimanual' ), $kw_density ), 'points' => 5, 'max' => 10 ];
		} else {
			$breakdown[] = [ 'label' => __( 'Keyword density', 'antimanual' ), 'points' => 0, 'max' => 10 ];
		}

		// Word count (15 pts).
		$wc = $page_stats['word_count'] ?? 0;
		if ( $wc >= 1500 ) {
			$score += 15;
			$breakdown[] = [ 'label' => sprintf( __( 'Word count: %d', 'antimanual' ), $wc ), 'points' => 15, 'max' => 15 ];
		} elseif ( $wc >= 800 ) {
			$score += 10;
			$breakdown[] = [ 'label' => sprintf( __( 'Word count: %d', 'antimanual' ), $wc ), 'points' => 10, 'max' => 15 ];
		} elseif ( $wc >= 300 ) {
			$score += 5;
			$breakdown[] = [ 'label' => sprintf( __( 'Word count: %d', 'antimanual' ), $wc ), 'points' => 5, 'max' => 15 ];
		} else {
			$breakdown[] = [ 'label' => sprintf( __( 'Word count: %d', 'antimanual' ), $wc ), 'points' => 0, 'max' => 15 ];
		}

		// Readability FK grade 6-12 (10 pts).
		$fk_grade = $readability['flesch_kincaid_grade'] ?? 0;
		if ( $fk_grade >= 6 && $fk_grade <= 12 ) {
			$score += 10;
			$breakdown[] = [ 'label' => sprintf( __( 'Readability FK grade: %.1f', 'antimanual' ), $fk_grade ), 'points' => 10, 'max' => 10 ];
		} elseif ( $fk_grade >= 4 && $fk_grade <= 14 ) {
			$score += 5;
			$breakdown[] = [ 'label' => sprintf( __( 'Readability FK grade: %.1f', 'antimanual' ), $fk_grade ), 'points' => 5, 'max' => 10 ];
		} else {
			$breakdown[] = [ 'label' => sprintf( __( 'Readability FK grade: %.1f', 'antimanual' ), $fk_grade ), 'points' => 0, 'max' => 10 ];
		}

		// Heading structure (10 pts): ≥2 H2s = 5pts, ≥1 H3 = 5pts.
		$h2c = $page_stats['h2_count'] ?? 0;
		$heading_pts = ( $h2c >= 2 ? 5 : 0 ) + ( $h3_count >= 1 ? 5 : 0 );
		$score += $heading_pts;
		$breakdown[] = [ 'label' => sprintf( __( 'Heading structure (H2: %d, H3: %d)', 'antimanual' ), $h2c, $h3_count ), 'points' => $heading_pts, 'max' => 10 ];

		// Internal links ≥3 (10 pts).
		$il = $page_stats['internal_links'] ?? 0;
		if ( $il >= 3 ) {
			$score += 10;
			$breakdown[] = [ 'label' => sprintf( __( 'Internal links: %d', 'antimanual' ), $il ), 'points' => 10, 'max' => 10 ];
		} elseif ( $il >= 1 ) {
			$score += 5;
			$breakdown[] = [ 'label' => sprintf( __( 'Internal links: %d', 'antimanual' ), $il ), 'points' => 5, 'max' => 10 ];
		} else {
			$breakdown[] = [ 'label' => __( 'Internal links: 0', 'antimanual' ), 'points' => 0, 'max' => 10 ];
		}

		// Meta description exists (5 pts).
		if ( '' !== $meta_desc ) {
			$score += 5;
			$breakdown[] = [ 'label' => __( 'Meta description present', 'antimanual' ), 'points' => 5, 'max' => 5 ];
		} else {
			$breakdown[] = [ 'label' => __( 'Meta description present', 'antimanual' ), 'points' => 0, 'max' => 5 ];
		}

		return [
			'score'     => $this->clamp_score( $score ),
			'breakdown' => $breakdown,
		];
	}

	/**
	 * Compute keyword density and placement analysis.
	 *
	 * Returns the density percentage, raw frequency, keyword
	 * placement map (title, H1, H2s, first paragraph, URL),
	 * and top N-gram phrases from the content.
	 *
	 * @param string $body_text  Plain body text.
	 * @param string $focus_kw   Focus keyword (lowercase).
	 * @param string $title      Page title.
	 * @param string $url_path   URL path.
	 * @param array  $h2_texts   Array of H2 heading texts.
	 * @return array{density_pct: float, frequency: int, placement: array, top_phrases: array, stuffing_warning: bool}
	 */
	private function compute_keyword_density(
		string $body_text,
		string $focus_kw,
		string $title,
		string $url_path,
		array $h2_texts
	): array {
		$result = [
			'density_pct'      => 0.0,
			'frequency'        => 0,
			'placement'        => [],
			'top_phrases'      => [],
			'stuffing_warning' => false,
		];

		// Extract words for word count.
		preg_match_all( '/\b[a-zA-Z]+\b/', $body_text, $wm );
		$words       = $wm[0] ?? [];
		$word_count  = count( $words );
		$text_lower  = strtolower( $body_text );

		if ( 0 === $word_count ) {
			return $result;
		}

		// Keyword density.
		if ( '' !== $focus_kw ) {
			$kw_word_count = str_word_count( $focus_kw );
			$kw_frequency  = substr_count( $text_lower, $focus_kw );
			$density       = ( $kw_frequency * $kw_word_count ) / $word_count * 100;

			$result['density_pct']      = round( $density, 2 );
			$result['frequency']        = $kw_frequency;
			$result['stuffing_warning'] = $density > 3.0;

			// Placement map.
			$title_lower = strtolower( $title );
			$url_lower   = strtolower( $url_path );
			$first_200   = mb_substr( $text_lower, 0, min( 200, (int) ( mb_strlen( $text_lower ) * 0.10 ) ) );

			$in_h2 = false;
			foreach ( $h2_texts as $h2t ) {
				if ( false !== stripos( $h2t, $focus_kw ) ) {
					$in_h2 = true;
					break;
				}
			}

			$result['placement'] = [
				'title'           => false !== strpos( $title_lower, $focus_kw ),
				'h1'              => false !== strpos( $title_lower, $focus_kw ),
				'h2'              => $in_h2,
				'first_paragraph' => false !== strpos( $first_200, $focus_kw ),
				'url'             => false !== strpos( $url_lower, str_replace( ' ', '-', $focus_kw ) ),
			];
		}

		// Top 2-gram and 3-gram phrases.
		$lower_words = array_map( 'strtolower', $words );
		$stop_words  = array_flip( [
			'a', 'an', 'the', 'and', 'or', 'of', 'in', 'on', 'at', 'to',
			'for', 'is', 'it', 'by', 'with', 'from', 'as', 'are', 'was',
			'be', 'this', 'that', 'which', 'but', 'not', 'has', 'have',
			'had', 'been', 'will', 'can', 'may', 'its', 'you', 'your',
			'we', 'our', 'they', 'their', 'he', 'she', 'his', 'her',
		] );

		$ngram_counts = [];
		$total_lower  = count( $lower_words );

		for ( $n = 2; $n <= 3; $n++ ) {
			for ( $i = 0; $i <= $total_lower - $n; $i++ ) {
				$gram_words = array_slice( $lower_words, $i, $n );

				// Skip if all words are stop words.
				$non_stop = array_filter(
					$gram_words,
					fn( $w ) => ! isset( $stop_words[ $w ] ) && mb_strlen( $w ) > 2
				);
				if ( count( $non_stop ) < 1 ) {
					continue;
				}

				$phrase = implode( ' ', $gram_words );
				$ngram_counts[ $phrase ] = ( $ngram_counts[ $phrase ] ?? 0 ) + 1;
			}
		}

		// Filter to phrases appearing 2+ times, sort by frequency.
		$ngram_counts = array_filter( $ngram_counts, fn( $c ) => $c >= 2 );
		arsort( $ngram_counts );

		$top_phrases = [];
		$count       = 0;
		foreach ( $ngram_counts as $phrase => $freq ) {
			$top_phrases[] = [ 'phrase' => $phrase, 'count' => $freq ];
			++$count;
			if ( $count >= 10 ) {
				break;
			}
		}
		$result['top_phrases'] = $top_phrases;

		return $result;
	}

	/**
	 * Compute text-to-HTML ratio.
	 *
	 * A ratio below 10% typically indicates bloated markup.
	 * Above 25% is considered very good.
	 *
	 * @param string $html      Raw HTML.
	 * @param string $body_text Extracted plain text.
	 * @return array{ratio_pct: float, grade: string}
	 */
	private function compute_text_html_ratio( string $html, string $body_text ): array {
		$html_len = strlen( $html );
		$text_len = strlen( $body_text );

		if ( 0 === $html_len ) {
			return [ 'ratio_pct' => 0.0, 'grade' => 'poor' ];
		}

		$ratio = ( $text_len / $html_len ) * 100;

		$grade = 'good';
		if ( $ratio < 10 ) {
			$grade = 'poor';
		} elseif ( $ratio < 25 ) {
			$grade = 'average';
		}

		return [
			'ratio_pct' => round( $ratio, 1 ),
			'grade'     => $grade,
		];
	}

	/**
	 * Outdated technology/platform patterns for freshness detection.
	 *
	 * @var array<string,string>
	 */
	private const OUTDATED_SIGNALS = [
		'flash player'       => 'Outdated technology',
		'internet explorer'  => 'Outdated technology',
		'windows xp'         => 'Outdated technology',
		'windows 7'          => 'Outdated technology',
		'python 2'           => 'Outdated technology',
		'php 5'              => 'Outdated technology',
		'google+'            => 'Defunct platform',
		'google plus'        => 'Defunct platform',
		'vine app'           => 'Defunct platform',
	];

	/**
	 * Content Freshness Monitor endpoint.
	 *
	 * Scans published posts and scores them for freshness heuristics.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response
	 */
	public function content_freshness( $request ) {
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$post_type = (string) $request->get_param( 'post_type' );
		$per_page  = (int) $request->get_param( 'per_page' );
		$page      = max( 1, (int) $request->get_param( 'page' ) );
		$sort      = (string) $request->get_param( 'sort' );

		$per_page = min( max( $per_page, 10 ), 100 );

		$args = [
			'post_type'              => $post_type,
			'post_status'            => 'publish',
			'posts_per_page'         => $per_page,
			'paged'                  => $page,
			'fields'                 => 'ids',
			'orderby'                => 'modified',
			'order'                  => 'modified_desc' === $sort ? 'DESC' : 'ASC',
			'no_found_rows'          => false,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
			'ignore_sticky_posts'    => true,
		];

		$query = new \WP_Query( $args );
		$ids   = array_map( 'intval', (array) $query->posts );
		$total = (int) $query->found_posts;
		$now   = time();

		// Prime post caches in one query so per-post helpers (get_the_title,
		// get_permalink, get_edit_post_link) hit the cache instead of running
		// individual lookups.
		if ( ! empty( $ids ) ) {
			_prime_post_caches( $ids, false, false );
		}

		$results = [];
		foreach ( $ids as $post_id ) {
			$scored = $this->score_post_freshness( $post_id, $now );
			if ( null !== $scored ) {
				$results[] = $scored;
			}
		}

		// Within-page score sort. Note: score is computed per page, so this
		// refines the page returned by the modified-date query — it is not a
		// global score sort across the whole post type.
		if ( 'score_asc' === $sort ) {
			usort( $results, fn( $a, $b ) => $a['score'] <=> $b['score'] );
		} elseif ( 'score_desc' === $sort ) {
			usort( $results, fn( $a, $b ) => $b['score'] <=> $a['score'] );
		}

		// Per-page grade distribution. The frontend uses these counts to
		// drive the clickable grade filter cards above the table.
		$grade_counts = [ 'A' => 0, 'B' => 0, 'C' => 0, 'D' => 0 ];
		foreach ( $results as $r ) {
			if ( isset( $grade_counts[ $r['grade'] ] ) ) {
				++$grade_counts[ $r['grade'] ];
			}
		}

		return rest_ensure_response( [
			'success' => true,
			'data'    => [
				'items'       => $results,
				'total'       => $total,
				'page'        => $page,
				'per_page'    => $per_page,
				'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 0,
				'grades'      => $grade_counts,
			],
		] );
	}

	/**
	 * Score a single post for freshness.
	 *
	 * Heuristic scoring (0-100) based on modification age, date references,
	 * outdated signals, and word count / update language.
	 *
	 * @param int $post_id Post ID.
	 * @param int $now     Current UTC timestamp.
	 * @return array|null Freshness data with score, grade, factors, action; null if the post is gone.
	 */
	private function score_post_freshness( int $post_id, int $now ): ?array {
		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return null;
		}

		$title       = get_the_title( $post_id );
		$content     = wp_strip_all_tags( (string) $post->post_content );
		// preg_match_all on \w+ is multibyte-safer than str_word_count, which
		// returns 0 for many non-Latin scripts (CJK, Arabic, etc.).
		$word_count  = preg_match_all( '/\w+/u', $content );
		$word_count  = false === $word_count ? 0 : (int) $word_count;

		$modified_ts = (int) get_post_modified_time( 'U', true, $post );
		if ( $modified_ts <= 0 ) {
			$modified_ts = strtotime( $post->post_modified_gmt ) ?: $now;
		}
		$days_since  = max( 0, (int) floor( ( $now - $modified_ts ) / DAY_IN_SECONDS ) );

		$score   = 50;
		$factors = [];

		// 0. Base score factor (shows the starting 50 points).
		$factors[] = [ 'label' => __( 'Base score', 'antimanual' ), 'points' => 50 ];

		// 1. Modification recency.
		if ( $days_since <= 30 ) {
			$score   += 25;
			$factors[] = [ 'label' => sprintf( __( 'Updated %dd ago', 'antimanual' ), $days_since ), 'points' => 25 ];
		} elseif ( $days_since <= 90 ) {
			$score   += 15;
			$factors[] = [ 'label' => sprintf( __( 'Updated %dd ago', 'antimanual' ), $days_since ), 'points' => 15 ];
		} elseif ( $days_since <= 365 ) {
			$score   += 5;
			$factors[] = [ 'label' => sprintf( __( 'Updated %dd ago', 'antimanual' ), $days_since ), 'points' => 5 ];
		} else {
			$score   -= 15;
			$factors[] = [ 'label' => sprintf( __( 'Not updated in %dd', 'antimanual' ), $days_since ), 'points' => -15 ];
		}

		// 2. Current year reference.
		$current_year = gmdate( 'Y', $now );
		$prev_year    = (string) ( (int) $current_year - 1 );

		if ( false !== strpos( $content, $current_year ) || false !== strpos( $title, $current_year ) ) {
			$score   += 10;
			$factors[] = [ 'label' => sprintf( __( 'References current year (%s)', 'antimanual' ), $current_year ), 'points' => 10 ];
		} elseif ( false !== strpos( $content, $prev_year ) || false !== strpos( $title, $prev_year ) ) {
			$score   += 5;
			$factors[] = [ 'label' => sprintf( __( 'References %s', 'antimanual' ), $prev_year ), 'points' => 5 ];
		}

		// 3. Old year references (2010-2018 ← clearly outdated).
		$content_lower = strtolower( $content );
		if ( preg_match( '/\b(201[0-8])\b/', $content_lower, $m ) ) {
			$score   -= 10;
			$factors[] = [ 'label' => sprintf( __( 'Contains old year reference (%s)', 'antimanual' ), $m[1] ), 'points' => -10 ];
		}

		// 4. Outdated signals.
		foreach ( self::OUTDATED_SIGNALS as $term => $desc ) {
			if ( false !== strpos( $content_lower, $term ) ) {
				$score   -= 10;
				$factors[] = [ 'label' => sprintf( __( 'Outdated: %1$s (%2$s)', 'antimanual' ), $desc, $term ), 'points' => -10 ];
				break; // Only penalise once.
			}
		}

		// 5. "Updated" / "Revised" language.
		if ( preg_match( '/\b(updated|revised|last updated|modified)\b.*\b20\d{2}\b/i', $content ) ) {
			$score   += 5;
			$factors[] = [ 'label' => __( 'Contains update language with date', 'antimanual' ), 'points' => 5 ];
		}

		// 6. Very short content penalty.
		if ( $word_count < 300 ) {
			$score   -= 5;
			$factors[] = [ 'label' => sprintf( __( 'Thin content (%d words)', 'antimanual' ), $word_count ), 'points' => -5 ];
		}

		$score = min( 100, max( 0, $score ) );

		$grade = 'A';
		if ( $score < 40 ) {
			$grade = 'D';
		} elseif ( $score < 60 ) {
			$grade = 'C';
		} elseif ( $score < 80 ) {
			$grade = 'B';
		}

		// Recommended action — aligned with grade boundaries.
		// Grade A/B (score >= 60) → keep, Grade C (score 40-59) → review,
		// Grade D (score < 40) → update/rewrite based on severity.
		$action = 'keep';
		if ( $score < 30 ) {
			$action = 'rewrite';
		} elseif ( $score < 40 ) {
			$action = 'update';
		} elseif ( $score < 60 ) {
			$action = 'review';
		}

		return [
			'post_id'    => $post_id,
			'title'      => $title,
			'edit_url'   => get_edit_post_link( $post_id, 'raw' ),
			'view_url'   => get_permalink( $post_id ),
			'score'      => $score,
			'grade'      => $grade,
			'action'     => $action,
			'days_since' => $days_since,
			'modified'   => get_the_modified_date( 'Y-m-d', $post_id ),
			'published'  => get_the_date( 'Y-m-d', $post_id ),
			'word_count' => $word_count,
			'factors'    => $factors,
		];
	}

	/**
	 * Apply an AI-powered freshness action (review/update/rewrite) to a post.
	 *
	 * Two phases:
	 *   - mode="preview": calls the AI, returns suggestions or new content, does NOT save.
	 *   - mode="apply":   writes the user-confirmed content via wp_update_post (no AI call).
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function freshness_apply( $request ) {
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		if ( function_exists( 'set_time_limit' ) ) {
			set_time_limit( 300 );
		}

		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : [];

		$post_id = absint( $payload['post_id'] ?? 0 );
		$action  = sanitize_key( $payload['action'] ?? '' );
		$mode    = sanitize_key( $payload['mode'] ?? 'preview' );

		$allowed_actions = [ 'review', 'update', 'rewrite' ];
		if ( ! in_array( $action, $allowed_actions, true ) ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'Invalid freshness action.', 'antimanual' ),
			] );
		}

		if ( ! in_array( $mode, [ 'preview', 'apply', 'auto' ], true ) ) {
			$mode = 'preview';
		}

		if ( $post_id <= 0 ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'A valid post ID is required.', 'antimanual' ),
			] );
		}

		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'Post not found.', 'antimanual' ),
			] );
		}

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'You are not allowed to edit this post.', 'antimanual' ),
			] );
		}

		// Apply phase: save user-confirmed content, no AI call.
		if ( 'apply' === $mode ) {
			if ( 'review' === $action ) {
				return rest_ensure_response( [
					'success' => false,
					'message' => __( 'The review action does not modify content.', 'antimanual' ),
				] );
			}

			$new_content = (string) ( $payload['content'] ?? '' );
			if ( '' === trim( $new_content ) ) {
				return rest_ensure_response( [
					'success' => false,
					'message' => __( 'No content supplied to apply.', 'antimanual' ),
				] );
			}

			if ( atml_is_bulk_rewrite_limit_exceeded() ) {
				return rest_ensure_response( [
					'success' => false,
					'message' => __( 'You have reached your monthly bulk rewrite limit. Upgrade to Pro for unlimited actions.', 'antimanual' ),
				] );
			}

			$updated = wp_update_post(
				[
					'ID'           => $post_id,
					'post_content' => $new_content,
				],
				true
			);

			if ( is_wp_error( $updated ) ) {
				return rest_ensure_response( [
					'success' => false,
					'message' => $updated->get_error_message(),
				] );
			}

			if ( ! atml_is_pro() ) {
				\Antimanual\UsageTracker::increment( 'bulk_rewrite' );
			}

			$freshness = $this->score_post_freshness( $post_id, time() );

			return rest_ensure_response( [
				'success' => true,
				'data'    => [
					'action'    => $action,
					'post_id'   => $post_id,
					'applied'   => true,
					'summary'   => __( 'Post content updated successfully.', 'antimanual' ),
					'freshness' => $freshness,
				],
			] );
		}

		// Preview phase: call AI.
		$title       = get_the_title( $post_id );
		$raw_content = (string) $post->post_content;
		$max_chars   = 12000;
		$excerpt     = mb_strlen( $raw_content ) > $max_chars
			? mb_substr( $raw_content, 0, $max_chars ) . "\n<!-- TRUNCATED -->"
			: $raw_content;

		$current_year = gmdate( 'Y' );
		$modified     = get_the_modified_date( 'Y-m-d', $post_id );

		if ( 'review' === $action ) {
			$system = 'You are a senior SEO and content editor. You audit WordPress posts for staleness and return structured, actionable review notes as strict JSON. Never wrap the JSON in code fences.';
			$user   = "Audit this WordPress post for content freshness and return recommendations.\n\n"
				. "Title: {$title}\n"
				. "Last modified: {$modified}\n"
				. "Current year: {$current_year}\n\n"
				. "Post content:\n```html\n{$excerpt}\n```\n\n"
				. "Respond with ONLY valid JSON in this exact shape:\n"
				. "{\n"
				. "  \"summary\": \"<one-sentence overall assessment>\",\n"
				. "  \"suggestions\": [\n"
				. "    { \"area\": \"<e.g. Statistics, Examples, Dates, Tone, Links>\", \"issue\": \"<what is stale or weak>\", \"fix\": \"<concrete action to take>\" }\n"
				. "  ]\n"
				. "}\n\n"
				. "Rules:\n"
				. "- Return 3-7 high-signal suggestions, ordered by impact.\n"
				. "- Be specific: reference concrete sentences, dates, or stats where possible.\n"
				. "- Do NOT rewrite the post. Only describe issues and fixes.";

			$reply_data = AIProvider::get_reply( [
				[ 'role' => 'system', 'content' => $system ],
				[ 'role' => 'user',   'content' => $user ],
			] );

			if ( is_array( $reply_data ) && isset( $reply_data['error'] ) ) {
				return rest_ensure_response( [
					'success' => false,
					'message' => $reply_data['error'],
				] );
			}

			$reply = is_array( $reply_data ) && isset( $reply_data['reply'] ) ? $reply_data['reply'] : (string) $reply_data;
			$reply = trim( (string) $reply );
			$reply = preg_replace( '/^```(?:json)?\s*/i', '', $reply );
			$reply = preg_replace( '/\s*```$/', '', $reply );

			$parsed = json_decode( $reply, true );
			if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $parsed ) ) {
				return rest_ensure_response( [
					'success' => false,
					'message' => __( 'Failed to parse AI review response.', 'antimanual' ),
				] );
			}

			$suggestions = [];
			foreach ( (array) ( $parsed['suggestions'] ?? [] ) as $s ) {
				if ( ! is_array( $s ) ) {
					continue;
				}
				$suggestions[] = [
					'area'  => sanitize_text_field( (string) ( $s['area'] ?? '' ) ),
					'issue' => sanitize_text_field( (string) ( $s['issue'] ?? '' ) ),
					'fix'   => sanitize_text_field( (string) ( $s['fix'] ?? '' ) ),
				];
			}

			return rest_ensure_response( [
				'success' => true,
				'data'    => [
					'action'      => 'review',
					'post_id'     => $post_id,
					'summary'     => sanitize_text_field( (string) ( $parsed['summary'] ?? '' ) ),
					'suggestions' => $suggestions,
					'applied'     => false,
				],
			] );
		}

		// update / rewrite — AI returns new Gutenberg content.
		if ( atml_is_bulk_rewrite_limit_exceeded() ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'You have reached your monthly bulk rewrite limit. Upgrade to Pro for unlimited actions.', 'antimanual' ),
			] );
		}

		if ( 'update' === $action ) {
			$instructions = "You are an expert WordPress content editor. You refresh stale posts while preserving their structure and voice.\n\n"
				. "Goals for this task:\n"
				. "1. Update outdated dates, years, statistics, and examples to reflect current ({$current_year}) information.\n"
				. "2. Replace dead terminology, deprecated tools, or superseded best practices.\n"
				. "3. Tighten weak sentences and fix awkward phrasing, but DO NOT change the topic, intent, or structure.\n"
				. "4. Preserve ALL WordPress block markup (<!-- wp:... --> ... <!-- /wp:... -->), shortcodes, embeds, and HTML.\n"
				. "5. Do NOT add or remove sections unless strictly necessary for accuracy.\n"
				. "6. Return ONLY the updated WordPress post content. No commentary, no code fences.";
		} else { // rewrite
			$instructions = "You are an expert WordPress content editor. You perform comprehensive rewrites of stale posts to bring them up to current standards.\n\n"
				. "Goals for this task:\n"
				. "1. Rewrite the post for clarity, flow, and current relevance ({$current_year}).\n"
				. "2. Preserve the original topic, target audience, and search intent.\n"
				. "3. Modernize examples, statistics, terminology, and recommended practices.\n"
				. "4. Keep a similar overall length unless the original is clearly too thin or bloated.\n"
				. "5. Preserve ALL WordPress block markup (<!-- wp:... --> ... <!-- /wp:... -->), shortcodes, embeds, and HTML structure at the block level.\n"
				. "6. Return ONLY the rewritten WordPress post content. No commentary, no code fences.";
		}

		$user_msg = "POST TITLE: {$title}\n"
			. "LAST MODIFIED: {$modified}\n"
			. "CURRENT YEAR: {$current_year}\n\n"
			. "ORIGINAL POST CONTENT:\n"
			. "```html\n"
			. $excerpt
			. "\n```";

		$reply = AIProvider::get_reply( [
			[ 'role' => 'system', 'content' => $instructions ],
			[ 'role' => 'user',   'content' => $user_msg ],
		] );

		if ( ! is_string( $reply ) ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => is_array( $reply ) && isset( $reply['error'] ) ? $reply['error'] : __( 'Failed to generate content.', 'antimanual' ),
			] );
		}

		$new_content = \Antimanual\AIResponseCleaner::clean_gutenberg_content( $reply );

		if ( '' === trim( $new_content ) ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'AI returned empty content.', 'antimanual' ),
			] );
		}

		// Auto mode: save the generated content directly and return the updated freshness record.
		if ( 'auto' === $mode ) {
			$updated = wp_update_post(
				[
					'ID'           => $post_id,
					'post_content' => $new_content,
				],
				true
			);

			if ( is_wp_error( $updated ) ) {
				return rest_ensure_response( [
					'success' => false,
					'message' => $updated->get_error_message(),
				] );
			}

			if ( ! atml_is_pro() ) {
				\Antimanual\UsageTracker::increment( 'bulk_rewrite' );
			}

			$freshness = $this->score_post_freshness( $post_id, time() );

			$summary = 'update' === $action
				? __( 'Dates, stats, and examples refreshed.', 'antimanual' )
				: __( 'Full rewrite applied.', 'antimanual' );

			return rest_ensure_response( [
				'success' => true,
				'data'    => [
					'action'    => $action,
					'post_id'   => $post_id,
					'applied'   => true,
					'summary'   => $summary,
					'freshness' => $freshness,
				],
			] );
		}

		$summary = 'update' === $action
			? __( 'Dates, stats, and examples refreshed. Review before applying.', 'antimanual' )
			: __( 'Full rewrite generated. Review before applying.', 'antimanual' );

		return rest_ensure_response( [
			'success' => true,
			'data'    => [
				'action'      => $action,
				'post_id'     => $post_id,
				'new_content' => $new_content,
				'summary'     => $summary,
				'applied'     => false,
			],
		] );
	}

	/* ====================================================================
	 * Enhanced Auto-Fix: Title / Meta Description variants (1.5)
	 * ==================================================================== */

	/**
	 * CTR power words used to score title variants.
	 *
	 * @var string[]
	 */
	private const CTR_POWER_WORDS = [
		'best', 'ultimate', 'essential', 'proven', 'simple', 'easy', 'quick',
		'top', 'new', 'guide', 'complete', 'free', 'how', 'why', 'secret',
		'powerful', 'amazing', 'effective', 'step-by-step', 'must-know',
	];

	/**
	 * Generate multiple title + meta description variants for a post.
	 *
	 * Returns a scored list so the user can pick the strongest variant
	 * and apply it. No DB writes — this is a preview/suggestion endpoint.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response
	 */
	public function suggest_variants( $request ) {
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : [];

		$post_id = intval( $payload['post_id'] ?? 0 );
		$url     = esc_url_raw( trim( $payload['url'] ?? '' ) );
		if ( $post_id <= 0 && '' !== $url ) {
			$post_id = url_to_postid( $url );
		}

		if ( $post_id <= 0 ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'A valid internal post/page is required.', 'antimanual' ),
			] );
		}

		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'Selected post could not be found.', 'antimanual' ),
			] );
		}

		$current_title = trim( (string) $post->post_title );
		$current_desc  = $this->get_seo_meta_description( $post );
		if ( '' === $current_desc ) {
			$current_desc = trim( (string) $post->post_excerpt );
		}
		$content = wp_strip_all_tags( (string) $post->post_content );
		$excerpt = mb_substr( $content, 0, 6000 );

		$ai_prompt = "You are an expert SEO copywriter. Generate SEO title and meta description variants for this post.\n\n"
			. "Current Title: {$current_title}\n"
			. "Current Meta Description: {$current_desc}\n\n"
			. "Post Content Excerpt:\n{$excerpt}\n\n"
			. "Respond with ONLY valid JSON in this exact structure:\n"
			. "{\n"
			. "  \"focus_keyword\": \"<primary keyword>\",\n"
			. "  \"titles\": [\n"
			. "    \"<variant 1: include keyword, 50-60 chars, use a number or power word>\",\n"
			. "    \"<variant 2: question/curiosity angle, 50-60 chars>\",\n"
			. "    \"<variant 3: benefit-driven with action verb, 50-60 chars>\",\n"
			. "    \"<variant 4: list-style with year if appropriate, 50-60 chars>\"\n"
			. "  ],\n"
			. "  \"descriptions\": [\n"
			. "    \"<variant 1: 140-160 chars, benefit-first with call to action>\",\n"
			. "    \"<variant 2: 140-160 chars, question-and-answer style>\",\n"
			. "    \"<variant 3: 140-160 chars, feature-focused>\"\n"
			. "  ]\n"
			. "}\n\n"
			. "Rules:\n"
			. "- Keep the language of the original content.\n"
			. "- Each title MUST include the focus keyword.\n"
			. "- Each description MUST include the focus keyword naturally.\n"
			. "- No markdown, no code fences, only JSON.";

		$messages = [
			[ 'role' => 'system', 'content' => 'You are an SEO copywriter. Always reply with valid JSON only.' ],
			[ 'role' => 'user',   'content' => $ai_prompt ],
		];

		$reply_data = AIProvider::get_reply( $messages );
		if ( isset( $reply_data['error'] ) ) {
			return rest_ensure_response( [ 'success' => false, 'message' => $reply_data['error'] ] );
		}

		$reply = is_array( $reply_data ) && isset( $reply_data['reply'] ) ? $reply_data['reply'] : (string) $reply_data;
		$reply = preg_replace( '/^```(?:json)?\s*/i', '', trim( $reply ) );
		$reply = preg_replace( '/\s*```$/', '', $reply );
		$data  = json_decode( $reply, true );

		if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'Failed to parse AI response.', 'antimanual' ),
			] );
		}

		$focus_keyword = sanitize_text_field( $data['focus_keyword'] ?? '' );
		$titles_raw    = is_array( $data['titles'] ?? null ) ? $data['titles'] : [];
		$descs_raw     = is_array( $data['descriptions'] ?? null ) ? $data['descriptions'] : [];

		$title_variants = [];
		foreach ( $titles_raw as $t ) {
			$text = sanitize_text_field( (string) $t );
			if ( '' === $text ) {
				continue;
			}
			$title_variants[] = $this->score_title_variant( $text, $focus_keyword );
		}

		$desc_variants = [];
		foreach ( $descs_raw as $d ) {
			$text = sanitize_text_field( (string) $d );
			if ( '' === $text ) {
				continue;
			}
			$desc_variants[] = $this->score_meta_description_variant( $text, $focus_keyword );
		}

		usort( $title_variants, fn( $a, $b ) => $b['score'] - $a['score'] );
		usort( $desc_variants, fn( $a, $b ) => $b['score'] - $a['score'] );

		return rest_ensure_response( [
			'success' => true,
			'data'    => [
				'post_id'       => $post_id,
				'focus_keyword' => $focus_keyword,
				'current'       => [
					'title'       => $current_title,
					'description' => $current_desc,
				],
				'titles'        => $title_variants,
				'descriptions'  => $desc_variants,
			],
		] );
	}

	/**
	 * Apply a chosen title or meta description variant to a post.
	 *
	 * Writes to post_title / post_excerpt and to all active SEO plugin
	 * meta keys (Rank Math, Yoast, AIOSEO, SEOPress).
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response
	 */
	public function apply_variant( $request ) {
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : [];

		$post_id = intval( $payload['post_id'] ?? 0 );
		$title   = isset( $payload['title'] ) ? sanitize_text_field( (string) $payload['title'] ) : '';
		$desc    = isset( $payload['meta_description'] ) ? sanitize_text_field( (string) $payload['meta_description'] ) : '';

		if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'You are not allowed to edit this post.', 'antimanual' ),
			] );
		}

		if ( '' === $title && '' === $desc ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'No variant provided.', 'antimanual' ),
			] );
		}

		$update = [ 'ID' => $post_id ];
		if ( '' !== $title ) {
			$update['post_title'] = $title;
		}
		if ( '' !== $desc ) {
			$update['post_excerpt'] = $desc;
		}
		$updated = wp_update_post( $update, true );
		if ( is_wp_error( $updated ) ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => $updated->get_error_message(),
			] );
		}

		$meta_keys_updated = $this->update_existing_seo_meta_keys( $post_id, $title, $desc );

		return rest_ensure_response( [
			'success' => true,
			'data'    => [
				'post_id'             => $post_id,
				'title_updated'       => '' !== $title,
				'description_updated' => '' !== $desc,
				'meta_keys_updated'   => $meta_keys_updated,
			],
		] );
	}

	/**
	 * Score a title variant based on CTR heuristics.
	 *
	 * Scoring factors:
	 * - Length (50-60 ideal)
	 * - Focus keyword presence + position
	 * - Power words
	 * - Numbers / current year
	 *
	 * @param string $title         Candidate title.
	 * @param string $focus_keyword The focus keyword.
	 * @return array{text:string, score:int, length:int, reasons:string[]}
	 */
	private function score_title_variant( string $title, string $focus_keyword ): array {
		$score   = 0;
		$reasons = [];
		$length  = mb_strlen( $title );
		$lower   = mb_strtolower( $title );

		if ( $length >= 50 && $length <= 60 ) {
			$score     += 30;
			$reasons[] = __( 'Ideal length (50-60 chars)', 'antimanual' );
		} elseif ( $length >= 40 && $length <= 65 ) {
			$score     += 18;
			$reasons[] = __( 'Acceptable length', 'antimanual' );
		} elseif ( $length > 65 ) {
			$score     += 5;
			$reasons[] = __( 'Will be truncated in SERP', 'antimanual' );
		} else {
			$reasons[] = __( 'Too short', 'antimanual' );
		}

		if ( '' !== $focus_keyword ) {
			$kw = mb_strtolower( $focus_keyword );
			$pos = mb_strpos( $lower, $kw );
			if ( false !== $pos ) {
				if ( $pos <= 10 ) {
					$score     += 25;
					$reasons[] = __( 'Keyword near start', 'antimanual' );
				} else {
					$score     += 15;
					$reasons[] = __( 'Keyword present', 'antimanual' );
				}
			} else {
				$reasons[] = __( 'Missing focus keyword', 'antimanual' );
			}
		}

		$power_hits = 0;
		foreach ( self::CTR_POWER_WORDS as $word ) {
			if ( false !== mb_strpos( $lower, $word ) ) {
				++$power_hits;
			}
		}
		if ( $power_hits > 0 ) {
			$score     += min( 20, $power_hits * 10 );
			$reasons[] = sprintf(
				/* translators: %d: number of power words */
				_n( '%d power word', '%d power words', $power_hits, 'antimanual' ),
				$power_hits
			);
		}

		if ( preg_match( '/\b\d+\b/', $title ) ) {
			$score     += 10;
			$reasons[] = __( 'Contains a number', 'antimanual' );
		}

		$current_year = gmdate( 'Y' );
		if ( false !== strpos( $title, $current_year ) ) {
			$score     += 10;
			$reasons[] = sprintf( __( 'References %s', 'antimanual' ), $current_year );
		}

		if ( preg_match( '/[?!:]/', $title ) ) {
			$score     += 5;
			$reasons[] = __( 'Uses punctuation hook', 'antimanual' );
		}

		$score = min( 100, max( 0, $score ) );

		return [
			'text'    => $title,
			'score'   => $score,
			'length'  => $length,
			'reasons' => $reasons,
		];
	}

	/**
	 * Score a meta description variant.
	 *
	 * @param string $desc          Candidate description.
	 * @param string $focus_keyword The focus keyword.
	 * @return array{text:string, score:int, length:int, reasons:string[]}
	 */
	private function score_meta_description_variant( string $desc, string $focus_keyword ): array {
		$score   = 0;
		$reasons = [];
		$length  = mb_strlen( $desc );
		$lower   = mb_strtolower( $desc );

		if ( $length >= 140 && $length <= 160 ) {
			$score     += 35;
			$reasons[] = __( 'Ideal length (140-160)', 'antimanual' );
		} elseif ( $length >= 120 && $length <= 170 ) {
			$score     += 20;
			$reasons[] = __( 'Acceptable length', 'antimanual' );
		} elseif ( $length > 170 ) {
			$score     += 5;
			$reasons[] = __( 'May be truncated in SERP', 'antimanual' );
		} else {
			$reasons[] = __( 'Too short', 'antimanual' );
		}

		if ( '' !== $focus_keyword ) {
			if ( false !== mb_strpos( $lower, mb_strtolower( $focus_keyword ) ) ) {
				$score     += 30;
				$reasons[] = __( 'Includes focus keyword', 'antimanual' );
			} else {
				$reasons[] = __( 'Missing focus keyword', 'antimanual' );
			}
		}

		$action_verbs = [ 'learn', 'discover', 'find out', 'get', 'start', 'build', 'create', 'try', 'master', 'save' ];
		foreach ( $action_verbs as $verb ) {
			if ( false !== mb_strpos( $lower, $verb ) ) {
				$score     += 10;
				$reasons[] = __( 'Uses action verb', 'antimanual' );
				break;
			}
		}

		if ( preg_match( '/[?!.]/', $desc ) ) {
			$score += 5;
		}

		if ( preg_match( '/\b\d+\b/', $desc ) ) {
			$score     += 10;
			$reasons[] = __( 'Contains a number', 'antimanual' );
		}

		$score = min( 100, max( 0, $score ) );

		return [
			'text'    => $desc,
			'score'   => $score,
			'length'  => $length,
			'reasons' => $reasons,
		];
	}

	/* ====================================================================
	 * Schema / Structured Data Generator (2.2)
	 * ==================================================================== */

	/**
	 * Detect existing JSON-LD, Microdata, and RDFa structured data.
	 *
	 * Accepts either a post_id (for local posts) or a URL.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response
	 */
	public function schema_detect( $request ) {
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : [];

		$post_id = intval( $payload['post_id'] ?? 0 );
		$url     = esc_url_raw( trim( $payload['url'] ?? '' ) );

		$html = '';
		if ( $post_id > 0 ) {
			$post = get_post( $post_id );
			if ( $post instanceof \WP_Post ) {
				$permalink = get_permalink( $post_id );
				$html      = $permalink ? $this->fetch_page_html( $permalink ) : '';
				if ( is_wp_error( $html ) || '' === $html ) {
					$html = $this->build_html_from_post( $post, (string) $permalink );
				}
				if ( '' === $url ) {
					$url = (string) $permalink;
				}
			}
		} elseif ( '' !== $url ) {
			$fetched = $this->fetch_page_html( $url );
			$html    = is_wp_error( $fetched ) ? '' : $fetched;
		}

		if ( '' === $html ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'Could not retrieve page HTML to inspect.', 'antimanual' ),
			] );
		}

		$jsonld    = $this->extract_jsonld_blocks( $html );
		$microdata = $this->extract_microdata_types( $html );
		$rdfa      = $this->extract_rdfa_types( $html );

		$saved = [];
		if ( $post_id > 0 ) {
			$saved = $this->read_saved_schemas( $post_id );
		}

		return rest_ensure_response( [
			'success' => true,
			'data'    => [
				'url'       => $url,
				'jsonld'    => $jsonld,
				'microdata' => $microdata,
				'rdfa'      => $rdfa,
				'saved'     => $saved,
				'has_any'   => ! empty( $jsonld ) || ! empty( $microdata ) || ! empty( $rdfa ) || ! empty( $saved ),
			],
		] );
	}

	/**
	 * Lightweight endpoint that returns only the persisted JSON-LD schemas
	 * for a post — no remote HTML fetch.
	 *
	 * Called automatically when a post is selected in the UI so the editor
	 * always knows the current saved list and cannot accidentally overwrite
	 * it on save.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response
	 */
	public function schema_list( $request ) {
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : [];

		$post_id = absint( $payload['post_id'] ?? 0 );
		if ( $post_id <= 0 || ! get_post( $post_id ) instanceof \WP_Post ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'Post not found.', 'antimanual' ),
			] );
		}

		return rest_ensure_response( [
			'success' => true,
			'data'    => [ 'saved' => $this->read_saved_schemas( $post_id ) ],
		] );
	}

	/**
	 * Read saved JSON-LD schemas for a post.
	 *
	 * Transparently migrates legacy single-string payloads to the new
	 * array shape so older posts keep rendering without manual intervention.
	 *
	 * @param int $post_id Post ID.
	 * @return array<int,array{id:string,type:string,jsonld:string}>
	 */
	private function read_saved_schemas( int $post_id ): array {
		$stored = get_post_meta( $post_id, self::META_SCHEMA_JSON, true );
		if ( ! is_string( $stored ) || '' === trim( $stored ) ) {
			return [];
		}

		$decoded = json_decode( $stored, true );
		if ( ! is_array( $decoded ) ) {
			return [];
		}

		// Legacy: raw JSON-LD object was stored. Wrap it into a single entry.
		if ( isset( $decoded['@type'] ) || isset( $decoded['@graph'] ) ) {
			$type = isset( $decoded['@type'] )
				? ( is_array( $decoded['@type'] ) ? implode( ',', $decoded['@type'] ) : (string) $decoded['@type'] )
				: 'Graph';
			return [
				[
					'id'     => 'legacy-0',
					'type'   => $type,
					'jsonld' => $stored,
				],
			];
		}

		$out = [];
		foreach ( $decoded as $idx => $entry ) {
			if ( ! is_array( $entry ) || empty( $entry['jsonld'] ) ) {
				continue;
			}
			$jsonld = (string) $entry['jsonld'];
			$parsed = json_decode( $jsonld, true );
			if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $parsed ) ) {
				continue;
			}
			$type = isset( $entry['type'] ) && is_string( $entry['type'] ) && '' !== $entry['type']
				? $entry['type']
				: ( isset( $parsed['@type'] )
					? ( is_array( $parsed['@type'] ) ? implode( ',', $parsed['@type'] ) : (string) $parsed['@type'] )
					: 'Schema' );
			$id = isset( $entry['id'] ) && is_string( $entry['id'] ) && '' !== $entry['id']
				? $entry['id']
				: 'sch-' . (int) $idx . '-' . substr( md5( $jsonld ), 0, 8 );
			$out[] = [
				'id'     => $id,
				'type'   => $type,
				'jsonld' => $jsonld,
			];
		}
		return $out;
	}

	/**
	 * Persist the saved-schemas array to a post's meta.
	 *
	 * @param int   $post_id Post ID.
	 * @param array $schemas Array of { id, type, jsonld } entries.
	 * @return bool True on success (including no-op clears).
	 */
	private function write_saved_schemas( int $post_id, array $schemas ): bool {
		if ( empty( $schemas ) ) {
			delete_post_meta( $post_id, self::META_SCHEMA_JSON );
			return true;
		}
		$payload = wp_json_encode( array_values( $schemas ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		if ( ! is_string( $payload ) ) {
			return false;
		}
		return false !== update_post_meta( $post_id, self::META_SCHEMA_JSON, wp_slash( $payload ) );
	}

	/**
	 * Parse all <script type="application/ld+json"> blocks from an HTML document.
	 *
	 * @param string $html The page HTML.
	 * @return array<int,array{type:string,raw:string,parsed:mixed}>
	 */
	private function extract_jsonld_blocks( string $html ): array {
		$out = [];
		if ( '' === $html ) {
			return $out;
		}

		if ( ! preg_match_all(
			'/<script[^>]*type\s*=\s*["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/is',
			$html,
			$matches
		) ) {
			return $out;
		}

		foreach ( $matches[1] as $raw ) {
			$trimmed = trim( html_entity_decode( $raw, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
			if ( '' === $trimmed ) {
				continue;
			}
			$parsed = json_decode( $trimmed, true );
			$type   = 'Unknown';
			if ( is_array( $parsed ) ) {
				if ( isset( $parsed['@type'] ) ) {
					$type = is_array( $parsed['@type'] ) ? implode( ', ', $parsed['@type'] ) : (string) $parsed['@type'];
				} elseif ( isset( $parsed[0]['@type'] ) ) {
					$type = is_array( $parsed[0]['@type'] ) ? implode( ', ', $parsed[0]['@type'] ) : (string) $parsed[0]['@type'];
				}
			}
			$out[] = [
				'type'   => $type,
				'raw'    => $trimmed,
				'parsed' => $parsed,
			];
		}
		return $out;
	}

	/**
	 * Extract schema.org Microdata types (itemtype URLs) from HTML.
	 *
	 * @param string $html The page HTML.
	 * @return string[]
	 */
	private function extract_microdata_types( string $html ): array {
		$types = [];
		if ( preg_match_all( '/itemtype\s*=\s*["\']([^"\']+)["\']/i', $html, $m ) ) {
			foreach ( $m[1] as $url ) {
				$name = preg_replace( '#.*schema\.org/#i', '', $url );
				if ( ! in_array( $name, $types, true ) ) {
					$types[] = $name;
				}
			}
		}
		return $types;
	}

	/**
	 * Extract RDFa schema typeof values from HTML.
	 *
	 * @param string $html The page HTML.
	 * @return string[]
	 */
	private function extract_rdfa_types( string $html ): array {
		$types = [];
		if ( preg_match_all( '/typeof\s*=\s*["\']([^"\']+)["\']/i', $html, $m ) ) {
			foreach ( $m[1] as $v ) {
				$clean = trim( preg_replace( '/^(schema:|)/i', '', $v ) );
				if ( '' !== $clean && ! in_array( $clean, $types, true ) ) {
					$types[] = $clean;
				}
			}
		}
		return $types;
	}

	/**
	 * Generate JSON-LD schema for the requested type.
	 *
	 * Supported types: Article, FAQPage, HowTo, Product, BreadcrumbList.
	 * The Article / BreadcrumbList / HowTo types can be produced deterministically;
	 * FAQPage uses AI to extract Q&A pairs from content; Product uses AI to draft
	 * descriptions and offers structure.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response
	 */
	public function schema_generate( $request ) {
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : [];

		$post_id     = intval( $payload['post_id'] ?? 0 );
		$schema_type = sanitize_key( $payload['schema_type'] ?? 'article' );

		if ( $post_id <= 0 ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'A valid post is required.', 'antimanual' ),
			] );
		}

		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'Post not found.', 'antimanual' ),
			] );
		}

		$schema = null;
		$notes  = [];
		switch ( $schema_type ) {
			case 'article':
				$schema = $this->build_article_schema( $post );
				break;
			case 'faqpage':
			case 'faq':
				$schema = $this->build_faq_schema( $post );
				break;
			case 'howto':
				$schema = $this->build_howto_schema( $post );
				break;
			case 'product':
				$schema = $this->build_product_schema( $post, $notes );
				break;
			case 'breadcrumb':
			case 'breadcrumblist':
				$schema = $this->build_breadcrumb_schema( $post );
				break;
			case 'organization':
				$schema = $this->build_organization_schema();
				break;
			case 'localbusiness':
			case 'local_business':
				$schema = $this->build_localbusiness_schema();
				break;
			case 'recipe':
				$schema = $this->build_recipe_schema( $post );
				break;
			case 'videoobject':
			case 'video':
				$schema = $this->build_videoobject_schema( $post );
				break;
			case 'event':
				$schema = $this->build_event_schema( $post );
				break;
			case 'website':
			case 'web_site':
				$schema = $this->build_website_schema();
				break;
			default:
				return rest_ensure_response( [
					'success' => false,
					'message' => __( 'Unsupported schema type.', 'antimanual' ),
				] );
		}

		if ( is_wp_error( $schema ) ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => $schema->get_error_message(),
			] );
		}

		if ( empty( $schema ) ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'Not enough content to generate this schema type.', 'antimanual' ),
			] );
		}

		$validation = $this->validate_schema( $schema_type, $schema );

		return rest_ensure_response( [
			'success' => true,
			'data'    => [
				'schema_type' => $schema_type,
				'jsonld'      => wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ),
				'parsed'      => $schema,
				'validation'  => $validation,
				'notes'       => $notes,
			],
		] );
	}

	/**
	 * Save a JSON-LD payload to a post's meta so it can be injected into <head>.
	 *
	 * Accepts raw JSON (validated) and stores it verbatim. An empty payload
	 * removes the saved meta.
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response
	 */
	public function schema_save( $request ) {
		if ( ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : [];

		$post_id = intval( $payload['post_id'] ?? 0 );

		if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'You are not allowed to edit this post.', 'antimanual' ),
			] );
		}

		// Two accepted shapes:
		// 1. { schemas: [ { id?, type?, jsonld } ] }  — replaces all saved schemas.
		// 2. { jsonld: "…" }                          — legacy single-payload save (replaces all).
		$entries = null;
		if ( isset( $payload['schemas'] ) && is_array( $payload['schemas'] ) ) {
			$entries = $payload['schemas'];
		} elseif ( array_key_exists( 'jsonld', $payload ) ) {
			$legacy = trim( (string) $payload['jsonld'] );
			$entries = '' === $legacy ? [] : [ [ 'jsonld' => $legacy ] ];
		} else {
			return rest_ensure_response( [
				'success' => false,
				'message' => __( 'Missing schemas payload.', 'antimanual' ),
			] );
		}

		// Clear all.
		if ( empty( $entries ) ) {
			$this->write_saved_schemas( $post_id, [] );
			return rest_ensure_response( [
				'success' => true,
				'data'    => [ 'saved' => [], 'cleared' => true ],
			] );
		}

		$clean = [];
		$seen  = [];
		foreach ( $entries as $idx => $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$jsonld = trim( (string) ( $entry['jsonld'] ?? '' ) );
			if ( '' === $jsonld ) {
				continue;
			}
			$parsed = json_decode( $jsonld, true );
			if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $parsed ) || empty( $parsed ) ) {
				return rest_ensure_response( [
					'success' => false,
					'message' => sprintf(
						/* translators: %d: schema index */
						__( 'Invalid JSON-LD payload in schema #%d.', 'antimanual' ),
						(int) $idx + 1
					),
				] );
			}

			$type = isset( $entry['type'] ) && is_string( $entry['type'] ) && '' !== $entry['type']
				? sanitize_text_field( $entry['type'] )
				: ( isset( $parsed['@type'] )
					? ( is_array( $parsed['@type'] ) ? implode( ',', array_map( 'sanitize_text_field', $parsed['@type'] ) ) : sanitize_text_field( (string) $parsed['@type'] ) )
					: 'Schema' );

			$id = isset( $entry['id'] ) && is_string( $entry['id'] ) && '' !== $entry['id']
				? sanitize_key( $entry['id'] )
				: '';
			if ( '' === $id || isset( $seen[ $id ] ) ) {
				$id = 'sch-' . (int) $idx . '-' . substr( md5( $jsonld . microtime( true ) ), 0, 8 );
			}
			$seen[ $id ] = true;

			$clean[] = [
				'id'     => $id,
				'type'   => $type,
				'jsonld' => wp_json_encode(
					$parsed,
					JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
				),
			];
		}

		$this->write_saved_schemas( $post_id, $clean );

		return rest_ensure_response( [
			'success' => true,
			'data'    => [ 'saved' => $clean, 'cleared' => false ],
		] );
	}

	/**
	 * Inject saved JSON-LD schema(s) into <head> for singular posts.
	 *
	 * Emits one <script type="application/ld+json"> per saved entry. Each
	 * payload is validated and re-encoded so malformed stored data cannot
	 * poison the page output.
	 */
	public function inject_saved_schema(): void {
		if ( ! is_singular() ) {
			return;
		}
		$post_id = get_queried_object_id();
		if ( $post_id <= 0 ) {
			return;
		}
		$schemas = $this->read_saved_schemas( $post_id );
		if ( empty( $schemas ) ) {
			return;
		}
		foreach ( $schemas as $entry ) {
			$raw    = (string) ( $entry['jsonld'] ?? '' );
			$parsed = json_decode( $raw, true );
			if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $parsed ) || empty( $parsed ) ) {
				continue;
			}
			$encoded = wp_json_encode( $parsed, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
			if ( ! is_string( $encoded ) || '' === $encoded ) {
				continue;
			}
			// Prevent </script> breakout inside JSON-LD content.
			$encoded = str_replace( '</', '<\/', $encoded );
			echo "\n<script type=\"application/ld+json\">" . $encoded . "</script>\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		}
	}

	/**
	 * Build Article schema for a post.
	 *
	 * @param \WP_Post $post The post object.
	 * @return array<string,mixed>
	 */
	private function build_article_schema( \WP_Post $post ): array {
		$permalink = get_permalink( $post );
		$thumb     = get_the_post_thumbnail_url( $post, 'full' );
		$excerpt   = has_excerpt( $post )
			? get_the_excerpt( $post )
			: wp_trim_words( wp_strip_all_tags( $post->post_content ), 35, '…' );

		$author_name = get_the_author_meta( 'display_name', $post->post_author );
		$site_name   = get_bloginfo( 'name' );

		$schema = [
			'@context'         => 'https://schema.org',
			'@type'            => 'Article',
			'headline'         => get_the_title( $post ),
			'description'      => $excerpt,
			'mainEntityOfPage' => [
				'@type' => 'WebPage',
				'@id'   => $permalink,
			],
			'datePublished'    => get_the_date( 'c', $post ),
			'dateModified'     => get_the_modified_date( 'c', $post ),
			'author'           => [
				'@type' => 'Person',
				'name'  => $author_name ?: $site_name,
			],
			'publisher'        => [
				'@type' => 'Organization',
				'name'  => $site_name,
			],
		];

		if ( $thumb ) {
			$schema['image'] = $thumb;
		}

		return $schema;
	}

	/**
	 * Build BreadcrumbList schema for a post.
	 *
	 * @param \WP_Post $post The post object.
	 * @return array<string,mixed>
	 */
	private function build_breadcrumb_schema( \WP_Post $post ): array {
		$items = [];
		$position = 1;

		$items[] = [
			'@type'    => 'ListItem',
			'position' => $position++,
			'name'     => __( 'Home', 'antimanual' ),
			'item'     => home_url( '/' ),
		];

		// Include primary category for posts.
		$categories = get_the_category( $post->ID );
		if ( ! empty( $categories ) && $categories[0] instanceof \WP_Term ) {
			$items[] = [
				'@type'    => 'ListItem',
				'position' => $position++,
				'name'     => $categories[0]->name,
				'item'     => get_category_link( $categories[0]->term_id ),
			];
		}

		$items[] = [
			'@type'    => 'ListItem',
			'position' => $position,
			'name'     => get_the_title( $post ),
			'item'     => get_permalink( $post ),
		];

		return [
			'@context'        => 'https://schema.org',
			'@type'           => 'BreadcrumbList',
			'itemListElement' => $items,
		];
	}

	/**
	 * Build FAQPage schema by detecting Q&A pairs in the post content.
	 *
	 * Tries two strategies in order:
	 * 1. Parse heading/paragraph pairs where a heading ends in "?".
	 * 2. Fall back to AI extraction if no deterministic matches.
	 *
	 * @param \WP_Post $post The post object.
	 * @return array<string,mixed>|\WP_Error
	 */
	private function build_faq_schema( \WP_Post $post ) {
		$pairs = $this->extract_faq_pairs_from_content( (string) $post->post_content );

		if ( empty( $pairs ) ) {
			$pairs = $this->extract_faq_pairs_via_ai( (string) $post->post_content );
		}

		if ( empty( $pairs ) ) {
			return new \WP_Error(
				'atml_no_faq',
				__( 'No question/answer pairs could be detected in this post.', 'antimanual' )
			);
		}

		$entities = [];
		foreach ( $pairs as $pair ) {
			$entities[] = [
				'@type'          => 'Question',
				'name'           => $pair['question'],
				'acceptedAnswer' => [
					'@type' => 'Answer',
					'text'  => $pair['answer'],
				],
			];
		}

		return [
			'@context'   => 'https://schema.org',
			'@type'      => 'FAQPage',
			'mainEntity' => $entities,
		];
	}

	/**
	 * Build HowTo schema by detecting ordered step lists in the content.
	 *
	 * @param \WP_Post $post The post object.
	 * @return array<string,mixed>|\WP_Error
	 */
	private function build_howto_schema( \WP_Post $post ) {
		$content = (string) $post->post_content;
		$steps   = [];

		if ( preg_match_all( '/<li[^>]*>(.*?)<\/li>/is', $content, $li_matches ) ) {
			foreach ( $li_matches[1] as $idx => $raw ) {
				$text = trim( wp_strip_all_tags( $raw ) );
				if ( '' === $text ) {
					continue;
				}
				$steps[] = [
					'@type'    => 'HowToStep',
					'position' => $idx + 1,
					'name'     => wp_trim_words( $text, 10, '…' ),
					'text'     => $text,
				];
			}
		}

		if ( empty( $steps ) && preg_match_all( '/<h[2-4][^>]*>(.*?)<\/h[2-4]>/is', $content, $h_matches ) ) {
			foreach ( $h_matches[1] as $idx => $raw ) {
				$heading = trim( wp_strip_all_tags( $raw ) );
				if ( '' === $heading ) {
					continue;
				}
				$steps[] = [
					'@type'    => 'HowToStep',
					'position' => $idx + 1,
					'name'     => $heading,
					'text'     => $heading,
				];
			}
		}

		if ( count( $steps ) < 2 ) {
			return new \WP_Error(
				'atml_no_howto',
				__( 'Not enough steps detected. HowTo requires at least 2 ordered steps (list items or headings).', 'antimanual' )
			);
		}

		return [
			'@context' => 'https://schema.org',
			'@type'    => 'HowTo',
			'name'     => get_the_title( $post ),
			'step'     => array_slice( $steps, 0, 25 ),
		];
	}

	/**
	 * Build Product schema from post content.
	 *
	 * Uses the featured image, title, and excerpt as the product basics.
	 * When WooCommerce is active and the post is a product, pulls price,
	 * currency, availability, SKU, and aggregate ratings automatically.
	 *
	 * @param \WP_Post $post  The post object.
	 * @param array    $notes Accumulator for human-readable autofill notes.
	 * @return array<string,mixed>
	 */
	private function build_product_schema( \WP_Post $post, array &$notes = [] ): array {
		$thumb   = get_the_post_thumbnail_url( $post, 'full' );
		$excerpt = has_excerpt( $post )
			? get_the_excerpt( $post )
			: wp_trim_words( wp_strip_all_tags( $post->post_content ), 40, '…' );

		$schema = [
			'@context'    => 'https://schema.org',
			'@type'       => 'Product',
			'name'        => get_the_title( $post ),
			'description' => $excerpt,
			'url'         => get_permalink( $post ),
		];

		if ( $thumb ) {
			$schema['image'] = $thumb;
		}

		// WooCommerce autofill.
		if ( class_exists( '\\WooCommerce' ) && 'product' === $post->post_type && function_exists( 'wc_get_product' ) ) {
			$product = wc_get_product( $post->ID );
			if ( $product ) {
				$sku = $product->get_sku();
				if ( '' !== $sku ) {
					$schema['sku'] = $sku;
				}
				$price    = (string) $product->get_price();
				$currency = function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : 'USD';
				$stock    = $product->is_in_stock() ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock';

				if ( '' !== $price ) {
					$schema['offers'] = [
						'@type'         => 'Offer',
						'url'           => get_permalink( $post ),
						'priceCurrency' => $currency,
						'price'         => $price,
						'availability'  => $stock,
					];
					$notes[] = __( 'Price, currency, and availability were pulled from WooCommerce.', 'antimanual' );
				}

				$rating_count = (int) $product->get_rating_count();
				$average      = (float) $product->get_average_rating();
				if ( $rating_count > 0 && $average > 0 ) {
					$schema['aggregateRating'] = [
						'@type'       => 'AggregateRating',
						'ratingValue' => number_format( $average, 2, '.', '' ),
						'reviewCount' => $rating_count,
					];
					$notes[] = __( 'Aggregate rating was pulled from WooCommerce reviews.', 'antimanual' );
				}

				$brand_terms = wc_get_product_terms( $post->ID, 'product_brand', [ 'fields' => 'names' ] );
				if ( ! empty( $brand_terms ) && is_array( $brand_terms ) ) {
					$schema['brand'] = [
						'@type' => 'Brand',
						'name'  => (string) reset( $brand_terms ),
					];
				}
			}
		} else {
			$notes[] = __( 'Edit the generated JSON-LD to add price, currency, and availability before saving.', 'antimanual' );
		}

		return $schema;
	}

	/**
	 * Build Organization schema for the site.
	 *
	 * Pulls name, url, logo, and description from WP options. This schema
	 * is site-wide and does not vary per post; the user may still save it
	 * on a specific post (for example the About page).
	 *
	 * @return array<string,mixed>
	 */
	private function build_organization_schema(): array {
		$schema = [
			'@context'    => 'https://schema.org',
			'@type'       => 'Organization',
			'name'        => get_bloginfo( 'name' ),
			'url'         => home_url( '/' ),
		];
		$tagline = get_bloginfo( 'description' );
		if ( '' !== $tagline ) {
			$schema['description'] = $tagline;
		}
		$logo = $this->get_site_logo_url();
		if ( '' !== $logo ) {
			$schema['logo'] = $logo;
		}
		return $schema;
	}

	/**
	 * Build LocalBusiness schema for the site.
	 *
	 * Extends Organization with address, telephone, and openingHours
	 * placeholders. Users are expected to fill the placeholders in the
	 * editor before saving.
	 *
	 * @return array<string,mixed>
	 */
	private function build_localbusiness_schema(): array {
		$base = $this->build_organization_schema();
		$base['@type']       = 'LocalBusiness';
		$base['address']     = [
			'@type'           => 'PostalAddress',
			'streetAddress'   => '',
			'addressLocality' => '',
			'addressRegion'   => '',
			'postalCode'      => '',
			'addressCountry'  => '',
		];
		$base['telephone']   = '';
		$base['openingHoursSpecification'] = [
			[
				'@type'     => 'OpeningHoursSpecification',
				'dayOfWeek' => [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday' ],
				'opens'     => '09:00',
				'closes'    => '17:00',
			],
		];
		return $base;
	}

	/**
	 * Build Recipe schema by extracting ingredients/instructions via AI.
	 *
	 * @param \WP_Post $post The post object.
	 * @return array<string,mixed>|\WP_Error
	 */
	private function build_recipe_schema( \WP_Post $post ) {
		$data = $this->extract_recipe_via_ai( (string) $post->post_content, get_the_title( $post ) );
		if ( empty( $data ) || empty( $data['recipeIngredient'] ) || empty( $data['recipeInstructions'] ) ) {
			return new \WP_Error(
				'atml_no_recipe',
				__( 'Could not detect a recipe (ingredients + instructions) in this post.', 'antimanual' )
			);
		}

		$thumb = get_the_post_thumbnail_url( $post, 'full' );

		$schema = [
			'@context'           => 'https://schema.org',
			'@type'              => 'Recipe',
			'name'               => get_the_title( $post ),
			'description'        => $data['description'] ?? wp_trim_words( wp_strip_all_tags( $post->post_content ), 30, '…' ),
			'author'             => [
				'@type' => 'Person',
				'name'  => get_the_author_meta( 'display_name', $post->post_author ) ?: get_bloginfo( 'name' ),
			],
			'datePublished'      => get_the_date( 'c', $post ),
			'recipeIngredient'   => $data['recipeIngredient'],
			'recipeInstructions' => array_map(
				fn( $text ) => [ '@type' => 'HowToStep', 'text' => $text ],
				$data['recipeInstructions']
			),
		];

		if ( $thumb ) {
			$schema['image'] = $thumb;
		}
		if ( ! empty( $data['prepTime'] ) ) {
			$schema['prepTime'] = $data['prepTime'];
		}
		if ( ! empty( $data['cookTime'] ) ) {
			$schema['cookTime'] = $data['cookTime'];
		}
		if ( ! empty( $data['recipeYield'] ) ) {
			$schema['recipeYield'] = $data['recipeYield'];
		}
		if ( ! empty( $data['recipeCategory'] ) ) {
			$schema['recipeCategory'] = $data['recipeCategory'];
		}
		if ( ! empty( $data['recipeCuisine'] ) ) {
			$schema['recipeCuisine'] = $data['recipeCuisine'];
		}

		return $schema;
	}

	/**
	 * Build VideoObject schema from the first embedded video in the post.
	 *
	 * Detects YouTube, Vimeo, and native <video> sources. Falls back with
	 * WP_Error when no video is found.
	 *
	 * @param \WP_Post $post The post object.
	 * @return array<string,mixed>|\WP_Error
	 */
	private function build_videoobject_schema( \WP_Post $post ) {
		$video = $this->detect_first_video( (string) $post->post_content );
		if ( empty( $video ) ) {
			return new \WP_Error(
				'atml_no_video',
				__( 'No embedded video (YouTube, Vimeo, or <video> source) was found in this post.', 'antimanual' )
			);
		}

		$thumb = get_the_post_thumbnail_url( $post, 'full' );

		$schema = [
			'@context'     => 'https://schema.org',
			'@type'        => 'VideoObject',
			'name'         => get_the_title( $post ),
			'description'  => wp_trim_words( wp_strip_all_tags( $post->post_content ), 30, '…' ),
			'uploadDate'   => get_the_date( 'c', $post ),
			'thumbnailUrl' => $video['thumbnail'] ?: ( $thumb ?: '' ),
		];
		if ( ! empty( $video['embed'] ) ) {
			$schema['embedUrl'] = $video['embed'];
		}
		if ( ! empty( $video['content'] ) ) {
			$schema['contentUrl'] = $video['content'];
		}
		return $schema;
	}

	/**
	 * Build Event schema scaffold for a post.
	 *
	 * Event requires data (start/end dates, venue) the post rarely encodes
	 * directly, so the builder returns a minimal valid scaffold that uses
	 * the post title + published date and placeholder location. Users edit
	 * the JSON-LD to fill venue/date details before saving.
	 *
	 * @param \WP_Post $post The post object.
	 * @return array<string,mixed>
	 */
	private function build_event_schema( \WP_Post $post ): array {
		$start = get_the_date( 'c', $post );
		return [
			'@context'            => 'https://schema.org',
			'@type'               => 'Event',
			'name'                => get_the_title( $post ),
			'description'         => wp_trim_words( wp_strip_all_tags( $post->post_content ), 40, '…' ),
			'startDate'           => $start,
			'endDate'             => $start,
			'eventStatus'         => 'https://schema.org/EventScheduled',
			'eventAttendanceMode' => 'https://schema.org/OfflineEventAttendanceMode',
			'location'            => [
				'@type'   => 'Place',
				'name'    => '',
				'address' => [
					'@type'           => 'PostalAddress',
					'streetAddress'   => '',
					'addressLocality' => '',
					'addressRegion'   => '',
					'postalCode'      => '',
					'addressCountry'  => '',
				],
			],
			'organizer'           => [
				'@type' => 'Organization',
				'name'  => get_bloginfo( 'name' ),
				'url'   => home_url( '/' ),
			],
			'url'                 => get_permalink( $post ),
		];
	}

	/**
	 * Build WebSite schema for the site (with SearchAction).
	 *
	 * Makes the site eligible for Google's sitelinks search box. This
	 * schema is usually saved on the homepage post/page.
	 *
	 * @return array<string,mixed>
	 */
	private function build_website_schema(): array {
		return [
			'@context'        => 'https://schema.org',
			'@type'           => 'WebSite',
			'name'            => get_bloginfo( 'name' ),
			'url'             => home_url( '/' ),
			'potentialAction' => [
				'@type'       => 'SearchAction',
				'target'      => [
					'@type'       => 'EntryPoint',
					'urlTemplate' => home_url( '/?s={search_term_string}' ),
				],
				'query-input' => 'required name=search_term_string',
			],
		];
	}

	/**
	 * Resolve the site's logo URL, preferring the Customizer logo over the icon.
	 *
	 * @return string Empty string when neither is configured.
	 */
	private function get_site_logo_url(): string {
		$logo_id = (int) get_theme_mod( 'custom_logo' );
		if ( $logo_id > 0 ) {
			$src = wp_get_attachment_image_src( $logo_id, 'full' );
			if ( is_array( $src ) && ! empty( $src[0] ) ) {
				return (string) $src[0];
			}
		}
		$icon = get_site_icon_url( 512 );
		return is_string( $icon ) ? $icon : '';
	}

	/**
	 * Detect the first embedded video in post content.
	 *
	 * Supports YouTube and Vimeo URLs and <video src=""> tags. Returns a
	 * normalized { embed, content, thumbnail } triple or an empty array.
	 *
	 * @param string $content Raw post content.
	 * @return array{embed:string,content:string,thumbnail:string}|array{}
	 */
	private function detect_first_video( string $content ): array {
		// YouTube (full URL or youtu.be) — first match wins.
		if ( preg_match( '#https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([A-Za-z0-9_\-]{6,})#i', $content, $m ) ) {
			$id = $m[1];
			return [
				'embed'     => 'https://www.youtube.com/embed/' . $id,
				'content'   => 'https://www.youtube.com/watch?v=' . $id,
				'thumbnail' => 'https://img.youtube.com/vi/' . $id . '/hqdefault.jpg',
			];
		}
		// Vimeo.
		if ( preg_match( '#https?://(?:www\.)?vimeo\.com/(\d+)#i', $content, $m ) ) {
			$id = $m[1];
			return [
				'embed'     => 'https://player.vimeo.com/video/' . $id,
				'content'   => 'https://vimeo.com/' . $id,
				'thumbnail' => '',
			];
		}
		// Native HTML5 <video src="…">.
		if ( preg_match( '#<video[^>]+src=["\']([^"\']+)["\']#i', $content, $m ) ) {
			return [
				'embed'     => '',
				'content'   => esc_url_raw( $m[1] ),
				'thumbnail' => '',
			];
		}
		// <source> inside <video>.
		if ( preg_match( '#<source[^>]+src=["\']([^"\']+\.(?:mp4|webm|ogv))["\']#i', $content, $m ) ) {
			return [
				'embed'     => '',
				'content'   => esc_url_raw( $m[1] ),
				'thumbnail' => '',
			];
		}
		return [];
	}

	/**
	 * Use AI to extract a Recipe payload from post content.
	 *
	 * @param string $content Raw post content.
	 * @param string $title   Post title (used as a hint).
	 * @return array<string,mixed> Associative array or empty on failure.
	 */
	private function extract_recipe_via_ai( string $content, string $title ): array {
		$text = wp_strip_all_tags( $content );
		$text = mb_substr( $text, 0, 6000 );
		if ( '' === trim( $text ) ) {
			return [];
		}

		$prompt = "Extract the recipe from the following content titled \"" . $title . "\". "
			. "Return ONLY valid JSON (no markdown) in this exact shape:\n"
			. "{\n"
			. "  \"description\": string,\n"
			. "  \"prepTime\": ISO 8601 duration (e.g., PT15M) or null,\n"
			. "  \"cookTime\": ISO 8601 duration (e.g., PT30M) or null,\n"
			. "  \"recipeYield\": string or null (e.g., \"4 servings\"),\n"
			. "  \"recipeCategory\": string or null,\n"
			. "  \"recipeCuisine\": string or null,\n"
			. "  \"recipeIngredient\": string[],\n"
			. "  \"recipeInstructions\": string[]\n"
			. "}\n\n"
			. "Content:\n" . $text;

		$messages = [
			[ 'role' => 'system', 'content' => 'You extract recipes. Reply with valid JSON only. Use null for unknown fields.' ],
			[ 'role' => 'user',   'content' => $prompt ],
		];

		$reply_data = AIProvider::get_reply( $messages );
		if ( isset( $reply_data['error'] ) ) {
			return [];
		}

		$reply = is_array( $reply_data ) && isset( $reply_data['reply'] ) ? $reply_data['reply'] : (string) $reply_data;
		$reply = preg_replace( '/^```(?:json)?\s*/i', '', trim( $reply ) );
		$reply = preg_replace( '/\s*```$/', '', $reply );

		$data = json_decode( $reply, true );
		if ( ! is_array( $data ) ) {
			return [];
		}

		$out = [];
		$out['description']        = isset( $data['description'] ) ? sanitize_text_field( (string) $data['description'] ) : '';
		$out['prepTime']           = isset( $data['prepTime'] ) && is_string( $data['prepTime'] ) ? sanitize_text_field( $data['prepTime'] ) : '';
		$out['cookTime']           = isset( $data['cookTime'] ) && is_string( $data['cookTime'] ) ? sanitize_text_field( $data['cookTime'] ) : '';
		$out['recipeYield']        = isset( $data['recipeYield'] ) && is_string( $data['recipeYield'] ) ? sanitize_text_field( $data['recipeYield'] ) : '';
		$out['recipeCategory']     = isset( $data['recipeCategory'] ) && is_string( $data['recipeCategory'] ) ? sanitize_text_field( $data['recipeCategory'] ) : '';
		$out['recipeCuisine']      = isset( $data['recipeCuisine'] ) && is_string( $data['recipeCuisine'] ) ? sanitize_text_field( $data['recipeCuisine'] ) : '';
		$out['recipeIngredient']   = isset( $data['recipeIngredient'] ) && is_array( $data['recipeIngredient'] )
			? array_values( array_filter( array_map( fn( $s ) => sanitize_text_field( (string) $s ), $data['recipeIngredient'] ) ) )
			: [];
		$out['recipeInstructions'] = isset( $data['recipeInstructions'] ) && is_array( $data['recipeInstructions'] )
			? array_values( array_filter( array_map( fn( $s ) => sanitize_text_field( (string) $s ), $data['recipeInstructions'] ) ) )
			: [];

		return $out;
	}

	/**
	 * Validate a schema payload against Google's required/recommended fields.
	 *
	 * Returns two lists — errors (likely to fail rich-result eligibility) and
	 * warnings (recommended fields that are missing). Validation is
	 * intentionally forgiving: we only check structural presence, not deep
	 * semantics.
	 *
	 * @param string $schema_type Normalized schema key (article, product, etc.).
	 * @param array  $schema      The parsed JSON-LD payload.
	 * @return array{errors:array<int,array{level:string,field:string,message:string}>, warnings:array<int,array{level:string,field:string,message:string}>}
	 */
	private function validate_schema( string $schema_type, array $schema ): array {
		$errors   = [];
		$warnings = [];

		$req = function ( string $field, string $message ) use ( $schema, &$errors ) {
			if ( empty( $schema[ $field ] ) ) {
				$errors[] = [ 'level' => 'error', 'field' => $field, 'message' => $message ];
			}
		};
		$rec = function ( string $field, string $message ) use ( $schema, &$warnings ) {
			if ( empty( $schema[ $field ] ) ) {
				$warnings[] = [ 'level' => 'warning', 'field' => $field, 'message' => $message ];
			}
		};

		switch ( $schema_type ) {
			case 'article':
				$req( 'headline',      __( 'Article requires a headline.', 'antimanual' ) );
				$rec( 'image',         __( 'Add an image URL for rich-result eligibility.', 'antimanual' ) );
				$rec( 'datePublished', __( 'Add datePublished in ISO 8601.', 'antimanual' ) );
				$rec( 'author',        __( 'Add an author (Person or Organization).', 'antimanual' ) );
				break;

			case 'product':
				$req( 'name',  __( 'Product requires a name.', 'antimanual' ) );
				$rec( 'image', __( 'Add at least one image URL.', 'antimanual' ) );
				if ( empty( $schema['offers'] ) ) {
					$warnings[] = [
						'level'   => 'warning',
						'field'   => 'offers',
						'message' => __( 'Add an offers block with price, priceCurrency, and availability.', 'antimanual' ),
					];
				} else {
					$offers = $schema['offers'];
					if ( is_array( $offers ) && ! isset( $offers[0] ) ) {
						// Single offer object.
						if ( empty( $offers['price'] ) ) {
							$errors[] = [ 'level' => 'error', 'field' => 'offers.price', 'message' => __( 'offers.price is required.', 'antimanual' ) ];
						}
						if ( empty( $offers['priceCurrency'] ) ) {
							$errors[] = [ 'level' => 'error', 'field' => 'offers.priceCurrency', 'message' => __( 'offers.priceCurrency is required (ISO 4217).', 'antimanual' ) ];
						}
						if ( empty( $offers['availability'] ) ) {
							$warnings[] = [ 'level' => 'warning', 'field' => 'offers.availability', 'message' => __( 'Add an availability URL (e.g., https://schema.org/InStock).', 'antimanual' ) ];
						}
					}
				}
				break;

			case 'faqpage':
			case 'faq':
				if ( empty( $schema['mainEntity'] ) || ! is_array( $schema['mainEntity'] ) ) {
					$errors[] = [ 'level' => 'error', 'field' => 'mainEntity', 'message' => __( 'FAQPage requires at least one Question in mainEntity.', 'antimanual' ) ];
				} else {
					foreach ( $schema['mainEntity'] as $idx => $q ) {
						if ( ! is_array( $q ) || empty( $q['name'] ) ) {
							$errors[] = [ 'level' => 'error', 'field' => "mainEntity[{$idx}].name", 'message' => __( 'Each Question needs a name.', 'antimanual' ) ];
						}
						if ( ! is_array( $q ) || empty( $q['acceptedAnswer']['text'] ) ) {
							$errors[] = [ 'level' => 'error', 'field' => "mainEntity[{$idx}].acceptedAnswer.text", 'message' => __( 'Each Question needs an acceptedAnswer.text.', 'antimanual' ) ];
						}
					}
				}
				break;

			case 'howto':
				$req( 'name', __( 'HowTo requires a name.', 'antimanual' ) );
				if ( empty( $schema['step'] ) || ! is_array( $schema['step'] ) || count( $schema['step'] ) < 2 ) {
					$errors[] = [ 'level' => 'error', 'field' => 'step', 'message' => __( 'HowTo needs at least 2 steps.', 'antimanual' ) ];
				}
				break;

			case 'recipe':
				$req( 'name',               __( 'Recipe requires a name.', 'antimanual' ) );
				$rec( 'image',              __( 'Add a recipe image.', 'antimanual' ) );
				if ( empty( $schema['recipeIngredient'] ) || ! is_array( $schema['recipeIngredient'] ) ) {
					$errors[] = [ 'level' => 'error', 'field' => 'recipeIngredient', 'message' => __( 'Recipe requires a non-empty recipeIngredient array.', 'antimanual' ) ];
				}
				if ( empty( $schema['recipeInstructions'] ) ) {
					$errors[] = [ 'level' => 'error', 'field' => 'recipeInstructions', 'message' => __( 'Recipe requires recipeInstructions.', 'antimanual' ) ];
				}
				$rec( 'prepTime',    __( 'Add prepTime (ISO 8601 duration).', 'antimanual' ) );
				$rec( 'cookTime',    __( 'Add cookTime (ISO 8601 duration).', 'antimanual' ) );
				$rec( 'recipeYield', __( 'Add recipeYield (e.g., "4 servings").', 'antimanual' ) );
				break;

			case 'organization':
				$req( 'name', __( 'Organization requires a name.', 'antimanual' ) );
				$req( 'url',  __( 'Organization requires a url.', 'antimanual' ) );
				$rec( 'logo', __( 'Add a logo URL (>= 112px) for rich-result eligibility.', 'antimanual' ) );
				break;

			case 'localbusiness':
			case 'local_business':
				$req( 'name',      __( 'LocalBusiness requires a name.', 'antimanual' ) );
				if ( empty( $schema['address'] ) || ! is_array( $schema['address'] ) ) {
					$errors[] = [ 'level' => 'error', 'field' => 'address', 'message' => __( 'LocalBusiness requires a PostalAddress.', 'antimanual' ) ];
				} elseif ( empty( $schema['address']['streetAddress'] ) || empty( $schema['address']['addressCountry'] ) ) {
					$warnings[] = [ 'level' => 'warning', 'field' => 'address', 'message' => __( 'Fill streetAddress and addressCountry in the address block.', 'antimanual' ) ];
				}
				$rec( 'telephone',                 __( 'Add a telephone number.', 'antimanual' ) );
				$rec( 'openingHoursSpecification', __( 'Add opening hours.', 'antimanual' ) );
				break;

			case 'event':
				$req( 'name',      __( 'Event requires a name.', 'antimanual' ) );
				$req( 'startDate', __( 'Event requires a startDate (ISO 8601).', 'antimanual' ) );
				if ( empty( $schema['location'] ) ) {
					$errors[] = [ 'level' => 'error', 'field' => 'location', 'message' => __( 'Event requires a location (Place or VirtualLocation).', 'antimanual' ) ];
				} elseif ( is_array( $schema['location'] ) && empty( $schema['location']['name'] ) ) {
					$warnings[] = [ 'level' => 'warning', 'field' => 'location.name', 'message' => __( 'Add the location venue name.', 'antimanual' ) ];
				}
				$rec( 'eventStatus',         __( 'Add eventStatus (e.g., https://schema.org/EventScheduled).', 'antimanual' ) );
				$rec( 'eventAttendanceMode', __( 'Add eventAttendanceMode.', 'antimanual' ) );
				break;

			case 'videoobject':
			case 'video':
				$req( 'name',         __( 'VideoObject requires a name.', 'antimanual' ) );
				$req( 'description',  __( 'VideoObject requires a description.', 'antimanual' ) );
				$req( 'thumbnailUrl', __( 'VideoObject requires a thumbnailUrl.', 'antimanual' ) );
				$req( 'uploadDate',   __( 'VideoObject requires an uploadDate.', 'antimanual' ) );
				if ( empty( $schema['contentUrl'] ) && empty( $schema['embedUrl'] ) ) {
					$errors[] = [ 'level' => 'error', 'field' => 'contentUrl', 'message' => __( 'VideoObject needs either contentUrl or embedUrl.', 'antimanual' ) ];
				}
				break;

			case 'website':
			case 'web_site':
				$req( 'name', __( 'WebSite requires a name.', 'antimanual' ) );
				$req( 'url',  __( 'WebSite requires a url.', 'antimanual' ) );
				$rec( 'potentialAction', __( 'Add a SearchAction potentialAction to enable the sitelinks search box.', 'antimanual' ) );
				break;

			case 'breadcrumb':
			case 'breadcrumblist':
				if ( empty( $schema['itemListElement'] ) || ! is_array( $schema['itemListElement'] ) || count( $schema['itemListElement'] ) < 2 ) {
					$errors[] = [ 'level' => 'error', 'field' => 'itemListElement', 'message' => __( 'BreadcrumbList needs at least 2 ListItem entries.', 'antimanual' ) ];
				}
				break;
		}

		return [ 'errors' => $errors, 'warnings' => $warnings ];
	}

	/**
	 * Extract Q&A pairs deterministically by scanning headings that end in "?".
	 *
	 * @param string $content Raw post content.
	 * @return array<int,array{question:string,answer:string}>
	 */
	private function extract_faq_pairs_from_content( string $content ): array {
		$pairs = [];

		if ( ! preg_match_all(
			'/<(h[2-4])[^>]*>(.*?)<\/\1>(.*?)(?=<h[2-4][^>]*>|$)/is',
			$content,
			$matches,
			PREG_SET_ORDER
		) ) {
			return $pairs;
		}

		foreach ( $matches as $m ) {
			$heading = trim( wp_strip_all_tags( $m[2] ) );
			$body    = trim( wp_strip_all_tags( $m[3] ) );

			if ( '' === $heading || '' === $body ) {
				continue;
			}

			if ( mb_substr( $heading, -1 ) !== '?' ) {
				continue;
			}

			$pairs[] = [
				'question' => $heading,
				'answer'   => wp_trim_words( $body, 80, '…' ),
			];
		}

		return array_slice( $pairs, 0, 10 );
	}

	/**
	 * Fall back to AI extraction for FAQ pairs when deterministic detection fails.
	 *
	 * @param string $content Raw post content.
	 * @return array<int,array{question:string,answer:string}>
	 */
	private function extract_faq_pairs_via_ai( string $content ): array {
		$text = wp_strip_all_tags( $content );
		$text = mb_substr( $text, 0, 6000 );
		if ( '' === trim( $text ) ) {
			return [];
		}

		$prompt = "Extract up to 6 frequently-asked questions answered by the following content. "
			. "Respond with ONLY valid JSON, no markdown:\n"
			. "{\"pairs\":[{\"question\":\"...\",\"answer\":\"...\"}]}\n\n"
			. "Content:\n" . $text;

		$messages = [
			[ 'role' => 'system', 'content' => 'You extract FAQs from content. Reply with valid JSON only.' ],
			[ 'role' => 'user',   'content' => $prompt ],
		];

		$reply_data = AIProvider::get_reply( $messages );
		if ( isset( $reply_data['error'] ) ) {
			return [];
		}

		$reply = is_array( $reply_data ) && isset( $reply_data['reply'] ) ? $reply_data['reply'] : (string) $reply_data;
		$reply = preg_replace( '/^```(?:json)?\s*/i', '', trim( $reply ) );
		$reply = preg_replace( '/\s*```$/', '', $reply );

		$data = json_decode( $reply, true );
		if ( ! is_array( $data ) || empty( $data['pairs'] ) || ! is_array( $data['pairs'] ) ) {
			return [];
		}

		$pairs = [];
		foreach ( $data['pairs'] as $p ) {
			$q = sanitize_text_field( (string) ( $p['question'] ?? '' ) );
			$a = sanitize_text_field( (string) ( $p['answer'] ?? '' ) );
			if ( '' === $q || '' === $a ) {
				continue;
			}
			$pairs[] = [ 'question' => $q, 'answer' => $a ];
		}

		return $pairs;
	}

	/* ============================================================
	 * Phase 3 — Content Cannibalization Detector (2.4)
	 * ============================================================ */

	/**
	 * Meta keys where focus keywords are commonly stored by SEO plugins.
	 */
	private const FOCUS_KEYWORD_META_KEYS = [
		'rank_math_focus_keyword',
		'_yoast_wpseo_focus_keyword',
		'_aioseo_keywords',
		'_seopress_analysis_target_kw',
	];

	/**
	 * Detect posts competing for the same keywords or similar content.
	 *
	 * Pairs are formed from posts that share a normalized focus keyword
	 * OR whose shingle-based content similarity exceeds the threshold.
	 * Each pair is scored and tagged with a recommended action.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function cannibalization_check( $request ) {
		$post_types = (array) $request->get_param( 'post_types' );
		$post_types = array_values( array_filter( array_map( 'sanitize_key', $post_types ) ) );
		if ( empty( $post_types ) ) {
			$post_types = [ 'post' ];
		}

		$limit     = absint( $request->get_param( 'limit' ) ?: 200 );
		$limit     = min( max( $limit, 10 ), 500 );
		$threshold = (float) ( $request->get_param( 'similarity_threshold' ) ?: 0.35 );
		$threshold = max( 0.1, min( 0.95, $threshold ) );

		$args = [
			'post_type'              => $post_types,
			'post_status'            => 'publish',
			'posts_per_page'         => $limit,
			'fields'                 => 'ids',
			'orderby'                => 'modified',
			'order'                  => 'DESC',
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
		];

		$query = new \WP_Query( $args );
		$ids   = array_map( 'intval', (array) $query->posts );

		if ( count( $ids ) < 2 ) {
			return rest_ensure_response( [
				'success' => true,
				'data'    => [
					'groups'       => [],
					'pairs'        => [],
					'total_posts'  => count( $ids ),
					'threshold'    => $threshold,
					'pairs_scope'  => [
						'compared'  => 0,
						'possible'  => 0,
						'cap'       => self::CANNIBAL_PAIR_LIMIT,
						'truncated' => false,
					],
					'scanned_at'   => current_time( 'mysql' ),
				],
			] );
		}

		// Prime meta cache for every post in one query so the
		// per-post focus_keyword lookups inside the loop don't each
		// trigger their own DB hit.
		update_meta_cache( 'post', $ids );

		// Build lightweight per-post feature set, cached per-post so unchanged
		// posts are not re-shingled on every scan.
		$features = [];
		foreach ( $ids as $id ) {
			$id      = (int) $id;
			$feature = $this->get_cannibal_features_cached( $id );
			if ( null === $feature ) {
				continue;
			}
			$features[ $id ] = $feature;
		}

		// Group by identical focus keyword first (strong signal).
		$keyword_groups = [];
		foreach ( $features as $id => $f ) {
			$key = $this->normalize_keyword_key( (string) $f['keyword'] );
			if ( '' === $key ) {
				continue;
			}
			$keyword_groups[ $key ][] = $id;
		}

		// Track which posts belong to a keyword group so we can tag overlapping pairs below.
		$grouped_post_ids = [];

		$groups = [];
		foreach ( $keyword_groups as $key => $group_ids ) {
			if ( count( $group_ids ) < 2 ) {
				continue;
			}

			// Rank members so we can suggest a likely winner.
			$scored = [];
			foreach ( $group_ids as $gid ) {
				$scored[ $gid ] = $this->score_cannibal_candidate( $features[ $gid ] );
			}
			arsort( $scored );
			$suggested_id = (int) array_key_first( $scored );

			$posts = array_map(
				function ( $id ) use ( $features, $scored, $suggested_id ) {
					$ref = $this->build_cannibal_post_ref( $features[ $id ] );
					$ref['score']         = (int) ( $scored[ $id ] ?? 0 );
					$ref['is_suggested']  = ( $id === $suggested_id );
					return $ref;
				},
				$group_ids
			);

			foreach ( $group_ids as $gid ) {
				$grouped_post_ids[ $gid ] = true;
			}

			// Top shared terms across every member of the group.
			$group_freqs = array_map( fn( $gid ) => $features[ $gid ]['freq'], $group_ids );
			$shared_terms = $this->compute_group_shared_terms( $group_freqs );

			$groups[] = [
				'keyword'              => $features[ $group_ids[0] ]['keyword'],
				'match_type'           => 'focus_keyword',
				'post_count'           => count( $group_ids ),
				'posts'                => $posts,
				'severity'             => count( $group_ids ) >= 3 ? 'critical' : 'warning',
				'shared_terms'         => $shared_terms,
				'suggested_winner_id'  => $suggested_id,
				'suggested_reason'     => $this->explain_cannibal_winner( $features[ $suggested_id ] ),
				'recommendation'       => $this->recommend_cannibal_action( 'focus_keyword', count( $group_ids ) ),
			];
		}

		// Similarity pairs from remaining posts (avoid N^2 blowup by capping compared pairs).
		$ids_list        = array_values( array_keys( $features ) );
		$n               = count( $ids_list );
		$possible_pairs  = (int) ( $n * ( $n - 1 ) / 2 );
		$pair_limit      = (int) min( self::CANNIBAL_PAIR_LIMIT, $possible_pairs );
		$pair_count      = 0;
		$pairs           = [];

		for ( $i = 0; $i < $n && $pair_count < $pair_limit; $i++ ) {
			$a = $features[ $ids_list[ $i ] ];
			if ( count( $a['shingles'] ) < 10 ) {
				continue;
			}
			for ( $j = $i + 1; $j < $n && $pair_count < $pair_limit; $j++ ) {
				$b = $features[ $ids_list[ $j ] ];
				if ( count( $b['shingles'] ) < 10 ) {
					continue;
				}
				++$pair_count;

				// 5-gram shingle Jaccard (from content_similarity_checker.py).
				$similarity = $this->jaccard_similarity( $a['shingles'], $b['shingles'] );
				if ( $similarity < $threshold ) {
					continue;
				}

				$shared_keyword = ( $a['keyword'] !== '' && $this->normalize_keyword_key( $a['keyword'] ) === $this->normalize_keyword_key( $b['keyword'] ) );
				if ( $shared_keyword ) {
					// Already represented in keyword groups.
					continue;
				}

				// Shared top terms (from keyword_cannibalization.py).
				$shared_terms = $this->compute_shared_terms( $a['freq'], $b['freq'] );

				$pairs[] = [
					'a'                    => $this->build_cannibal_post_ref( $a ),
					'b'                    => $this->build_cannibal_post_ref( $b ),
					'similarity'           => round( $similarity, 3 ),
					'similarity_pct'       => (int) round( $similarity * 100 ),
					'severity'             => $similarity >= 0.7 ? 'critical' : ( $similarity >= 0.5 ? 'warning' : 'info' ),
					'shared_terms'         => $shared_terms,
					// Surface when a pair references a post that already appears in a keyword group,
					// so the UI can flag the overlap rather than showing the same issue twice.
					'overlaps_group'       => isset( $grouped_post_ids[ $a['id'] ] ) || isset( $grouped_post_ids[ $b['id'] ] ),
					'recommendation'       => $this->recommend_cannibal_action( 'similarity', 2, $similarity ),
				];
			}
		}

		usort( $pairs, function ( $x, $y ) {
			return $y['similarity'] <=> $x['similarity'];
		} );

		// Cap returned pairs to avoid huge payloads.
		$pairs = array_slice( $pairs, 0, 100 );

		return rest_ensure_response( [
			'success' => true,
			'data'    => [
				'groups'       => $groups,
				'pairs'        => $pairs,
				'total_posts'  => count( $features ),
				'threshold'    => $threshold,
				'pairs_scope'  => [
					'compared'  => $pair_count,
					'possible'  => $possible_pairs,
					'cap'       => self::CANNIBAL_PAIR_LIMIT,
					'truncated' => $pair_count >= $pair_limit && $possible_pairs > $pair_limit,
				],
				'scanned_at'   => current_time( 'mysql' ),
			],
		] );
	}

	/**
	 * Hard cap on how many pair comparisons the scan will perform per run.
	 *
	 * With 500 posts, all-pairs would be ~125k comparisons — this keeps the
	 * REST request predictable while still covering typical sites in full.
	 */
	private const CANNIBAL_PAIR_LIMIT = 2000;

	/**
	 * TTL for cached features (days). Invalidation is driven by `post_modified`,
	 * so the TTL only exists as a floor for posts that are never touched again.
	 */
	private const CANNIBAL_CACHE_TTL = WEEK_IN_SECONDS;

	/**
	 * Fetch the feature bundle for a post, reading from transient cache when
	 * the cached `post_modified` matches. Rebuilds and stores on miss.
	 *
	 * @param int $post_id Post ID.
	 * @return array|null Feature bundle, or null if the post is gone.
	 */
	private function get_cannibal_features_cached( int $post_id ): ?array {
		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return null;
		}

		$modified = (string) $post->post_modified_gmt;
		$cache_key = self::CANNIBAL_CACHE_PREFIX . $post_id;
		$cached    = get_transient( $cache_key );

		if (
			is_array( $cached )
			&& isset( $cached['modified_gmt'] )
			&& $cached['modified_gmt'] === $modified
		) {
			// Title/URL/edit link can change without post_modified moving
			// (e.g. permalink structure swaps), so refresh the cheap fields.
			$cached['title'] = get_the_title( $post_id );
			$cached['url']   = get_permalink( $post_id );
			$cached['edit']  = (string) get_edit_post_link( $post_id, 'raw' );
			return $cached;
		}

		$text = wp_strip_all_tags( (string) $post->post_content );
		$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );

		$feature = [
			'id'           => $post_id,
			'title'        => get_the_title( $post_id ),
			'url'          => get_permalink( $post_id ),
			'edit'         => (string) get_edit_post_link( $post_id, 'raw' ),
			'keyword'      => $this->get_post_focus_keyword( $post_id ),
			'shingles'     => $this->shingle_text( $text ),
			'freq'         => $this->word_frequencies( $text ),
			'modified'     => get_post_modified_time( 'c', true, $post_id ),
			'modified_gmt' => $modified,
			'modified_ts'  => (int) get_post_modified_time( 'U', true, $post_id ),
		];
		$feature['length'] = count( $feature['freq'] );

		set_transient( $cache_key, $feature, self::CANNIBAL_CACHE_TTL );
		return $feature;
	}

	/**
	 * Build a compact post reference used in cannibalization payloads.
	 *
	 * @param array $feature Feature map for a post.
	 * @return array
	 */
	private function build_cannibal_post_ref( array $feature ): array {
		return [
			'post_id'   => $feature['id'],
			'title'     => $feature['title'],
			'url'       => $feature['url'],
			'edit_link' => $feature['edit'] ?: '',
			'keyword'   => $feature['keyword'],
			'modified'  => $feature['modified'],
			'words'     => array_sum( $feature['freq'] ),
		];
	}

	/**
	 * Score a keyword-group candidate to suggest the likely winner.
	 *
	 * Combines word count (cap 2000), recency of last edit, and whether
	 * the focus keyword actually appears in the title — signals a human
	 * would use in a manual triage.
	 *
	 * @param array $feature Feature bundle for a post.
	 * @return int Arbitrary ranking score; higher wins.
	 */
	private function score_cannibal_candidate( array $feature ): int {
		$words = array_sum( $feature['freq'] );

		// Word count contribution: caps at 2000 words to avoid drowning
		// out the other signals on very long pages.
		$word_score = (int) min( 2000, $words );

		// Recency contribution: +0 for >1y old, up to +500 for edited today.
		$now        = time();
		$modified   = (int) ( $feature['modified_ts'] ?? 0 );
		$age_days   = $modified > 0 ? max( 0, ( $now - $modified ) / DAY_IN_SECONDS ) : 365;
		$recency    = (int) max( 0, 500 - ( $age_days * 500 / 365 ) );

		// Keyword-in-title contribution: strong signal the page is "about" the keyword.
		$keyword = strtolower( trim( (string) $feature['keyword'] ) );
		$title   = strtolower( (string) $feature['title'] );
		$in_title = ( '' !== $keyword && '' !== $title && false !== strpos( $title, $keyword ) ) ? 400 : 0;

		return $word_score + $recency + $in_title;
	}

	/**
	 * Build a one-line explanation of why a post was suggested as the winner.
	 *
	 * @param array $feature Feature bundle.
	 * @return string Short, translated sentence.
	 */
	private function explain_cannibal_winner( array $feature ): string {
		$reasons = [];

		$words = array_sum( $feature['freq'] );
		if ( $words >= 400 ) {
			$reasons[] = sprintf(
				/* translators: %d: word count */
				__( 'longest content (%d words)', 'antimanual' ),
				$words
			);
		}

		$modified_ts = (int) ( $feature['modified_ts'] ?? 0 );
		if ( $modified_ts > 0 ) {
			$age_days = max( 0, ( time() - $modified_ts ) / DAY_IN_SECONDS );
			if ( $age_days <= 30 ) {
				$reasons[] = __( 'updated recently', 'antimanual' );
			}
		}

		$keyword = strtolower( trim( (string) $feature['keyword'] ) );
		$title   = strtolower( (string) $feature['title'] );
		if ( '' !== $keyword && '' !== $title && false !== strpos( $title, $keyword ) ) {
			$reasons[] = __( 'keyword in title', 'antimanual' );
		}

		if ( empty( $reasons ) ) {
			return (string) __( 'Best overall match for the focus keyword.', 'antimanual' );
		}

		return sprintf(
			/* translators: %s: comma-separated reason list */
			__( 'Suggested because: %s.', 'antimanual' ),
			implode( ', ', $reasons )
		);
	}

	/**
	 * Top shared terms across every member of a keyword group.
	 *
	 * Ranks by the number of group members a term appears in (broader signal
	 * than any pair), then by average frequency to break ties. Returns up to
	 * 8 terms, matching the pair shared_terms output.
	 *
	 * @param array<array<string,int>> $freqs Frequency maps, one per post.
	 * @return array<string>
	 */
	private function compute_group_shared_terms( array $freqs ): array {
		if ( count( $freqs ) < 2 ) {
			return [];
		}

		// Count how many members each term appears in, and accumulate total frequency.
		$presence = [];
		$totals   = [];
		foreach ( $freqs as $freq ) {
			foreach ( $freq as $word => $count ) {
				$presence[ $word ] = ( $presence[ $word ] ?? 0 ) + 1;
				$totals[ $word ]   = ( $totals[ $word ]   ?? 0 ) + (int) $count;
			}
		}

		$member_count = count( $freqs );
		$shared       = [];
		foreach ( $presence as $word => $members ) {
			if ( $members < 2 ) {
				continue;
			}
			// Primary weight by coverage (0..1), secondary by avg frequency.
			$coverage       = $members / $member_count;
			$avg_freq       = $totals[ $word ] / $members;
			$shared[ $word ] = ( $coverage * 10000 ) + $avg_freq;
		}

		arsort( $shared );
		return array_slice( array_keys( $shared ), 0, 8 );
	}

	/**
	 * Map a detection reason to a recommendation string.
	 *
	 * @param string     $reason     One of focus_keyword|similarity.
	 * @param int        $count      Number of competing posts.
	 * @param float|null $similarity Overlap ratio (0..1) for similarity pairs.
	 * @return string
	 */
	private function recommend_cannibal_action( string $reason, int $count, ?float $similarity = null ): string {
		if ( 'focus_keyword' === $reason ) {
			if ( $count >= 3 ) {
				return (string) __( 'Merge the weaker pages into the strongest or differentiate focus keywords.', 'antimanual' );
			}
			return (string) __( 'Differentiate focus keywords, or merge the weaker page into the better-performing one with a 301 redirect.', 'antimanual' );
		}
		if ( 'similarity' === $reason ) {
			if ( null !== $similarity && $similarity >= 0.7 ) {
				return (string) __( 'Very high content overlap — merge into a single canonical page and 301-redirect the other.', 'antimanual' );
			}
			if ( null !== $similarity && $similarity >= 0.5 ) {
				return (string) __( 'Substantial overlap — rewrite one page to cover a distinct angle, or consolidate.', 'antimanual' );
			}
			return (string) __( 'Refresh one page with unique sections, examples, or intent to reduce overlap.', 'antimanual' );
		}
		return (string) __( 'Review and consolidate.', 'antimanual' );
	}

	/**
	 * Normalize a focus keyword for comparison.
	 *
	 * @param string $keyword Raw keyword.
	 * @return string Lowercased, whitespace-collapsed key, or empty.
	 */
	private function normalize_keyword_key( string $keyword ): string {
		$keyword = strtolower( trim( $keyword ) );
		$keyword = preg_replace( '/\s+/', ' ', $keyword );
		return (string) $keyword;
	}

	/**
	 * Read the focus keyword for a post from the common SEO plugin meta keys.
	 *
	 * @param int $post_id Post ID.
	 * @return string Keyword, or empty string.
	 */
	private function get_post_focus_keyword( int $post_id ): string {
		foreach ( self::FOCUS_KEYWORD_META_KEYS as $key ) {
			$value = get_post_meta( $post_id, $key, true );
			if ( is_array( $value ) ) {
				$value = reset( $value );
			}
			$value = trim( (string) $value );
			if ( '' !== $value ) {
				// AIOSEO stores comma-separated; take the first.
				if ( false !== strpos( $value, ',' ) ) {
					$parts = explode( ',', $value );
					$value = trim( (string) reset( $parts ) );
				}
				return $value;
			}
		}
		return '';
	}

	/**
	 * Generate 5-word shingles from text for Jaccard similarity.
	 *
	 * Mirrors content_similarity_checker.py: shingle(text, k=5).
	 * Shingles capture phrase-level patterns and are far more resistant
	 * to false positives than unigram token sets.
	 *
	 * @param string $text Plain text.
	 * @param int    $k    Shingle width (default 5).
	 * @return array<string> Unique k-gram strings.
	 */
	private function shingle_text( string $text, int $k = 5 ): array {
		$text  = strtolower( (string) preg_replace( '/[^a-z0-9\s]/i', ' ', $text ) );
		$words = preg_split( '/\s+/', trim( $text ), -1, PREG_SPLIT_NO_EMPTY );
		$count = count( $words );
		if ( $count < $k ) {
			return [];
		}
		$shingles = [];
		for ( $i = 0; $i <= $count - $k; $i++ ) {
			$shingles[ implode( ' ', array_slice( $words, $i, $k ) ) ] = true;
		}
		return array_keys( $shingles );
	}

	/**
	 * Build a word-frequency map for shared-term extraction.
	 *
	 * Mirrors keyword_cannibalization.py: filters stopwords and short words,
	 * returns each word mapped to its occurrence count.
	 *
	 * @param string $text Plain text.
	 * @return array<string, int> word => count.
	 */
	private function word_frequencies( string $text ): array {
		static $stopwords = null;
		if ( null === $stopwords ) {
			$stopwords = array_flip( [
				'about','above','after','again','against','also','and','any','are','because','been','before',
				'being','below','between','both','but','could','does','doing','down','during','each','few',
				'for','from','further','had','has','have','having','her','here','hers','herself','him','himself',
				'his','how','into','itself','just','more','most','myself','nor','not','now','off','once','only',
				'other','our','ours','ourselves','out','over','own','same','should','some','such','than','that',
				'the','their','theirs','them','themselves','then','there','these','they','this','those','through',
				'too','under','until','very','was','were','what','when','where','which','while','who','whom',
				'why','will','with','you','your','yours','yourself','yourselves',
			] );
		}
		$text  = strtolower( $text );
		$text  = preg_replace( '/[^a-z0-9\s]/', ' ', $text );
		$parts = preg_split( '/\s+/', (string) $text, -1, PREG_SPLIT_NO_EMPTY );
		$freq  = [];
		foreach ( $parts as $tok ) {
			if ( strlen( $tok ) < 4 || isset( $stopwords[ $tok ] ) ) {
				continue;
			}
			$freq[ $tok ] = ( $freq[ $tok ] ?? 0 ) + 1;
		}
		return $freq;
	}

	/**
	 * Find shared top terms between two word-frequency maps.
	 *
	 * Mirrors keyword_cannibalization.py shared_terms logic: words present in
	 * both pages, ranked by average frequency, top 8 returned.
	 *
	 * @param array<string,int> $freq_a Frequency map for post A.
	 * @param array<string,int> $freq_b Frequency map for post B.
	 * @return array<string> Top shared terms.
	 */
	private function compute_shared_terms( array $freq_a, array $freq_b ): array {
		$shared = [];
		foreach ( $freq_a as $word => $cnt_a ) {
			if ( isset( $freq_b[ $word ] ) ) {
				$shared[ $word ] = ( $cnt_a + $freq_b[ $word ] ) / 2;
			}
		}
		arsort( $shared );
		return array_slice( array_keys( $shared ), 0, 8 );
	}

	/**
	 * Jaccard similarity between two shingle/token lists.
	 *
	 * @param array $a First list.
	 * @param array $b Second list.
	 * @return float Similarity in [0, 1].
	 */
	private function jaccard_similarity( array $a, array $b ): float {
		if ( empty( $a ) || empty( $b ) ) {
			return 0.0;
		}
		$set_a = array_flip( $a );
		$set_b = array_flip( $b );
		$inter = count( array_intersect_key( $set_a, $set_b ) );
		if ( 0 === $inter ) {
			return 0.0;
		}
		$union = count( $set_a ) + count( $set_b ) - $inter;
		return $union > 0 ? $inter / $union : 0.0;
	}

	/* ============================================================
	 * Phase 3 — Content Brief Generator (2.5)
	 * ============================================================ */

	/**
	 * Generate an AI-powered content brief for a topic.
	 *
	 * Combines deterministic signals from existing internal posts
	 * (similar titles, coverage density) with an AI-generated outline,
	 * recommended word count, questions, and FAQ suggestions.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function content_brief( $request ) {
		// Mode gating: 'full' (with competitor analysis) requires Pro Max.
		// 'lite' (LLM-only) is available to any user who can edit posts —
		// keeps the Generate Post brief panel useful for free/Pro users.
		$requested_mode = ( $request->get_param( 'mode' ) === 'lite' ) ? 'lite' : 'full';
		if ( 'full' === $requested_mode && ! atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required for competitor analysis.', 'antimanual' ) ],
				403
			);
		}

		$result = \Antimanual\ContentBriefService::generate( [
			'topic'           => (string) $request->get_param( 'topic' ),
			'focus_keyword'   => (string) $request->get_param( 'focus_keyword' ),
			'target_audience' => (string) $request->get_param( 'target_audience' ),
			'intent'          => (string) ( $request->get_param( 'intent' ) ?: 'informational' ),
			'competitor_urls' => (array) ( $request->get_param( 'competitor_urls' ) ?? [] ),
			'mode'            => $requested_mode,
			'bypass_cache'    => (bool) $request->get_param( 'bypass_cache' ),
		] );

		if ( is_wp_error( $result ) ) {
			$status = ( 'missing_topic' === $result->get_error_code() ) ? 400 : 500;
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => $result->get_error_message() ],
				$status
			);
		}

		return rest_ensure_response( [
			'success' => true,
			'data'    => $result,
		] );
	}
}
