<?php
/**
 * AI REST API controller.
 *
 * Handles AI-powered page generation and refinement endpoints.
 * Relays prompts to the configured AI provider and returns structured responses.
 *
 * @package Antimanual_Builder
 * @since   1.0.0
 */

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

/**
 * Class AMBLD_API_Ai_Api
 *
 * REST API endpoints for AI page generation and refinement.
 *
 * @since 1.0.0
 */
class AMBLD_API_Ai_Api {

	use AMBLD_API_Builder_Meta_Trait;

	/**
	 * Validate that a URL is safe to fetch from the server (SSRF guard).
	 *
	 * Rejects:
	 *   - Non-HTTP(S) schemes (file://, gopher://, ftp://, etc.)
	 *   - Empty or syntactically-invalid URLs
	 *   - Hostnames that resolve to private (RFC1918), loopback,
	 *     link-local, cloud-metadata (169.254.169.254), or
	 *     unspecified (0.0.0.0 / ::) IP ranges
	 *   - Direct IP-literal hosts in any of the above ranges
	 *   - `localhost` and similar reserved names
	 *
	 * Returns the original URL string on success, WP_Error on rejection.
	 * Used by `/ai/generate-preset` to gate the user-supplied URL before
	 * passing to `wp_remote_get()`.
	 *
	 * @since  1.8.0
	 * @param  string $url Raw URL from request.
	 * @return string|WP_Error
	 */
	public static function sanitize_external_url( $url ) {
		$url = trim( (string) $url );
		if ( '' === $url ) {
			return new \WP_Error( 'invalid_url', __( 'URL is required.', 'antimanual-builder' ), array( 'status' => 400 ) );
		}

		$parts = wp_parse_url( $url );
		if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return new \WP_Error( 'invalid_url', __( 'URL is malformed.', 'antimanual-builder' ), array( 'status' => 400 ) );
		}

		$scheme = strtolower( (string) $parts['scheme'] );
		if ( 'http' !== $scheme && 'https' !== $scheme ) {
			return new \WP_Error( 'invalid_scheme', __( 'Only http and https URLs are allowed.', 'antimanual-builder' ), array( 'status' => 400 ) );
		}

		$host = strtolower( (string) $parts['host'] );
		$ips  = self::resolve_public_host_ips( $host );
		if ( is_wp_error( $ips ) ) {
			return $ips;
		}

		return $url;
	}

	/**
	 * Resolve a host to its IPs and verify every one is public.
	 *
	 * @since  0.4.0
	 * @param  string $host Lowercased hostname or IP literal.
	 * @return string[]|WP_Error Public IPs on success.
	 */
	private static function resolve_public_host_ips( $host ) {
		// Reject reserved hostnames before DNS lookup. `localhost` resolves
		// to 127.0.0.1 anyway, but blocking the name early gives a clearer
		// error and avoids the DNS roundtrip.
		$reserved_names = array( 'localhost', 'ip6-localhost', 'ip6-loopback', 'broadcasthost' );
		if ( in_array( $host, $reserved_names, true ) ) {
			return new \WP_Error( 'private_host', __( 'URLs to local/private hosts are not allowed.', 'antimanual-builder' ), array( 'status' => 400 ) );
		}

		// Resolve to IPs (covers DNS rebinding at the pre-flight layer —
		// any IP returned by the resolver must be public).
		$ips = array();
		if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
			$ips[] = $host;
		} else {
			// gethostbynamel returns false on failure; treat as unresolvable
			// → reject (we can't verify it's safe).
			$resolved = @gethostbynamel( $host ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
			if ( ! is_array( $resolved ) || empty( $resolved ) ) {
				return new \WP_Error( 'host_unresolvable', __( 'Could not resolve the host.', 'antimanual-builder' ), array( 'status' => 400 ) );
			}
			$ips = $resolved;
		}

		foreach ( $ips as $ip ) {
			// `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE` rejects
			// RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8, ::1),
			// link-local (169.254/16, fe80::/10), and reserved ranges.
			if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
				return new \WP_Error(
					'private_host',
					/* translators: %s: blocked IP address. */
					sprintf( __( 'Host resolves to a private or reserved address (%s).', 'antimanual-builder' ), $ip ),
					array( 'status' => 400 )
				);
			}
		}

		return $ips;
	}

	/**
	 * Fetch a user-supplied external URL with the full SSRF guard.
	 *
	 * Beyond sanitize_external_url()'s pre-flight check, this closes the
	 * remaining TOCTOU window: the IPs validated at check time are PINNED
	 * for the actual request (CURLOPT_RESOLVE), so a DNS record that flips
	 * to a private address between the two lookups cannot redirect the
	 * fetch. Redirects are followed manually, re-validating and re-pinning
	 * every hop, so a public host 302-ing to 127.0.0.1 is also blocked.
	 *
	 * @since  0.4.0
	 * @param  string $url      Raw URL from the request.
	 * @param  array  $args     wp_remote_get args (redirection handled here).
	 * @param  int    $max_hops Maximum redirects to follow.
	 * @return array|WP_Error wp_remote_get response or error.
	 */
	public static function fetch_validated_external_url( $url, $args = array(), $max_hops = 3 ) {
		$args['redirection'] = 0;

		for ( $hop = 0; $hop <= $max_hops; $hop++ ) {
			$safe_url = self::sanitize_external_url( (string) $url );
			if ( is_wp_error( $safe_url ) ) {
				return $safe_url;
			}

			$parts = wp_parse_url( $safe_url );
			$host  = strtolower( (string) $parts['host'] );
			$port  = isset( $parts['port'] )
				? (int) $parts['port']
				: ( 'https' === strtolower( (string) $parts['scheme'] ) ? 443 : 80 );
			$ips   = self::resolve_public_host_ips( $host );
			if ( is_wp_error( $ips ) ) {
				return $ips;
			}

			// Pin the validated IPs for the cURL transport. The streams
			// fallback can't pin — there the pre-flight check above is the
			// (pre-existing) level of protection.
			$pin = static function ( $handle ) use ( $host, $port, $ips ) {
				if ( defined( 'CURLOPT_RESOLVE' ) ) {
					curl_setopt( $handle, CURLOPT_RESOLVE, array( $host . ':' . $port . ':' . implode( ',', $ips ) ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
				}
			};

			add_action( 'http_api_curl', $pin );
			$response = wp_remote_get( $safe_url, $args );
			remove_action( 'http_api_curl', $pin );

			if ( is_wp_error( $response ) ) {
				return $response;
			}

			$code = (int) wp_remote_retrieve_response_code( $response );
			if ( $code < 300 || $code >= 400 ) {
				return $response;
			}

			$location = wp_remote_retrieve_header( $response, 'location' );
			if ( '' === (string) $location ) {
				return $response;
			}

			// Relative redirect targets resolve against the current URL.
			$url = \WP_Http::make_absolute_url( (string) $location, $safe_url );
		}

		return new \WP_Error(
			'too_many_redirects',
			__( 'The URL redirected too many times.', 'antimanual-builder' ),
			array( 'status' => 400 )
		);
	}

	/**
	 * REST namespace.
	 *
	 * @since 1.0.0
	 * @var   string
	 */
	private $namespace = 'ambld/v1';

	/**
	 * Shared singleton instance.
	 *
	 * @since 0.7.0
	 * @var   AMBLD_API_Ai_Api|null
	 */
	private static $instance = null;

	/**
	 * Return the shared instance, creating it on first use.
	 *
	 * This class has no constructor side effects, so a lazily created instance
	 * is safe to reuse. The Pro import controller (AMBLD_Pro\Api\Import_Controller)
	 * calls this to reach the shared AI/HTML helpers — call_ai_api(),
	 * get_configured_ai_api_key(), get_inner_html(), serialize_element_attributes(),
	 * load_project_html_document(), collect_script_attributes(),
	 * extract_project_document_attributes() — which are why those methods are
	 * public. The free REST bootstrap continues to instantiate its own copy.
	 *
	 * @since  0.7.0
	 * @return AMBLD_API_Ai_Api
	 */
	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * Transient key prefix for clean-rebuild migration jobs.
	 *
	 * @since 1.0.0
	 * @var   string
	 */
	const CLEAN_MIGRATE_JOB_PREFIX = 'ambld_clean_migrate_';

	/**
	 * TTL (seconds) for a clean-rebuild migration job (30 minutes).
	 *
	 * @since 1.0.0
	 * @var   int
	 */
	const CLEAN_MIGRATE_JOB_TTL = 1800;

	/**
	 * Number of source classes named per AI batch / per job step.
	 *
	 * @since 1.0.0
	 * @var   int
	 */
	const CLEAN_MIGRATE_NAME_BATCH = 60;

	/**
	 * Maximum distinct classes the AI is asked to name (cost guard). Excess
	 * classes are left unrenamed rather than spending unbounded AI calls.
	 *
	 * @since 1.0.0
	 * @var   int
	 */
	const CLEAN_MIGRATE_MAX_CLASSES = 600;

	/**
	 * Register routes.
	 *
	 * @since  1.0.0
	 * @return void
	 */
	public function register_routes() {
		// Generate a full page from a prompt.
		register_rest_route(
			$this->namespace,
			'/ai/generate',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'generate' ),
				'permission_callback' => array( $this, 'can_use_ai' ),
				'args'                => array(
					'prompt' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_textarea_field',
					),
					'context' => array(
						'default'           => '',
						'sanitize_callback' => 'sanitize_textarea_field',
					),
					'pageSettings' => array(
						'default' => array(),
					),
				),
			)
		);

		// Refine an existing page based on instructions.
		register_rest_route(
			$this->namespace,
			'/ai/refine',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'refine' ),
				'permission_callback' => array( $this, 'can_use_ai' ),
				'args'                => array(
					'prompt' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_textarea_field',
					),
					'html' => array(
						'required' => true,
					),
					'selectedSection' => array(
						'default'           => '',
						'sanitize_callback' => 'sanitize_textarea_field',
					),
					'pageSettings' => array(
						'default' => array(),
					),
				),
			)
		);

		// Plan: produce a structured JSON plan before generation.
		register_rest_route(
			$this->namespace,
			'/ai/plan',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'plan' ),
				'permission_callback' => array( $this, 'can_use_ai' ),
				'args'                => array(
					'prompt'           => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_textarea_field',
					),
					'currentHtml'      => array(
						'default' => '',
					),
					'currentCss'       => array(
						'default' => '',
					),
					'selectedSection'  => array(
						'default' => '',
					),
					'pageSettings'     => array(
						'default' => array(),
					),
					'conversationHistory' => array(
						'default' => array(),
					),
					'useKnowledgeBase' => array(
						'default' => false,
					),
					'attachments'      => array(
						'default'           => array(),
						'sanitize_callback' => array( $this, 'sanitize_attachments_arg' ),
					),
					'entityType'       => array(
						'default'           => 'page',
						'sanitize_callback' => 'sanitize_key',
						'validate_callback' => function ( $value ) {
							return in_array( $value, array( 'page', 'component', 'template' ), true );
						},
					),
					'templateKind'     => array(
						'default'           => '',
						'sanitize_callback' => 'sanitize_key',
						'validate_callback' => function ( $value ) {
							return in_array( $value, array( '', 'header', 'footer' ), true );
						},
					),
				),
			)
		);

		// Enhance/expand a user prompt into a more detailed one.
		register_rest_route(
			$this->namespace,
			'/ai/enhance-prompt',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'enhance_prompt' ),
				'permission_callback' => array( $this, 'can_use_ai' ),
				'args'                => array(
					'prompt' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_textarea_field',
					),
				),
			)
		);

		// Convert AI HTML response to block JSON.
		register_rest_route(
			$this->namespace,
			'/ai/html-to-blocks',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'html_to_blocks' ),
				'permission_callback' => array( $this, 'can_use_ai' ),
				'args'                => array(
					'html' => array(
						'required' => true,
					),
				),
			)
		);

		// Save Design Defaults.
		register_rest_route(
			$this->namespace,
			'/preferences/design-defaults',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'save_design_defaults' ),
				'permission_callback' => array( $this, 'can_manage_options' ),
				// Schema-level sanitization. The callback itself does
				// stricter type/whitelist validation (font presets, color
				// validity, template ID existence) — these args are the
				// REST gateway preventing oversized strings or weird types
				// from reaching the callback.
				'args'                => array(
					'tone'             => array( 'default' => '', 'sanitize_callback' => 'sanitize_text_field' ),
					'designStyle'      => array( 'default' => '', 'sanitize_callback' => 'sanitize_text_field' ),
					'fontPreset'       => array( 'default' => '', 'sanitize_callback' => 'sanitize_text_field' ),
					'headingFont'      => array( 'default' => '', 'sanitize_callback' => 'sanitize_text_field' ),
					'bodyFont'         => array( 'default' => '', 'sanitize_callback' => 'sanitize_text_field' ),
					'borderRadius'     => array( 'default' => '', 'sanitize_callback' => 'sanitize_text_field' ),
					'spacing'          => array( 'default' => '', 'sanitize_callback' => 'sanitize_text_field' ),
					'brandColors'      => array( 'default' => array(), 'type' => 'object' ),
					'headerTemplateId' => array( 'default' => 0, 'sanitize_callback' => 'absint' ),
					'footerTemplateId' => array( 'default' => 0, 'sanitize_callback' => 'absint' ),
				),
			)
		);

		// Faithful (snapshot) page migration.
		register_rest_route(
			$this->namespace,
			'/migrate-page-direct',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'migrate_page_direct' ),
				// Per-post permission check — the broad `edit_pages`
				// capability is necessary but not sufficient. A user
				// with `edit_pages` could still be denied access to a
				// specific page (private, password-protected, multisite
				// restrictions, etc.). Verify the target post directly.
				'permission_callback' => function ( $request ) {
					$post_id = absint( $request->get_param( 'postId' ) );
					if ( $post_id <= 0 ) {
						return new \WP_Error(
							'rest_invalid_post',
							__( 'Invalid post ID.', 'antimanual-builder' ),
							array( 'status' => 400 )
						);
					}
					if ( ! current_user_can( 'edit_post', $post_id ) ) {
						return new \WP_Error(
							'rest_forbidden',
							__( 'You do not have permission to migrate this page.', 'antimanual-builder' ),
							array( 'status' => 403 )
						);
					}
					return true;
				},
				'args'                => array(
					'postId'   => array(
						'required'          => true,
						'sanitize_callback' => 'absint',
					),
					'behavior' => array(
						'required'          => false,
						'sanitize_callback' => 'sanitize_key',
					),
				),
			)
		);

		// AI-assisted "clean rebuild" migration (deterministic rename) —
		// background job. Start captures + plans + collects classes.
		register_rest_route(
			$this->namespace,
			'/migrate-clean/start',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'migrate_clean_start' ),
				'permission_callback' => function ( $request ) {
					$post_id = absint( $request->get_param( 'postId' ) );
					if ( $post_id <= 0 ) {
						return new \WP_Error( 'rest_invalid_post', __( 'Invalid post ID.', 'antimanual-builder' ), array( 'status' => 400 ) );
					}
					if ( ! current_user_can( 'edit_post', $post_id ) ) {
						return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to migrate this page.', 'antimanual-builder' ), array( 'status' => 403 ) );
					}
					return $this->can_use_ai();
				},
				'args'                => array(
					'postId'   => array(
						'required'          => true,
						'sanitize_callback' => 'absint',
					),
					'behavior' => array(
						'required'          => false,
						'sanitize_callback' => 'sanitize_key',
					),
				),
			)
		);

		// Advance a clean-rebuild migration job by one unit (one naming
		// batch, or the final deterministic rewrite + persist).
		register_rest_route(
			$this->namespace,
			'/migrate-clean/step',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'migrate_clean_step' ),
				'permission_callback' => array( $this, 'can_use_ai' ),
				'args'                => array(
					'jobId' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);

		// Poll a clean-rebuild migration job's progress.
		register_rest_route(
			$this->namespace,
			'/migrate-clean/status',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'migrate_clean_status' ),
				'permission_callback' => array( $this, 'can_use_ai' ),
				'args'                => array(
					'jobId' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);

		// Save AI token budget preferences (max_output_tokens, reasoning
		// effort). Both are user-tunable knobs that directly affect AI cost
		// and quality on every chat call. The thinking budget is no longer a
		// stored knob — it is derived from reasoning effort per model.
		register_rest_route(
			$this->namespace,
			'/preferences/ai-budget',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'save_ai_budget' ),
				'permission_callback' => array( $this, 'can_manage_options' ),
				'args'                => array(
					'maxOutputTokens' => array(
						'required'          => false,
						'sanitize_callback' => 'absint',
					),
					'reasoningEffort' => array(
						'required'          => false,
						'sanitize_callback' => 'sanitize_key',
						'validate_callback' => function ( $value ) {
							return in_array( $value, array( 'none', 'low', 'minimal', 'medium', 'high', 'xhigh', 'max' ), true );
						},
					),
				),
			)
		);

		// Re-run the shipped RAG seed corpus indexing. Useful after a content
		// update ships (corpus body hashes change → previously-skipped chunks
		// re-embed), or after the user switches the parent embedding provider
		// (old vectors are dimension-mismatched). Idempotent — files whose
		// hash already matches are skipped inside the indexer.
		register_rest_route(
			$this->namespace,
			'/preferences/rag-reseed',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'reseed_rag_corpus' ),
				'permission_callback' => array( $this, 'can_manage_options' ),
				'args'                => array(
					'mode' => array(
						'default'           => 'cron',
						'sanitize_callback' => 'sanitize_key',
						'validate_callback' => function ( $value ) {
							return in_array( $value, array( 'cron', 'sync' ), true );
						},
					),
				),
			)
		);

		// Status payload for the admin "Knowledge base" panel — chunk counts
		// by source type, pending flag, last completion timestamp.
		register_rest_route(
			$this->namespace,
			'/preferences/rag-status',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_rag_status' ),
				'permission_callback' => array( $this, 'can_manage_options' ),
			)
		);

		// Diagnostic probe — run a single retrieval pass against an arbitrary
		// query string and return the raw top-K results without going through
		// the full chat pipeline. Useful for verifying RAG is wired correctly
		// before debugging chat-side issues.
		register_rest_route(
			$this->namespace,
			'/preferences/rag-probe',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'probe_rag' ),
				'permission_callback' => array( $this, 'can_manage_options' ),
				'args'                => array(
					'q' => array(
						'required'          => true,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'k' => array(
						'default'           => 6,
						'sanitize_callback' => 'absint',
					),
					'types' => array(
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);

		// Save Migration Behavior preference.
		register_rest_route(
			$this->namespace,
			'/preferences/migration-behavior',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'save_migration_behavior' ),
				'permission_callback' => array( $this, 'can_manage_options' ),
				'args'                => array(
					'behavior' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_key',
						'validate_callback' => function ( $value ) {
							return in_array( $value, array( 'replace', 'duplicate' ), true );
						},
					),
				),
			)
		);

		// Generate a design preset from a website URL via AI.
		register_rest_route(
			$this->namespace,
			'/ai/generate-preset',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'generate_preset' ),
				'permission_callback' => array( $this, 'can_use_ai' ),
				'args'                => array(
					'url' => array(
						'required'          => true,
						'sanitize_callback' => 'esc_url_raw',
						'validate_callback' => function ( $value ) {
							return filter_var( $value, FILTER_VALIDATE_URL ) && preg_match( '/^https?:\/\//i', $value );
						},
					),
				),
			)
		);

		// Save a custom preset to the database.
		register_rest_route(
			$this->namespace,
			'/preferences/custom-presets',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'save_custom_preset' ),
					'permission_callback' => array( $this, 'can_manage_options' ),
				),
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_custom_preset' ),
					'permission_callback' => array( $this, 'can_manage_options' ),
					'args'                => array(
						'id' => array(
							'required'          => true,
							'sanitize_callback' => 'sanitize_key',
						),
					),
				),
			)
		);
	}

	/**
	 * Check if user can manage options (to save settings).
	 *
	 * @since  1.0.0
	 * @return bool|WP_Error
	 */
	public function can_manage_options() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return new \WP_Error(
				'rest_forbidden',
				__( 'You do not have permission to manage settings.', 'antimanual-builder' ),
				array( 'status' => 403 )
			);
		}
		return true;
	}

	/**
	 * Save Design Defaults.
	 *
	 * Stores global design preferences that will be used as defaults in the editor.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request REST request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function save_design_defaults( $request ) {
		$payload = $request->get_json_params();
		$allowed_tones = array( 'professional', 'friendly', 'creative', 'formal', 'casual', 'persuasive' );
		$allowed_design_styles = array( '', 'neo-brutalism', 'neumorphism', 'glassmorphism', 'editorial-minimal', 'retro', 'flat-design' );
		$allowed_font_presets = array( '', 'poppins-inter', 'playfair-lato', 'outfit-dm', 'space-mono', 'raleway-opensans' );
		$allowed_border_radius = array( '', 'sharp', 'rounded', 'pill' );
		$allowed_spacing = array( '', 'compact', 'balanced', 'airy' );

		if ( ! is_array( $payload ) ) {
			return new \WP_Error( 'invalid_payload', __( 'Invalid payload.', 'antimanual-builder' ), array( 'status' => 400 ) );
		}

		$design_defaults = array();

		if ( isset( $payload['tone'] ) ) {
			$tone = sanitize_key( $payload['tone'] );
			if ( in_array( $tone, $allowed_tones, true ) ) {
				$design_defaults['tone'] = $tone;
			}
		}

		if ( isset( $payload['designStyle'] ) ) {
			$design_style = sanitize_text_field( $payload['designStyle'] );
			if ( in_array( $design_style, $allowed_design_styles, true ) ) {
				$design_defaults['designStyle'] = $design_style;
			}
		}

		if ( isset( $payload['fontPreset'] ) ) {
			$font_preset = sanitize_text_field( $payload['fontPreset'] );
			if ( in_array( $font_preset, $allowed_font_presets, true ) ) {
				$design_defaults['fontPreset'] = $font_preset;
			}
		}

		if ( isset( $payload['headingFont'] ) ) {
			$design_defaults['headingFont'] = sanitize_text_field( $payload['headingFont'] );
		}

		if ( isset( $payload['bodyFont'] ) ) {
			$design_defaults['bodyFont'] = sanitize_text_field( $payload['bodyFont'] );
		}

		if ( isset( $payload['borderRadius'] ) ) {
			$border_radius = sanitize_text_field( $payload['borderRadius'] );
			if ( in_array( $border_radius, $allowed_border_radius, true ) ) {
				$design_defaults['borderRadius'] = $border_radius;
			}
		}

		if ( isset( $payload['spacing'] ) ) {
			$spacing = sanitize_text_field( $payload['spacing'] );
			if ( in_array( $spacing, $allowed_spacing, true ) ) {
				$design_defaults['spacing'] = $spacing;
			}
		}

		if ( isset( $payload['brandColors'] ) && is_array( $payload['brandColors'] ) ) {
			$brand_colors = array();
			if ( isset( $payload['brandColors']['primary'] ) ) {
				$brand_colors['primary'] = sanitize_hex_color( $payload['brandColors']['primary'] ) ?: '';
			}
			if ( isset( $payload['brandColors']['secondary'] ) ) {
				$brand_colors['secondary'] = sanitize_hex_color( $payload['brandColors']['secondary'] ) ?: '';
			}
			if ( isset( $payload['brandColors']['background'] ) ) {
				$brand_colors['background'] = sanitize_hex_color( $payload['brandColors']['background'] ) ?: '';
			}
			if ( isset( $payload['brandColors']['accent'] ) ) {
				$brand_colors['accent'] = sanitize_hex_color( $payload['brandColors']['accent'] ) ?: '';
			}
			$design_defaults['brandColors'] = $brand_colors;
		}

		if ( array_key_exists( 'headerTemplateId', $payload ) ) {
			$header_template_id = absint( $payload['headerTemplateId'] );
			$header_template_id = $this->validate_template_id_for_kind( $header_template_id, 'header' );
			$design_defaults['headerTemplateId'] = $header_template_id > 0 ? $header_template_id : 0;
		}

		if ( array_key_exists( 'footerTemplateId', $payload ) ) {
			$footer_template_id = absint( $payload['footerTemplateId'] );
			$footer_template_id = $this->validate_template_id_for_kind( $footer_template_id, 'footer' );
			$design_defaults['footerTemplateId'] = $footer_template_id > 0 ? $footer_template_id : 0;
		}

		update_option( 'ambld_design_defaults', $design_defaults );

		return new \WP_REST_Response(
			array( 'message' => __( 'Design defaults saved successfully.', 'antimanual-builder' ) ),
			200
		);
	}

	/**
	 * Validate template ID against post type and template kind.
	 *
	 * @since  1.0.0
	 * @param  int    $template_id Template post ID.
	 * @param  string $kind        Required kind: header|footer.
	 * @return int
	 */
	private function validate_template_id_for_kind( $template_id, $kind ) {
		$template_id = absint( $template_id );
		$kind        = sanitize_key( (string) $kind );

		if ( $template_id <= 0 || ! in_array( $kind, array( 'header', 'footer' ), true ) ) {
			return 0;
		}

		$post = get_post( $template_id );
		if ( ! ( $post instanceof \WP_Post ) ) {
			return 0;
		}

		if ( AMBLD_Post_Type_Builder_Template::POST_TYPE !== $post->post_type ) {
			return 0;
		}

		if ( ! in_array( $post->post_status, AMBLD_Render_Template_Resolver::renderable_statuses(), true ) ) {
			return 0;
		}

		$template_kind = sanitize_key( (string) get_post_meta( $template_id, '_ambld_template_kind', true ) );

		return $template_kind === $kind ? $template_id : 0;
	}

	/**
	 * Check if user can use AI features.
	 *
	 * @since  1.0.0
	 * @return bool|WP_Error
	 */
	public function can_use_ai() {
		if ( ! current_user_can( 'edit_pages' ) ) {
			return new \WP_Error(
				'rest_forbidden',
				__( 'You do not have permission to use AI features.', 'antimanual-builder' ),
				array( 'status' => 403 )
			);
		}

		if ( ! class_exists( 'AMBLD_Antimanual_Ai_Provider' ) ) {
			return new \WP_Error(
				'ai_not_configured',
				__( 'AI provider integration is unavailable. The Antimanual plugin must be active.', 'antimanual-builder' ),
				array( 'status' => 400 )
			);
		}

		$api_key = $this->get_configured_ai_api_key();

		if ( empty( $api_key ) ) {
			return new \WP_Error(
				'ai_not_configured',
				AMBLD_Antimanual_Ai_Provider::get_status_message(),
				array( 'status' => 400 )
			);
		}

		return true;
	}

	/**
	 * Get the configured AI API key for the active provider.
	 *
	 * @since  1.0.0
	 * @return string
	 */
	public function get_configured_ai_api_key() {
		if ( ! class_exists( 'AMBLD_Antimanual_Ai_Provider' ) ) {
			return '';
		}
		return AMBLD_Antimanual_Ai_Provider::get_api_key();
	}

	/**
	 * Generate a full page from a prompt.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function generate( $request ) {
		$prompt  = $request->get_param( 'prompt' );
		$context = $request->get_param( 'context' );
		$page_settings = $request->get_param( 'pageSettings' ) ?: array();

		$system_prompt = $this->build_generation_system_prompt( $page_settings );
		$user_prompt   = $prompt;

		if ( ! empty( $context ) ) {
			$user_prompt .= "\n\nAdditional context: " . $context;
		}

		$response = $this->call_ai_api( $system_prompt, $user_prompt );

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$parsed = $this->parse_generated_page_response( isset( $response['content'] ) ? $response['content'] : '' );

		return new \WP_REST_Response(
			array(
				'html'    => $parsed['html'],
				'title'   => $parsed['title'],
				'message' => __( 'Page generated successfully.', 'antimanual-builder' ),
			),
			200
		);
	}

	/**
	 * Migrate a page directly without AI ("snapshot" migration).
	 *
	 * Captures the page's real front-end render via a loopback request to
	 * its own permalink, then processes that HTML through the same
	 * import-document model the HTML importer uses (head markup, page-scoped
	 * CSS, an ordered per-script JS manifest, html/body attributes). Because
	 * the source page lives on this same site, its asset URLs already resolve
	 * — stylesheets stay as <link>s (so their own url() refs keep working) and
	 * scripts are preserved in place, instead of inlining the whole theme.
	 *
	 * The entire migration is persisted server-side in one transaction, so a
	 * failure never leaves a half-saved page or an orphaned duplicate draft.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function migrate_page_direct( $request ) {
		$post_id = (int) $request->get_param( 'postId' );
		$post    = get_post( $post_id );

		if ( ! $post || AMBLD_Page_Integration::POST_TYPE !== $post->post_type ) {
			return new \WP_Error(
				'not_found',
				__( 'Page not found.', 'antimanual-builder' ),
				array( 'status' => 404 )
			);
		}

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return new \WP_Error(
				'forbidden',
				__( 'You do not have permission to migrate this page.', 'antimanual-builder' ),
				array( 'status' => 403 )
			);
		}

		if ( AMBLD_Page_Integration::is_builder_page( $post ) ) {
			return new \WP_Error(
				'already_builder_page',
				__( 'This page is already managed by AM Builder.', 'antimanual-builder' ),
				array( 'status' => 400 )
			);
		}

		if ( ! AMBLD_Page_Integration::has_migratable_content( $post ) ) {
			return new \WP_Error(
				'empty_page',
				__( 'This page does not have enough content to migrate.', 'antimanual-builder' ),
				array( 'status' => 400 )
			);
		}

		// Capture the real front-end render and process it BEFORE creating any
		// duplicate, so a fetch/parse failure can never orphan a draft.
		$rendered_html = $this->fetch_rendered_page_html( $post );
		if ( is_wp_error( $rendered_html ) ) {
			return $rendered_html;
		}

		$prepared = $this->prepare_live_page_snapshot( $rendered_html, $post );
		if ( is_wp_error( $prepared ) ) {
			return $prepared;
		}

		// Resolve target (same page for Replace, a fresh draft for Copy).
		$behavior  = $this->resolve_migration_behavior( $request );
		$target_id = $post_id;

		if ( 'duplicate' === $behavior ) {
			$new_id = $this->duplicate_page( $post );
			if ( is_wp_error( $new_id ) ) {
				return $new_id;
			}
			$target_id = $new_id;
		}

		$this->persist_migrated_snapshot( $target_id, $prepared );

		return new \WP_REST_Response(
			array(
				'done'      => true,
				'postId'    => $target_id,
				'newPostId' => $target_id !== $post_id ? $target_id : null,
				'title'     => $prepared['title'],
				'editUrl'   => add_query_arg(
					'post_id',
					$target_id,
					admin_url( 'admin.php?page=ambld-editor' )
				),
				'message'   => 'duplicate' === $behavior
					? __( 'Page duplicated and migrated to AM Builder.', 'antimanual-builder' )
					: __( 'Page migrated to AM Builder.', 'antimanual-builder' ),
			),
			200
		);
	}

	/**
	 * Resolve the migration behavior for a request.
	 *
	 * Prefers the per-click choice sent from the editor's Replace/Copy
	 * prompt; falls back to the saved `ambld_migration_behavior` default.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return string `replace` or `duplicate`.
	 */
	private function resolve_migration_behavior( $request ) {
		$behavior = sanitize_key( (string) $request->get_param( 'behavior' ) );

		if ( in_array( $behavior, array( 'replace', 'duplicate' ), true ) ) {
			return $behavior;
		}

		$saved = get_option( 'ambld_migration_behavior', 'duplicate' );

		return in_array( $saved, array( 'replace', 'duplicate' ), true ) ? $saved : 'duplicate';
	}

	/**
	 * Fetch a page's real front-end render via a loopback HTTP request.
	 *
	 * Published pages are fetched by permalink; drafts and other non-public
	 * statuses use the nonce-signed preview link. The current request's auth
	 * cookies are forwarded so the render matches what the logged-in author
	 * sees (and so previews authorize).
	 *
	 * @since  1.0.0
	 * @param  WP_Post $post Page post object.
	 * @return string|WP_Error Rendered HTML document, or an error.
	 */
	private function fetch_rendered_page_html( $post ) {
		$url = ( 'publish' === $post->post_status )
			? get_permalink( $post )
			: get_preview_post_link( $post );

		if ( empty( $url ) ) {
			return new \WP_Error(
				'migrate_no_url',
				__( 'Could not determine the page URL to render for migration.', 'antimanual-builder' ),
				array( 'status' => 500 )
			);
		}

		$cookies = array();
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		foreach ( $_COOKIE as $name => $value ) {
			if ( is_scalar( $value ) ) {
				$cookies[] = new \WP_Http_Cookie(
					array(
						'name'  => (string) $name,
						'value' => (string) wp_unslash( $value ),
					)
				);
			}
		}

		$response = wp_remote_get(
			$url,
			array(
				'timeout'     => 20,
				'redirection' => 3,
				'sslverify'   => false,
				'cookies'     => $cookies,
				'headers'     => array( 'X-AMB-Migration' => '1' ),
			)
		);

		if ( is_wp_error( $response ) ) {
			return new \WP_Error(
				'migrate_fetch_failed',
				sprintf(
					/* translators: %s: error message. */
					__( 'Could not load the page to migrate: %s', 'antimanual-builder' ),
					$response->get_error_message()
				),
				array( 'status' => 502 )
			);
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = (string) wp_remote_retrieve_body( $response );

		if ( $code < 200 || $code >= 300 || '' === trim( $body ) ) {
			return new \WP_Error(
				'migrate_fetch_failed',
				sprintf(
					/* translators: %d: HTTP status code. */
					__( 'The page could not be rendered for migration (HTTP %d).', 'antimanual-builder' ),
					$code
				),
				array( 'status' => 502 )
			);
		}

		return $body;
	}

	/**
	 * Process a live front-end render into an import-document payload.
	 *
	 * Produces the same shape as prepare_project_page_import(): head markup
	 * (stylesheets kept as <link>s), page-scoped CSS from <style> blocks, an
	 * ordered per-script JS manifest, and preserved html/body attributes.
	 * Asset URLs are absolutized so the clone renders identically regardless
	 * of where it is served from.
	 *
	 * @since  1.0.0
	 * @param  string  $html Rendered HTML document.
	 * @param  WP_Post $post Source page.
	 * @return array|WP_Error
	 */
	private function prepare_live_page_snapshot( $html, $post ) {
		$dom = $this->load_project_html_document( $html );

		if ( ! ( $dom instanceof \DOMDocument ) ) {
			return new \WP_Error(
				'migrate_parse_failed',
				__( 'The page markup could not be parsed for migration.', 'antimanual-builder' ),
				array( 'status' => 500 )
			);
		}

		$base_url = get_permalink( $post );
		if ( empty( $base_url ) ) {
			$base_url = site_url();
		}

		$body         = $dom->getElementsByTagName( 'body' )->item( 0 );
		$body         = $body ? $body : $dom->documentElement;
		$html_element = $dom->getElementsByTagName( 'html' )->item( 0 );
		$head_node    = $dom->getElementsByTagName( 'head' )->item( 0 );
		$xpath        = new \DOMXPath( $dom );

		// The admin bar (and its assets) only appear because we render as a
		// logged-in user — strip it from the clone.
		$this->remove_admin_bar_nodes( $xpath );

		$custom_css_chunks = array();
		$head_markup       = array();
		$script_manifest   = array();

		if ( $head_node ) {
			// Head <style> → page-scoped CSS.
			foreach ( iterator_to_array( $xpath->query( './/style', $head_node ) ) as $style_element ) {
				$text = trim( $style_element->textContent );
				if ( '' !== $text ) {
					$custom_css_chunks[] = $text;
				}
				$style_element->parentNode->removeChild( $style_element );
			}

			// Head <link> → kept in head markup with an absolute href.
			// Stylesheets stay links so their own relative url() refs resolve
			// against the source file rather than against this page.
			foreach ( iterator_to_array( $xpath->query( './/link[@href]', $head_node ) ) as $link_element ) {
				$abs = $this->absolutize_migration_url( $link_element->getAttribute( 'href' ), $base_url );
				if ( '' !== $abs ) {
					$link_element->setAttribute( 'href', $abs );
				}
				$head_markup[] = $dom->saveHTML( $link_element );
				$link_element->parentNode->removeChild( $link_element );
			}

			// Head <script> → JS manifest (inline kept verbatim, src by URL).
			foreach ( iterator_to_array( $xpath->query( './/script', $head_node ) ) as $script_element ) {
				$this->route_live_script( $script_element, $base_url, $script_manifest, 'head' );
				$script_element->parentNode->removeChild( $script_element );
			}
		}

		if ( $body instanceof \DOMElement ) {
			// Body <style> → page-scoped CSS.
			foreach ( iterator_to_array( $xpath->query( './/style', $body ) ) as $style_element ) {
				$text = trim( $style_element->textContent );
				if ( '' !== $text ) {
					$custom_css_chunks[] = $text;
				}
				$style_element->parentNode->removeChild( $style_element );
			}

			// Body <script> → JS manifest.
			foreach ( iterator_to_array( $xpath->query( './/script', $body ) ) as $script_element ) {
				$this->route_live_script( $script_element, $base_url, $script_manifest, 'body' );
				$script_element->parentNode->removeChild( $script_element );
			}

			// Absolutize remaining asset references (img/src, srcset, hrefs, …).
			$this->absolutize_migration_dom_assets( $body, $base_url );
		}

		$rendered_html = trim( $this->get_inner_html( $body ) );
		$html_attrs    = $this->serialize_element_attributes( $html_element );
		$body_attrs    = $this->serialize_element_attributes( $body instanceof \DOMElement ? $body : null );

		// Keep the remaining head inner (meta / <title> / SEO tags) — styles,
		// links and scripts were already relocated above. The standalone
		// template wp_kses()-es this to metadata-only, so it is the page's only
		// source of <title> and meta. Re-append the absolutized stylesheet
		// <link>s after it, matching the HTML-import path.
		$head_inner = $head_node ? trim( $this->get_inner_html( $head_node ) ) : '';
		$full_head  = trim( $head_inner . "\n" . implode( "\n", array_filter( $head_markup ) ) );

		$import_document = "<!DOCTYPE html>\n<html{$html_attrs}>\n<head>\n{$full_head}\n</head>\n<body{$body_attrs}>\n{$rendered_html}\n</body>\n</html>";

		return array(
			'title'                  => $post->post_title,
			'renderedHtml'           => $rendered_html,
			'customCss'              => trim( implode( "\n\n", array_filter( $custom_css_chunks ) ) ),
			'importedScriptsJson'    => wp_json_encode( $script_manifest ),
			'importedHeadMarkup'     => $full_head,
			'importedFooterMarkup'   => '',
			'importDocument'         => $import_document,
			'importedHtmlAttributes' => $this->extract_project_document_attributes( $html_element ),
			'importedBodyAttributes' => $this->extract_project_document_attributes( $body instanceof \DOMElement ? $body : null ),
		);
	}

	/**
	 * Route a live-page <script> element into the import JS manifest.
	 *
	 * Inline scripts keep their source verbatim; src scripts are recorded as
	 * remote references by absolute URL (they already serve from this site).
	 *
	 * @since  1.0.0
	 * @param  \DOMElement $script_element Script node.
	 * @param  string      $base_url       Page URL used to absolutize `src`.
	 * @param  array       $manifest       Manifest collector (by reference).
	 * @param  string      $position       `head` or `body`.
	 * @return void
	 */
	private function route_live_script( $script_element, $base_url, &$manifest, $position ) {
		if ( ! ( $script_element instanceof \DOMElement ) ) {
			return;
		}

		$src   = trim( (string) $script_element->getAttribute( 'src' ) );
		$attrs = $this->collect_script_attributes( $script_element );

		if ( '' === $src ) {
			$js_text = trim( $script_element->textContent );
			if ( '' !== $js_text ) {
				$manifest[] = array(
					'kind'     => 'inline',
					'position' => $position,
					'content'  => $js_text,
					'attrs'    => $attrs,
				);
			}
			return;
		}

		$abs = $this->absolutize_migration_url( $src, $base_url );
		if ( '' === $abs ) {
			return;
		}

		$manifest[] = array(
			'kind'     => 'remote',
			'position' => $position,
			'src'      => $abs,
			'attrs'    => $attrs,
		);
	}

	/**
	 * Resolve a possibly-relative URL to an absolute one for the clone.
	 *
	 * Non-fetchable schemes (data/mailto/tel/javascript/blob) and pure
	 * fragments are returned untouched.
	 *
	 * @since  1.0.0
	 * @param  string $url      Raw URL.
	 * @param  string $base_url Base URL to resolve against.
	 * @return string
	 */
	private function absolutize_migration_url( $url, $base_url ) {
		$url = trim( (string) $url );

		if ( '' === $url || str_starts_with( $url, '#' ) ) {
			return $url;
		}

		if ( preg_match( '#^(?:data|mailto|tel|javascript|blob):#i', $url ) ) {
			return $url;
		}

		if ( class_exists( '\WP_Http' ) ) {
			$abs = \WP_Http::make_absolute_url( $url, $base_url );
			if ( is_string( $abs ) && '' !== $abs ) {
				return $abs;
			}
		}

		return $url;
	}

	/**
	 * Absolutize asset references throughout a body subtree.
	 *
	 * @since  1.0.0
	 * @param  \DOMElement $root     Body element.
	 * @param  string      $base_url Base URL to resolve against.
	 * @return void
	 */
	private function absolutize_migration_dom_assets( $root, $base_url ) {
		$xpath = new \DOMXPath( $root->ownerDocument );

		foreach ( array( 'src', 'href', 'poster' ) as $attr ) {
			foreach ( iterator_to_array( $xpath->query( './/*[@' . $attr . ']', $root ) ) as $el ) {
				$el->setAttribute( $attr, $this->absolutize_migration_url( $el->getAttribute( $attr ), $base_url ) );
			}
		}

		foreach ( iterator_to_array( $xpath->query( './/*[@srcset]', $root ) ) as $el ) {
			$el->setAttribute( 'srcset', $this->absolutize_migration_srcset( $el->getAttribute( 'srcset' ), $base_url ) );
		}
	}

	/**
	 * Absolutize every candidate in a `srcset` attribute.
	 *
	 * @since  1.0.0
	 * @param  string $srcset   Raw srcset value.
	 * @param  string $base_url Base URL to resolve against.
	 * @return string
	 */
	private function absolutize_migration_srcset( $srcset, $base_url ) {
		$out = array();

		foreach ( explode( ',', (string) $srcset ) as $candidate ) {
			$candidate = trim( $candidate );
			if ( '' === $candidate ) {
				continue;
			}
			$bits  = preg_split( '/\s+/', $candidate, 2 );
			$url   = $this->absolutize_migration_url( $bits[0], $base_url );
			$out[] = isset( $bits[1] ) ? $url . ' ' . $bits[1] : $url;
		}

		return implode( ', ', $out );
	}

	/**
	 * Remove WordPress admin-bar markup and its assets from a rendered DOM.
	 *
	 * @since  1.0.0
	 * @param  \DOMXPath $xpath XPath bound to the rendered document.
	 * @return void
	 */
	private function remove_admin_bar_nodes( $xpath ) {
		$queries = array(
			'//*[@id="wpadminbar"]',
			'//link[contains(@id,"admin-bar")]',
			'//style[contains(@id,"admin-bar")]',
			'//script[contains(@id,"admin-bar")]',
		);

		foreach ( $queries as $query ) {
			foreach ( iterator_to_array( $xpath->query( $query ) ) as $node ) {
				if ( $node->parentNode ) {
					$node->parentNode->removeChild( $node );
				}
			}
		}
	}

	/**
	 * Persist a prepared snapshot onto a target page as builder content.
	 *
	 * Mirrors the per-page persistence in create_project_import_pages():
	 * sanitized rendered HTML + page CSS, the JS manifest, standalone render
	 * settings with the page's scope class, and the editable import document.
	 *
	 * @since  1.0.0
	 * @param  int   $post_id  Target page ID.
	 * @param  array $prepared Prepared snapshot payload.
	 * @return void
	 */
	private function persist_migrated_snapshot( $post_id, $prepared ) {
		$sanitized_rendered_html = $this->sanitize_builder_html_value( $prepared['renderedHtml'] );
		$sanitized_custom_css    = $this->sanitize_builder_css_value( $prepared['customCss'] );

		$page_settings = $this->sanitize_builder_page_settings_array(
			array(
				'layoutMode'             => 'canvas',
				'renderMode'             => 'standalone',
				'importedHeadMarkup'     => $prepared['importedHeadMarkup'],
				'importedFooterMarkup'   => $prepared['importedFooterMarkup'],
				'importedHtmlAttributes' => $prepared['importedHtmlAttributes'],
				'importedBodyAttributes' => $prepared['importedBodyAttributes'],
			)
		);

		// Scope class so compiled CSS targets this page's unique class on the
		// front end (matches the HTML-import path).
		$scope_class         = 'ambld-import-' . $post_id;
		$body_attrs          = isset( $page_settings['importedBodyAttributes'] ) ? (array) $page_settings['importedBodyAttributes'] : array();
		$existing_class      = isset( $body_attrs['class'] ) ? trim( (string) $body_attrs['class'] ) : '';
		$body_attrs['class'] = '' !== $existing_class ? $existing_class . ' ' . $scope_class : $scope_class;
		$page_settings['importedBodyAttributes'] = $body_attrs;

		$sync_scripts = array();
		if ( ! empty( $prepared['importedScriptsJson'] ) ) {
			$decoded = json_decode( (string) $prepared['importedScriptsJson'], true );
			if ( is_array( $decoded ) ) {
				$sync_scripts = $decoded;
			}
		}

		$this->save_builder_meta(
			$post_id,
			array(
				'renderedHtml'    => $sanitized_rendered_html,
				'customCss'       => $sanitized_custom_css,
				'customJs'        => '',
				'importedScripts' => $sync_scripts,
			)
		);

		update_post_meta(
			$post_id,
			'_ambld_page_settings',
			wp_slash( wp_json_encode( $page_settings ) )
		);

		// Keep post_content in sync so non-builder fallbacks still show the
		// migrated markup (mirrors how imported pages store rendered HTML).
		wp_update_post(
			array(
				'ID'           => $post_id,
				'post_content' => wp_slash( $sanitized_rendered_html ),
			)
		);

		if ( ! empty( $prepared['importDocument'] ) && current_user_can( 'unfiltered_html' ) ) {
			update_post_meta( $post_id, '_ambld_import_document', wp_slash( (string) $prepared['importDocument'] ) );
		}
	}

	/* ======================================================================
	 * AI-assisted "clean rebuild" migration (deterministic rename).
	 *
	 * Captures the live render AND the full CSS, then RENAMES machine-generated
	 * classes (e.g. `elementor-element-2eb1417e`) to readable, semantic names —
	 * applying the rename deterministically across both the HTML and the CSS
	 * SELECTORS while carrying every CSS declaration body VERBATIM. The AI is
	 * used ONLY to propose names + section labels; it never rewrites style
	 * values, so the rendered design is identical by construction. CSS lands in
	 * the custom-CSS zone, custom JS in the JS tab, framework libraries become
	 * CDN dependencies. Runs as a stepped background job so large pages never
	 * blow the PHP execution limit.
	 * ==================================================================== */

	/**
	 * Build the option key for a clean-rebuild migration job.
	 *
	 * Stored as a non-autoload OPTION (not a transient): the captured CSS can
	 * exceed an object cache's per-item limit (often ~1MB), and a transient
	 * that fails to cache is never re-read from the DB — get_transient just
	 * returns false ("job_not_found"). get_option always falls back to the DB.
	 *
	 * @param  string $job_id Job id.
	 * @return string
	 */
	private function clean_migration_job_key( $job_id ) {
		return self::CLEAN_MIGRATE_JOB_PREFIX . sanitize_key( $job_id );
	}

	/**
	 * Load a clean-rebuild migration job (expiring stale records on read).
	 *
	 * @param  string $job_id Job id.
	 * @return array|null
	 */
	private function get_clean_migration_job( $job_id ) {
		$key = $this->clean_migration_job_key( $job_id );
		$raw = get_option( $key, null );
		if ( ! is_string( $raw ) || '' === $raw ) {
			return null;
		}
		// Stored gzip-compressed (see save) to stay well under object-cache /
		// max_allowed_packet limits for pages with large CSS.
		if ( 0 === strpos( $raw, 'gz:' ) ) {
			$decoded = base64_decode( substr( $raw, 3 ), true );
			$raw     = ( false !== $decoded && function_exists( 'gzuncompress' ) ) ? @gzuncompress( $decoded ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors
			if ( false === $raw ) {
				return null;
			}
		}
		$job = maybe_unserialize( $raw );
		if ( ! is_array( $job ) ) {
			return null;
		}
		if ( isset( $job['expires'] ) && time() > (int) $job['expires'] ) {
			delete_option( $key );
			return null;
		}
		return $job;
	}

	/**
	 * Persist a clean-rebuild migration job.
	 *
	 * Serialized then gzip-compressed into a compact STRING option (autoload
	 * off). Compression keeps even large captured-CSS jobs small enough to
	 * survive object caches (~1MB item cap) and MySQL max_allowed_packet.
	 *
	 * @param  string $job_id Job id.
	 * @param  array  $job    Job state.
	 * @return void
	 */
	private function save_clean_migration_job( $job_id, $job ) {
		$job['expires'] = time() + self::CLEAN_MIGRATE_JOB_TTL;
		$payload        = maybe_serialize( $job );
		if ( function_exists( 'gzcompress' ) ) {
			$compressed = @gzcompress( $payload, 6 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
			if ( false !== $compressed ) {
				$payload = 'gz:' . base64_encode( $compressed );
			}
		}
		update_option( $this->clean_migration_job_key( $job_id ), $payload, false );
	}

	/**
	 * Delete a clean-rebuild migration job record.
	 *
	 * @param  string $job_id Job id.
	 * @return void
	 */
	private function delete_clean_migration_job( $job_id ) {
		delete_option( $this->clean_migration_job_key( $job_id ) );
	}

	/**
	 * Public progress snapshot of a job (no heavy payload fields).
	 *
	 * @param  string $job_id Job id.
	 * @param  array  $job    Job state.
	 * @return array
	 */
	private function clean_migration_progress( $job_id, $job ) {
		$labels = array(
			'naming'   => __( 'Renaming classes…', 'antimanual-builder' ),
			'finalize' => __( 'Rebuilding the page…', 'antimanual-builder' ),
			'done'     => __( 'Done.', 'antimanual-builder' ),
		);

		return array(
			'jobId'    => $job_id,
			'status'   => $job['status'],
			'done'     => (int) $job['done'],
			'total'    => (int) $job['total'],
			'label'    => isset( $labels[ $job['status'] ] ) ? $labels[ $job['status'] ] : '',
			'warnings' => array_values( array_slice( (array) $job['warnings'], 0, 20 ) ),
			'editUrl'  => isset( $job['editUrl'] ) ? $job['editUrl'] : '',
			'postId'   => isset( $job['targetId'] ) && $job['targetId'] ? (int) $job['targetId'] : (int) $job['postId'],
			'newPostId' => ( isset( $job['targetId'] ) && $job['targetId'] && (int) $job['targetId'] !== (int) $job['postId'] ) ? (int) $job['targetId'] : null,
		);
	}

	/**
	 * Start a clean-rebuild migration job: capture, plan sections, collect the
	 * rename-eligible classes. The actual AI naming happens on subsequent steps.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function migrate_clean_start( $request ) {
		$post_id = (int) $request->get_param( 'postId' );
		$post    = get_post( $post_id );

		if ( ! $post || AMBLD_Page_Integration::POST_TYPE !== $post->post_type ) {
			return new \WP_Error( 'not_found', __( 'Page not found.', 'antimanual-builder' ), array( 'status' => 404 ) );
		}
		if ( AMBLD_Page_Integration::is_builder_page( $post ) ) {
			return new \WP_Error( 'already_builder_page', __( 'This page is already managed by AM Builder.', 'antimanual-builder' ), array( 'status' => 400 ) );
		}
		if ( ! AMBLD_Page_Integration::has_migratable_content( $post ) ) {
			return new \WP_Error( 'empty_page', __( 'This page does not have enough content to migrate.', 'antimanual-builder' ), array( 'status' => 400 ) );
		}

		try {
			return $this->run_migrate_clean_start( $request, $post, $post_id );
		} catch ( \Throwable $e ) {
			error_log( '[AMB-MIGRATE] start: EXCEPTION post=' . $post_id . ' ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() ); // phpcs:ignore
			return new \WP_Error(
				'migrate_exception',
				/* translators: %s: error detail. */
				sprintf( __( 'Migration could not start: %s', 'antimanual-builder' ), $e->getMessage() ),
				array( 'status' => 500 )
			);
		}
	}

	/**
	 * Inner body of migrate_clean_start (wrapped in a Throwable guard above).
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @param  WP_Post         $post    Source page.
	 * @param  int             $post_id Source page ID.
	 * @return WP_REST_Response|WP_Error
	 */
	private function run_migrate_clean_start( $request, $post, $post_id ) {
		$source = $this->prepare_rename_migration_source( $post );
		if ( is_wp_error( $source ) ) {
			error_log( '[AMB-MIGRATE] start: prepare_source FAILED post=' . $post_id . ' code=' . $source->get_error_code() . ' msg=' . $source->get_error_message() ); // phpcs:ignore
			return $source;
		}
		error_log( sprintf( '[AMB-MIGRATE] start: captured post=%d bodyHtml=%d css=%d js=%d cdn=%d', $post_id, strlen( (string) $source['bodyHtml'] ), strlen( (string) $source['sourceCss'] ), strlen( (string) $source['customJs'] ), count( (array) $source['cdnResources'] ) ) ); // phpcs:ignore

		$section_nodes = $this->count_section_nodes( $source['bodyHtml'] );
		$outline       = $this->build_migration_outline( $source['bodyHtml'] );
		$sections      = $this->plan_migration_sections( $outline, $section_nodes );
		error_log( sprintf( '[AMB-MIGRATE] start: sections=%d wpPage=%s', $section_nodes, ( false !== strpos( $source['bodyHtml'], 'data-elementor-type="wp-page"' ) || false !== strpos( $source['bodyHtml'], "data-elementor-type='wp-page'" ) ) ? 'yes' : 'no' ) ); // phpcs:ignore

		$classes = $this->collect_rename_eligible_classes( $source['bodyHtml'], $source['jsText'], $source['cssClasses'] );
		$batches = array_chunk( $classes, self::CLEAN_MIGRATE_NAME_BATCH );

		// Reserve every original class name so no proposed name can collide
		// with a class we keep (which would let it inherit unrelated CSS).
		$reserved = array();
		foreach ( $classes as $entry ) {
			$reserved[ $entry['class'] ] = true;
		}
		foreach ( $source['keptClasses'] as $kept ) {
			$reserved[ $kept ] = true;
		}

		$behavior = $this->resolve_migration_behavior( $request );
		// Clean alphanumeric id (no dots, sanitize_key-safe and round-trip-safe).
		$job_id = wp_generate_password( 20, false, false );

		$job = array(
			'status'       => empty( $batches ) ? 'finalize' : 'naming',
			'postId'       => $post_id,
			'targetId'     => 0,
			'behavior'     => $behavior,
			'title'        => $source['title'],
			'bodyHtml'     => $source['bodyHtml'],
			'sourceCss'    => $source['sourceCss'],
			'customJs'     => $source['customJs'],
			'cdnResources' => $source['cdnResources'],
			'sections'     => $sections,
			'classBatches' => $batches,
			'batchCursor'  => 0,
			'nameMap'      => array(),
			'reserved'     => $reserved,
			'warnings'     => $source['warnings'],
			'done'         => 0,
			'total'        => count( $batches ) + 1,
		);

		$this->save_clean_migration_job( $job_id, $job );

		// Verify the record actually persisted. If the captured payload was too
		// large for the storage backend (e.g. MySQL max_allowed_packet), fail
		// here with a clear message rather than letting the first step 404.
		if ( ! $this->get_clean_migration_job( $job_id ) ) {
			error_log( sprintf( '[AMB-MIGRATE] start: STORE FAILED job=%s serialized~%d bytes', $job_id, strlen( (string) maybe_serialize( $job ) ) ) ); // phpcs:ignore
			return new \WP_Error(
				'migrate_store_failed',
				__( 'This page is too large to rebuild with AI. Try the "Exact copy (snapshot)" method instead.', 'antimanual-builder' ),
				array( 'status' => 500 )
			);
		}
		error_log( sprintf( '[AMB-MIGRATE] start: OK job=%s batches=%d total=%d', $job_id, count( $batches ), count( $batches ) + 1 ) ); // phpcs:ignore

		return new \WP_REST_Response( $this->clean_migration_progress( $job_id, $job ), 200 );
	}

	/**
	 * Advance a clean-rebuild migration job by one unit.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function migrate_clean_step( $request ) {
		$job_id = sanitize_text_field( (string) $request->get_param( 'jobId' ) );
		$job    = $this->get_clean_migration_job( $job_id );

		if ( ! $job ) {
			return new \WP_Error( 'job_not_found', __( 'Migration session expired. Please start again.', 'antimanual-builder' ), array( 'status' => 404 ) );
		}
		if ( ! current_user_can( 'edit_post', (int) $job['postId'] ) ) {
			return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to migrate this page.', 'antimanual-builder' ), array( 'status' => 403 ) );
		}

		if ( 'done' === $job['status'] ) {
			return new \WP_REST_Response( $this->clean_migration_progress( $job_id, $job ), 200 );
		}

		if ( 'naming' === $job['status'] ) {
			$batch = isset( $job['classBatches'][ $job['batchCursor'] ] ) ? $job['classBatches'][ $job['batchCursor'] ] : array();
			$names = $this->name_source_classes( $batch );

			$applied = 0;
			foreach ( $batch as $entry ) {
				$old = $entry['class'];
				$new = isset( $names[ $old ] ) ? $names[ $old ] : '';
				$new = $this->normalize_class_name( $new );
				if ( '' === $new || $new === $old ) {
					continue;
				}
				$new = $this->reserve_unique_class_name( $new, $job['reserved'] );
				$job['reserved'][ $new ] = true;
				$job['nameMap'][ $old ]  = $new;
				$applied++;
			}
			error_log( sprintf( '[AMB-MIGRATE] step naming: batch=%d size=%d aiNames=%d applied=%d mapTotal=%d', $job['batchCursor'], count( $batch ), count( (array) $names ), $applied, count( $job['nameMap'] ) ) ); // phpcs:ignore

			$job['batchCursor']++;
			$job['done']++;
			if ( $job['batchCursor'] >= count( $job['classBatches'] ) ) {
				$job['status'] = 'finalize';
			}
			$this->save_clean_migration_job( $job_id, $job );
			return new \WP_REST_Response( $this->clean_migration_progress( $job_id, $job ), 200 );
		}

		// status === 'finalize': run the deterministic rewrite + persist.
		try {
			$result = $this->finalize_clean_migration( $job );
		} catch ( \Throwable $e ) {
			error_log( '[AMB-MIGRATE] finalize EXCEPTION: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() ); // phpcs:ignore
			return new \WP_Error( 'migrate_finalize_exception', sprintf( __( 'Could not finish rebuilding: %s', 'antimanual-builder' ), $e->getMessage() ), array( 'status' => 500 ) );
		}
		if ( is_wp_error( $result ) ) {
			error_log( '[AMB-MIGRATE] finalize FAILED: ' . $result->get_error_message() ); // phpcs:ignore
			return $result;
		}

		$job['targetId'] = $result['targetId'];
		$job['editUrl']  = $result['editUrl'];
		$job['status']   = 'done';
		$job['done']     = $job['total'];

		// The frontend reads the editUrl from THIS response and reloads, so the
		// job record is no longer needed — drop it to avoid orphan options.
		$progress = $this->clean_migration_progress( $job_id, $job );
		$this->delete_clean_migration_job( $job_id );

		return new \WP_REST_Response( $progress, 200 );
	}

	/**
	 * Poll a clean-rebuild migration job.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function migrate_clean_status( $request ) {
		$job_id = sanitize_text_field( (string) $request->get_param( 'jobId' ) );
		$job    = $this->get_clean_migration_job( $job_id );
		if ( ! $job ) {
			return new \WP_Error( 'job_not_found', __( 'Migration session expired.', 'antimanual-builder' ), array( 'status' => 404 ) );
		}
		if ( empty( $job['postId'] ) || ! current_user_can( 'edit_post', (int) $job['postId'] ) ) {
			return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to access this migration job.', 'antimanual-builder' ), array( 'status' => 403 ) );
		}
		return new \WP_REST_Response( $this->clean_migration_progress( $job_id, $job ), 200 );
	}

	/**
	 * Capture a page for clean-rebuild migration: the rendered body, the FULL
	 * CSS (inline + same-origin external files, with @imports resolved), the
	 * custom JS, and the framework libraries (as CDN references).
	 *
	 * @since  1.0.0
	 * @param  WP_Post $post Source page.
	 * @return array|WP_Error
	 */
	private function prepare_rename_migration_source( $post ) {
		$html = $this->fetch_rendered_page_html( $post );
		if ( is_wp_error( $html ) ) {
			return $html;
		}

		$dom = $this->load_project_html_document( $html );
		if ( ! ( $dom instanceof \DOMDocument ) ) {
			return new \WP_Error( 'migrate_parse_failed', __( 'The page markup could not be parsed for migration.', 'antimanual-builder' ), array( 'status' => 500 ) );
		}

		$base_url = get_permalink( $post );
		if ( empty( $base_url ) ) {
			$base_url = site_url();
		}

		$body  = $dom->getElementsByTagName( 'body' )->item( 0 );
		$body  = $body ? $body : $dom->documentElement;
		$xpath = new \DOMXPath( $dom );
		$this->remove_admin_bar_nodes( $xpath );

		$css_chunks  = array();
		$cdn         = array();
		$cdn_seen    = array();
		$custom_js   = '';
		$js_text     = '';
		$warnings    = array();
		$root_vars   = array(); // Harvested Elementor global/kit CSS variables.
		$css_budget  = 1500000; // ~1.5MB cap on captured CSS.
		$css_total   = 0;

		$add_cdn = function ( $type, $url, $position ) use ( &$cdn, &$cdn_seen ) {
			$url = trim( (string) $url );
			if ( '' === $url || ! preg_match( '#^https?://#i', $url ) ) {
				return;
			}
			$key = $type . '|' . $url;
			if ( isset( $cdn_seen[ $key ] ) ) {
				return;
			}
			$cdn_seen[ $key ] = true;
			$entry            = array( 'type' => $type, 'url' => $url );
			if ( 'js' === $type ) {
				$entry['position'] = 'body';
				$entry['defer']    = true;
			}
			$cdn[] = $entry;
		};

		// Single document-order pass over <style>, stylesheet <link>s and
		// <script>s so the captured CDN assets keep their EXACT original order.
		//
		// Rule: files DIRECTLY CONNECTED to this page — inline <style>, the
		// Elementor per-page CSS (`post-{id}.css`), and inline scripts — flow
		// into the builder CSS/JS pipeline (inlined → renamed → editable).
		// Every BASE file — WordPress core (/wp-includes/), plugins, theme,
		// Elementor framework + global/kit CSS — and cross-origin assets load
		// as ordered CDN dependencies instead.
		$asset_nodes = iterator_to_array( $xpath->query( '//style | //link[@rel] | //script' ) );
		foreach ( $asset_nodes as $node ) {
			if ( ! ( $node instanceof \DOMElement ) ) {
				continue;
			}
			$tag = strtolower( $node->nodeName );

			if ( 'style' === $tag ) {
				$text = trim( $node->textContent );
				if ( '' !== $text && $css_total < $css_budget ) {
					$resolved     = $this->inline_css_imports( $text, $base_url, $add_cdn, 0 );
					$css_chunks[] = $resolved;
					$css_total   += strlen( $resolved );
				}
			} elseif ( 'link' === $tag ) {
				if ( false === strpos( strtolower( (string) $node->getAttribute( 'rel' ) ), 'stylesheet' ) ) {
					continue;
				}
				$abs = $this->absolutize_migration_url( $node->getAttribute( 'href' ), $base_url );
				if ( '' !== $abs ) {
					$page_specific = $this->is_page_specific_asset( $abs, $post->ID ) && $this->is_same_origin_url( $abs ) && $css_total < $css_budget;
					if ( $page_specific ) {
						$content = $this->fetch_same_origin_text( $abs );
						if ( '' !== $content ) {
							$resolved     = $this->inline_css_imports( $content, $abs, $add_cdn, 0 );
							$css_chunks[] = "/* source: " . esc_url_raw( $abs ) . " */\n" . $resolved;
							$css_total   += strlen( $resolved );
						} else {
							$warnings[] = sprintf( 'Could not fetch stylesheet: %s', $abs );
							$add_cdn( 'css', $abs, 'head' );
						}
					} else {
						$add_cdn( 'css', $abs, 'head' );
						// Harvest Elementor global/kit variables from the
						// generated CSS dir even though the file loads via CDN —
						// its `.elementor-kit-NNN` host (the body) is dropped by
						// the unwrap, so we re-expose the variables at :root.
						if ( $this->is_same_origin_url( $abs ) && false !== strpos( strtolower( (string) wp_parse_url( $abs, PHP_URL_PATH ) ), '/elementor/css/' ) ) {
							$kit = $this->fetch_same_origin_text( $abs );
							if ( '' !== $kit ) {
								foreach ( $this->extract_kit_variables( $kit ) as $vk => $vv ) {
									$root_vars[ $vk ] = $vv;
								}
							}
						}
					}
				}
			} elseif ( 'script' === $tag ) {
				$src  = trim( (string) $node->getAttribute( 'src' ) );
				$type = strtolower( trim( (string) $node->getAttribute( 'type' ) ) );
				if ( '' !== $src ) {
					$abs = $this->absolutize_migration_url( $src, $base_url );
					if ( '' !== $abs ) {
						if ( $this->is_page_specific_asset( $abs, $post->ID ) && $this->is_same_origin_url( $abs ) ) {
							$content = $this->fetch_same_origin_text( $abs );
							if ( '' !== $content ) {
								$custom_js .= "\n/* " . wp_basename( (string) wp_parse_url( $abs, PHP_URL_PATH ) ) . " */\n" . $content . "\n";
								$js_text   .= "\n" . $content;
							} else {
								$add_cdn( 'js', $abs, 'body' );
							}
						} else {
							$add_cdn( 'js', $abs, 'body' );
						}
					}
				} elseif ( $this->is_executable_inline_js( $type ) ) {
					$inline = trim( $node->textContent );
					if ( '' !== $inline ) {
						$custom_js .= "\n" . $inline . "\n";
						$js_text   .= "\n" . $inline;
					}
				}
			}

			if ( $node->parentNode ) {
				$node->parentNode->removeChild( $node );
			}
		}

		if ( $body instanceof \DOMElement ) {
			$this->absolutize_migration_dom_assets( $body, $base_url );
		}

		$body_html    = trim( $this->get_inner_html( $body ) );
		$source_css   = trim( implode( "\n\n", array_filter( $css_chunks ) ) );
		// Re-expose harvested Elementor global/kit variables at :root so
		// var(--e-global-…) still resolves after the body wrapper is dropped.
		if ( ! empty( $root_vars ) ) {
			$source_css = $this->build_root_var_block( $root_vars ) . $source_css;
		}
		// Only classes actually styled by the INLINED page CSS may be renamed —
		// a class styled solely by a CDN'd base file must keep its name (we
		// can't rewrite that file's selectors), or the design would break.
		$css_classes  = $this->extract_css_class_set( $source_css );
		$kept_classes = $this->collect_protected_classes( $body_html, $js_text, $css_classes );

		return array(
			'title'        => $post->post_title,
			'bodyHtml'     => $body_html,
			'sourceCss'    => $source_css,
			'customJs'     => trim( $custom_js ),
			'jsText'       => $js_text,
			'cdnResources' => $cdn,
			'keptClasses'  => $kept_classes,
			'cssClasses'   => $css_classes,
			'warnings'     => $warnings,
		);
	}

	/**
	 * Whether an asset URL is the per-page file Elementor generates for THIS
	 * post (`…/post-{id}.css` / `.min.css` / `.js`). That file holds the page's
	 * own design and flows into the builder pipeline; the kit/global files
	 * (`post-{kitId}.css`) carry a different id and stay CDN.
	 *
	 * @param  string $url     Asset URL.
	 * @param  int    $post_id Migrating post ID.
	 * @return bool
	 */
	private function is_page_specific_asset( $url, $post_id ) {
		$base = strtolower( wp_basename( (string) wp_parse_url( $url, PHP_URL_PATH ) ) );
		if ( '' === $base ) {
			return false;
		}
		return (bool) preg_match( '/^post-' . (int) $post_id . '\.(min\.)?(css|js)$/', $base );
	}

	/**
	 * Collect the set of class names used in the SELECTORS of a stylesheet
	 * (brace/comment/string aware, descends into @media/@supports). Used to
	 * restrict renaming to classes whose CSS we control (the inlined CSS).
	 *
	 * @param  string $css CSS source.
	 * @return array  Set of class name => true.
	 */
	private function extract_css_class_set( $css ) {
		$set = array();
		if ( '' === $css ) {
			return $set;
		}
		$len     = strlen( $css );
		$i       = 0;
		$prelude = '';
		while ( $i < $len ) {
			if ( '/' === $css[ $i ] && $i + 1 < $len && '*' === $css[ $i + 1 ] ) {
				$end = strpos( $css, '*/', $i + 2 );
				$i   = ( false === $end ) ? $len : $end + 2;
				continue;
			}
			$ch = $css[ $i ];
			if ( '{' === $ch ) {
				$block   = $this->read_css_block( $css, $i, $len );
				$trimmed = ltrim( $prelude );
				if ( '' !== $trimmed && '@' === $trimmed[0] ) {
					if ( preg_match( '/^@(media|supports|container|layer|document|scope)\b/i', $trimmed ) ) {
						foreach ( $this->extract_css_class_set( $block['inner'] ) as $c => $unused ) {
							$set[ $c ] = true;
						}
					}
				} else {
					$this->collect_selector_class_tokens( $prelude, $set );
				}
				$prelude = '';
				$i       = $block['next'];
				continue;
			}
			if ( '}' === $ch ) {
				$prelude = '';
				$i++;
				continue;
			}
			$prelude .= $ch;
			$i++;
		}
		return $set;
	}

	/**
	 * Collect `.class` tokens from a selector string (skipping attribute
	 * selectors and strings) into $set.
	 *
	 * @param  string $selector Selector text.
	 * @param  array  $set      Accumulator (by reference).
	 * @return void
	 */
	private function collect_selector_class_tokens( $selector, &$set ) {
		$len        = strlen( $selector );
		$i          = 0;
		$in_bracket = 0;
		$quote      = '';
		while ( $i < $len ) {
			$c = $selector[ $i ];
			if ( '' !== $quote ) {
				if ( '\\' === $c && $i + 1 < $len ) {
					$i += 2;
					continue;
				}
				if ( $c === $quote ) {
					$quote = '';
				}
				$i++;
				continue;
			}
			if ( '"' === $c || "'" === $c ) {
				$quote = $c;
				$i++;
				continue;
			}
			if ( '[' === $c ) {
				$in_bracket++;
				$i++;
				continue;
			}
			if ( ']' === $c ) {
				if ( $in_bracket > 0 ) {
					$in_bracket--;
				}
				$i++;
				continue;
			}
			if ( '.' === $c && 0 === $in_bracket ) {
				$j     = $i + 1;
				$ident = '';
				while ( $j < $len ) {
					$cj = $selector[ $j ];
					if ( '\\' === $cj && $j + 1 < $len ) {
						$ident .= $selector[ $j + 1 ];
						$j     += 2;
						continue;
					}
					if ( preg_match( '/[A-Za-z0-9_-]/', $cj ) || ord( $cj ) >= 0x80 ) {
						$ident .= $cj;
						$j++;
						continue;
					}
					break;
				}
				if ( '' !== $ident ) {
					$set[ $ident ] = true;
				}
				$i = $j;
				continue;
			}
			$i++;
		}
	}

	/**
	 * Resolve @import rules in captured CSS: inline same-origin targets
	 * recursively, relocate cross-origin ones to CDN css, and strip the
	 * @import statements (the meta sanitizer would strip them anyway).
	 *
	 * @param  string   $css      CSS source.
	 * @param  string   $base_url Base URL the CSS was loaded from.
	 * @param  callable $add_cdn  Callback (type,url,position) to register a CDN resource.
	 * @param  int      $depth    Current recursion depth.
	 * @return string
	 */
	private function inline_css_imports( $css, $base_url, $add_cdn, $depth ) {
		if ( $depth > 3 || false === stripos( $css, '@import' ) ) {
			return $css;
		}

		return (string) preg_replace_callback(
			'/@import\s+(?:url\(\s*)?[\'"]?([^\'")\s;]+)[\'"]?\s*\)?\s*([^;]*);/i',
			function ( $m ) use ( $base_url, $add_cdn, $depth ) {
				$abs = $this->absolutize_migration_url( $m[1], $base_url );
				if ( '' === $abs ) {
					return '';
				}
				// Inline only same-origin, non-library targets; framework CSS
				// (/wp-includes/, /wp-content/plugins/) and cross-origin stay CDN.
				if ( $this->is_same_origin_url( $abs ) && ! $this->is_library_asset_url( $abs ) ) {
					$content = $this->fetch_same_origin_text( $abs );
					if ( '' !== $content ) {
						return $this->inline_css_imports( $content, $abs, $add_cdn, $depth + 1 );
					}
				}
				// Library / cross-origin / unfetchable — keep loading as CDN CSS.
				call_user_func( $add_cdn, 'css', $abs, 'head' );
				return '';
			},
			$css
		);
	}

	/**
	 * Fetch a same-origin text asset (CSS/JS). Returns '' on any failure or
	 * oversize body. No SSRF guard needed — same-origin is our own site.
	 *
	 * @param  string $url Absolute same-origin URL.
	 * @return string
	 */
	private function fetch_same_origin_text( $url ) {
		$cookies = array();
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		foreach ( $_COOKIE as $name => $value ) {
			if ( is_scalar( $value ) ) {
				$cookies[] = new \WP_Http_Cookie( array( 'name' => (string) $name, 'value' => (string) wp_unslash( $value ) ) );
			}
		}

		$response = wp_remote_get(
			$url,
			array(
				'timeout'     => 15,
				'redirection' => 2,
				'sslverify'   => false,
				'cookies'     => $cookies,
			)
		);

		if ( is_wp_error( $response ) ) {
			return '';
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = (string) wp_remote_retrieve_body( $response );
		if ( $code < 200 || $code >= 300 || strlen( $body ) > 1500000 ) {
			return '';
		}
		return $body;
	}

	/**
	 * Whether a URL points at the same host as this site.
	 *
	 * @param  string $url Absolute URL.
	 * @return bool
	 */
	private function is_same_origin_url( $url ) {
		$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
		$site = strtolower( (string) wp_parse_url( site_url(), PHP_URL_HOST ) );
		$home = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
		return '' !== $host && ( $host === $site || $host === $home );
	}

	/**
	 * Whether a URL points at a WordPress core (/wp-includes/) or plugin
	 * (/wp-content/plugins/) asset. These are framework/library files — for
	 * both CSS and JS we keep them as CDN <link>/<script> dependencies rather
	 * than inlining them into the editor's CSS/JS zones.
	 *
	 * @param  string $url Asset URL.
	 * @return bool
	 */
	private function is_library_asset_url( $url ) {
		$path = strtolower( (string) wp_parse_url( $url, PHP_URL_PATH ) );
		if ( '' === $path ) {
			return false;
		}
		return ( false !== strpos( $path, '/wp-includes/' ) ) || ( false !== strpos( $path, '/wp-content/plugins/' ) );
	}

	/**
	 * Whether a script URL is a framework/library we keep as a dependency
	 * rather than inlining (WP core, plugins, jQuery, Swiper, Elementor, etc.).
	 *
	 * @param  string $url Script URL.
	 * @return bool
	 */
	private function is_js_library_url( $url ) {
		if ( $this->is_library_asset_url( $url ) ) {
			return true;
		}
		$path = strtolower( (string) wp_parse_url( $url, PHP_URL_PATH ) );
		if ( '' === $path ) {
			return false;
		}
		return (bool) preg_match( '#(jquery|swiper|gsap|aos|slick|owl\.carousel|bootstrap|popper|fontawesome|font-awesome|lottie|isotope|masonry|wow|splide|tiny-slider|flickity|lazysizes|imagesloaded|waypoints|elementor[\w-]*frontend|frontend\.min|webpack)#i', $path );
	}

	/**
	 * Whether an inline <script> type is executable JavaScript we should lift
	 * into the JS tab. JSON-LD, importmaps, templates etc. are skipped.
	 *
	 * @param  string $type Lowercased type attribute.
	 * @return bool
	 */
	private function is_executable_inline_js( $type ) {
		return in_array( $type, array( '', 'text/javascript', 'application/javascript', 'module' ), true );
	}

	/* ----- Section planning ------------------------------------------------ */

	/**
	 * Resolve the list of "section" DOM nodes for a captured body.
	 *
	 * Detection order (most → least reliable):
	 *   1. The Elementor content container `[data-elementor-type="wp-page"]`
	 *      (the page's own builder root, NOT header/footer templates) — every
	 *      section is a DIRECT child of it, whether a classic `<section
	 *      class="elementor-section">` or a flexbox `<div class="e-con">`. The
	 *      attribute is never renamed, so this anchor is rename-proof. Falls
	 *      back to the first `[data-elementor-type]` container if no wp-page.
	 *   2. Explicit `<section>` tags (outermost only) — for themes / non-
	 *      Elementor pages that mark sections semantically.
	 *   3. Single-child wrapper descent — generic fallback.
	 *
	 * Deterministic for a given body (used identically at plan + annotate time).
	 *
	 * @param  \DOMElement $body Body (or root) element.
	 * @return \DOMElement[]
	 */
	private function resolve_section_nodes( $body ) {
		if ( ! ( $body instanceof \DOMElement ) ) {
			return array();
		}

		$xpath = new \DOMXPath( $body->ownerDocument );

		// 1) Elementor content container — prefer the wp-page one.
		$el_root = null;
		foreach ( iterator_to_array( $xpath->query( './/*[@data-elementor-type]', $body ) ) as $cand ) {
			if ( 'wp-page' === strtolower( (string) $cand->getAttribute( 'data-elementor-type' ) ) ) {
				$el_root = $cand;
				break;
			}
			if ( null === $el_root ) {
				$el_root = $cand;
			}
		}
		if ( $el_root instanceof \DOMElement ) {
			$children = $this->element_children( $el_root );
			if ( ! empty( $children ) ) {
				return $children;
			}
		}

		// 2) Explicit <section> tags (outermost only).
		$sections = $this->filter_outermost_elements( iterator_to_array( $xpath->query( './/section', $body ) ) );
		if ( ! empty( $sections ) ) {
			return $sections;
		}

		// 3) Fallback: descend single-element wrapper chains, then return kids.
		$node  = $body;
		$depth = 0;
		while ( $depth < 4 ) {
			$children = $this->element_children( $node );
			if ( 1 === count( $children ) && $children[0]->childNodes->length > 0
				&& preg_match( '/(elementor|page|content|wrap|inner|container|site|main|body)/i', (string) $children[0]->getAttribute( 'class' ) . ' ' . $children[0]->nodeName ) ) {
				$node = $children[0];
				$depth++;
				continue;
			}
			return $children;
		}

		return $this->element_children( $node );
	}

	/**
	 * Detect the Elementor page scope classes — the class attribute of the
	 * `[data-elementor-type="wp-page"]` wrapper (e.g. "elementor elementor-178").
	 * The per-page CSS/JS is scoped under `.elementor-{id}`, so reproducing
	 * these classes on a wrapper keeps every scoped rule matching after the
	 * original wrapper is dropped. Falls back to `elementor elementor-{post_id}`.
	 *
	 * @param  \DOMElement $body    Body element.
	 * @param  int         $post_id Migrating post ID.
	 * @return string
	 */
	private function detect_page_scope_class( $body, $post_id ) {
		$fallback = 'elementor elementor-' . (int) $post_id;
		if ( ! ( $body instanceof \DOMElement ) ) {
			return $fallback;
		}
		$xpath = new \DOMXPath( $body->ownerDocument );
		$node  = null;
		foreach ( iterator_to_array( $xpath->query( './/*[@data-elementor-type]', $body ) ) as $cand ) {
			if ( 'wp-page' === strtolower( (string) $cand->getAttribute( 'data-elementor-type' ) ) ) {
				$node = $cand;
				break;
			}
			if ( null === $node ) {
				$node = $cand;
			}
		}
		if ( $node instanceof \DOMElement ) {
			$cls = trim( preg_replace( '/\s+/', ' ', (string) $node->getAttribute( 'class' ) ) );
			if ( '' !== $cls ) {
				return $cls;
			}
			$eid = trim( (string) $node->getAttribute( 'data-elementor-id' ) );
			if ( '' !== $eid && ctype_digit( $eid ) ) {
				return 'elementor elementor-' . $eid;
			}
		}
		return $fallback;
	}

	/**
	 * Direct element (non-text) children of a node.
	 *
	 * @param  \DOMNode $node Parent node.
	 * @return \DOMElement[]
	 */
	private function element_children( $node ) {
		$out = array();
		if ( $node instanceof \DOMNode ) {
			foreach ( $node->childNodes as $child ) {
				if ( $child instanceof \DOMElement ) {
					$out[] = $child;
				}
			}
		}
		return $out;
	}

	/**
	 * Keep only the outermost elements (drop any nested inside another in the
	 * same set) so nested `<section>`s don't double-count.
	 *
	 * @param  \DOMElement[] $elements Candidate elements.
	 * @return \DOMElement[]
	 */
	private function filter_outermost_elements( $elements ) {
		$set = array();
		foreach ( $elements as $el ) {
			$set[ spl_object_id( $el ) ] = true;
		}
		$out = array();
		foreach ( $elements as $el ) {
			$nested = false;
			$p      = $el->parentNode;
			while ( $p instanceof \DOMElement ) {
				if ( isset( $set[ spl_object_id( $p ) ] ) ) {
					$nested = true;
					break;
				}
				$p = $p->parentNode;
			}
			if ( ! $nested ) {
				$out[] = $el;
			}
		}
		return $out;
	}

	/**
	 * Count section nodes for a body HTML fragment (used to size the plan).
	 *
	 * @param  string $body_html Body inner HTML.
	 * @return int
	 */
	private function count_section_nodes( $body_html ) {
		$dom = $this->load_project_html_document( '<!DOCTYPE html><html><body>' . $body_html . '</body></html>' );
		if ( ! ( $dom instanceof \DOMDocument ) ) {
			return 0;
		}
		$body = $dom->getElementsByTagName( 'body' )->item( 0 );
		if ( ! $body ) {
			return 0;
		}
		return count( $this->resolve_section_nodes( $body ) );
	}

	/**
	 * Build a compact structural outline (tags/classes, no bulk text or CSS)
	 * for the section-planning AI call.
	 *
	 * @param  string $body_html Body inner HTML.
	 * @return string
	 */
	private function build_migration_outline( $body_html ) {
		$dom = $this->load_project_html_document( '<!DOCTYPE html><html><body>' . $body_html . '</body></html>' );
		if ( ! ( $dom instanceof \DOMDocument ) ) {
			return '';
		}
		$body = $dom->getElementsByTagName( 'body' )->item( 0 );
		if ( ! $body ) {
			return '';
		}

		$nodes = $this->resolve_section_nodes( $body );
		$lines = array();
		foreach ( $nodes as $i => $node ) {
			$tag     = strtolower( $node->nodeName );
			$classes = trim( (string) $node->getAttribute( 'class' ) );
			$classes = $classes ? preg_replace( '/\s+/', ' ', $classes ) : '';
			$text    = trim( preg_replace( '/\s+/', ' ', (string) $node->textContent ) );
			if ( strlen( $text ) > 80 ) {
				$text = substr( $text, 0, 80 ) . '…';
			}
			$lines[] = sprintf( '%d: <%s class="%s"> text="%s"', $i, $tag, $classes, $text );
		}
		return implode( "\n", $lines );
	}

	/**
	 * Ask the AI to label each top-level block with a semantic section id.
	 * Falls back to deterministic ids on any failure.
	 *
	 * @param  string $outline Outline text.
	 * @param  int    $count   Number of section nodes.
	 * @return array  Map of node index → kebab section id.
	 */
	private function plan_migration_sections( $outline, $count ) {
		$fallback = array();
		for ( $i = 0; $i < $count; $i++ ) {
			$fallback[ $i ] = 'section-' . ( $i + 1 );
		}
		if ( $count <= 0 || '' === trim( $outline ) ) {
			return $fallback;
		}

		$system = 'You label the top-level blocks of a web page with short, semantic, kebab-case section ids '
			. '(e.g. "hero", "features", "pricing", "testimonials", "faq", "cta", "site-header", "site-footer"). '
			. 'Return ONLY a JSON object mapping each block index (as a string) to a unique kebab-case id. '
			. 'Use only [a-z0-9-]. Every index from 0 to ' . ( $count - 1 ) . ' must be present and unique.';
		$user   = "Blocks:\n" . $outline;

		$parsed = $this->ai_json( $system, $user, 1500 );
		if ( ! is_array( $parsed ) ) {
			return $fallback;
		}

		$seen = array();
		$out  = array();
		for ( $i = 0; $i < $count; $i++ ) {
			$id = isset( $parsed[ (string) $i ] ) ? $this->normalize_class_name( $parsed[ (string) $i ] ) : '';
			if ( '' === $id ) {
				$id = 'section-' . ( $i + 1 );
			}
			$base = $id;
			$n    = 2;
			while ( isset( $seen[ $id ] ) ) {
				$id = $base . '-' . $n;
				$n++;
			}
			$seen[ $id ] = true;
			$out[ $i ]   = $id;
		}
		return $out;
	}

	/* ----- Class collection & naming -------------------------------------- */

	/**
	 * Whether a class is a framework structural class that JS/CSS rely on by
	 * exact name and must NOT be renamed (the per-element `elementor-element-*`
	 * hash IS renameable — it is a pure CSS hook).
	 *
	 * @param  string $class Class name.
	 * @return bool
	 */
	private function is_protected_framework_class( $class ) {
		if ( preg_match( '/^elementor-element-[a-z0-9]+$/i', $class ) ) {
			return false;
		}
		return (bool) preg_match( '/^(elementor|e-|swiper|wp-|woocommerce|menu-item|page-|postid-|admin-bar|dialog-|screen-reader)/i', $class );
	}

	/**
	 * Whether a class is eligible to be renamed. It must be:
	 *   - not a framework class (those keep their names),
	 *   - not referenced by any captured JS (would break behavior),
	 *   - present in the INLINED page CSS selectors (so renaming it rewrites a
	 *     rule we control; a class styled only by a CDN'd base file is left
	 *     alone so its file keeps matching).
	 *
	 * @param  string $class       Class name.
	 * @param  string $js_text     Concatenated captured JS.
	 * @param  array  $css_classes Set of classes used in the inlined CSS.
	 * @return bool
	 */
	private function is_rename_eligible_class( $class, $js_text, $css_classes ) {
		if ( strlen( $class ) < 2 ) {
			return false;
		}
		if ( $this->is_protected_framework_class( $class ) ) {
			return false;
		}
		if ( '' !== $js_text && false !== strpos( $js_text, $class ) ) {
			return false;
		}
		return isset( $css_classes[ $class ] );
	}

	/**
	 * Collect classes we will KEEP (not rename) — used to reserve names so a
	 * proposed name can't collide with a kept class.
	 *
	 * @param  string $body_html   Body inner HTML.
	 * @param  string $js_text     Captured JS.
	 * @param  array  $css_classes Set of classes used in the inlined CSS.
	 * @return string[]
	 */
	private function collect_protected_classes( $body_html, $js_text, $css_classes ) {
		$kept = array();
		if ( preg_match_all( '/class\s*=\s*"([^"]*)"/i', $body_html, $m ) ) {
			foreach ( $m[1] as $attr ) {
				foreach ( preg_split( '/\s+/', trim( $attr ) ) as $cls ) {
					if ( '' !== $cls && ! $this->is_rename_eligible_class( $cls, $js_text, $css_classes ) ) {
						$kept[ $cls ] = true;
					}
				}
			}
		}
		return array_keys( $kept );
	}

	/**
	 * Collect distinct rename-eligible classes with a naming hint (the first
	 * element they appear on: tag + short text). Capped for cost.
	 *
	 * @param  string $body_html   Body inner HTML.
	 * @param  string $js_text     Captured JS.
	 * @param  array  $css_classes Set of classes used in the inlined CSS.
	 * @return array[] Each: array{ class:string, tag:string, hint:string }.
	 */
	private function collect_rename_eligible_classes( $body_html, $js_text, $css_classes ) {
		$dom = $this->load_project_html_document( '<!DOCTYPE html><html><body>' . $body_html . '</body></html>' );
		if ( ! ( $dom instanceof \DOMDocument ) ) {
			return array();
		}
		$xpath = new \DOMXPath( $dom );
		$seen  = array();
		$out   = array();

		foreach ( iterator_to_array( $xpath->query( '//*[@class]' ) ) as $el ) {
			$tag = strtolower( $el->nodeName );
			foreach ( preg_split( '/\s+/', trim( (string) $el->getAttribute( 'class' ) ) ) as $cls ) {
				if ( '' === $cls || isset( $seen[ $cls ] ) || ! $this->is_rename_eligible_class( $cls, $js_text, $css_classes ) ) {
					continue;
				}
				$seen[ $cls ] = true;
				$text         = trim( preg_replace( '/\s+/', ' ', (string) $el->textContent ) );
				if ( strlen( $text ) > 50 ) {
					$text = substr( $text, 0, 50 ) . '…';
				}
				$siblings = trim( (string) $el->getAttribute( 'class' ) );
				$out[]    = array(
					'class' => $cls,
					'tag'   => $tag,
					'hint'  => trim( $text . ' [' . preg_replace( '/\s+/', ' ', $siblings ) . ']' ),
				);
				if ( count( $out ) >= self::CLEAN_MIGRATE_MAX_CLASSES ) {
					return $out;
				}
			}
		}
		return $out;
	}

	/**
	 * Ask the AI to rename a batch of machine classes to semantic names.
	 *
	 * @param  array[] $batch Class entries (class/tag/hint).
	 * @return array Map of original class → proposed name (raw, unvalidated).
	 */
	private function name_source_classes( $batch ) {
		if ( empty( $batch ) ) {
			return array();
		}

		$items = array();
		foreach ( $batch as $entry ) {
			$items[] = array(
				'class' => $entry['class'],
				'tag'   => $entry['tag'],
				'hint'  => $entry['hint'],
			);
		}

		$system = 'You rename machine-generated CSS class names to short, human-readable, semantic kebab-case '
			. 'names for a website code cleanup. You are given a JSON array of classes, each with the HTML tag it '
			. 'appears on and a context hint (nearby text and sibling classes). Return ONLY a JSON object mapping '
			. 'each ORIGINAL class string to a new name. Rules: new names use only [a-z0-9-]; keep them short '
			. '(1-3 words); base them on the element role/content; if a class is ALREADY clearly human-readable, '
			. 'return it unchanged; never output a name unrelated to the hint. Include every input class as a key.';
		$user   = wp_json_encode( $items );

		$parsed = $this->ai_json( $system, $user, 4000 );
		return is_array( $parsed ) ? $parsed : array();
	}

	/**
	 * Normalize a proposed class name to a safe kebab-case token.
	 *
	 * @param  mixed $name Proposed name.
	 * @return string
	 */
	private function normalize_class_name( $name ) {
		$name = strtolower( trim( (string) $name ) );
		$name = preg_replace( '/[^a-z0-9-]+/', '-', $name );
		$name = trim( preg_replace( '/-+/', '-', $name ), '-' );
		return ( strlen( $name ) >= 2 && ! preg_match( '/^[0-9]/', $name ) ) ? $name : '';
	}

	/**
	 * Ensure a proposed class name is unique against reserved names.
	 *
	 * @param  string $name     Candidate name.
	 * @param  array  $reserved Reserved name set (name => true).
	 * @return string
	 */
	private function reserve_unique_class_name( $name, $reserved ) {
		if ( ! isset( $reserved[ $name ] ) ) {
			return $name;
		}
		$n = 2;
		while ( isset( $reserved[ $name . '-' . $n ] ) ) {
			$n++;
		}
		return $name . '-' . $n;
	}

	/* ----- Deterministic rewrite & persist -------------------------------- */

	/**
	 * Finalize: deterministic HTML/CSS rename + assemble + persist.
	 *
	 * @param  array $job Job state.
	 * @return array|WP_Error array{ targetId:int, editUrl:string }.
	 */
	private function finalize_clean_migration( $job ) {
		$post = get_post( (int) $job['postId'] );
		if ( ! $post ) {
			return new \WP_Error( 'not_found', __( 'Page not found.', 'antimanual-builder' ), array( 'status' => 404 ) );
		}

		$map = is_array( $job['nameMap'] ) ? $job['nameMap'] : array();

		// HTML: rename classes + annotate sections.
		$dom = $this->load_project_html_document( '<!DOCTYPE html><html><body>' . $job['bodyHtml'] . '</body></html>' );
		if ( ! ( $dom instanceof \DOMDocument ) ) {
			return new \WP_Error( 'migrate_parse_failed', __( 'The page could not be rebuilt.', 'antimanual-builder' ), array( 'status' => 500 ) );
		}
		$body = $dom->getElementsByTagName( 'body' )->item( 0 );
		if ( ! ( $body instanceof \DOMElement ) ) {
			return new \WP_Error( 'migrate_parse_failed', __( 'The page could not be rebuilt.', 'antimanual-builder' ), array( 'status' => 500 ) );
		}
		// Resolve section nodes BEFORE renaming classes so the plan indices
		// (computed on the original classes at start) still line up — the
		// descent heuristic reads class names. Element references survive the
		// class rewrite (it only mutates attributes, never the tree).
		$section_nodes = $this->resolve_section_nodes( $body );
		// Elementor scopes the per-page CSS/JS with the page wrapper class
		// (`.elementor-{id}` on the data-elementor-type="wp-page" div) — every
		// rule is `.elementor-{id} .x`. Capture that wrapper's classes BEFORE
		// renaming so we can reproduce them on a single scope wrapper; without
		// it the dropped wrapper would orphan every scoped rule (broken design).
		$scope_class   = $this->detect_page_scope_class( $body, (int) $post->ID );
		$this->rewrite_html_classes( $dom, $body, $map );

		// Output ONLY the sections at the top level — the surrounding
		// scaffolding (main.site-main / .page-content / wp-page div) is dropped
		// with NO replacement wrapper. The page scope class is instead applied
		// to <body> (via importedBodyAttributes below), which both the canvas
		// and the front-end already honor — so `.elementor-{id} .x` rules still
		// match without adding any container to the content. Falls back to the
		// full body inner when no sections were resolved.
		$sections_html = $this->build_migrated_sections_html( $dom, $section_nodes, is_array( $job['sections'] ) ? $job['sections'] : array() );
		$rendered_html = ( '' === trim( $sections_html ) )
			? trim( $this->get_inner_html( $body ) )
			: $sections_html;
		// Defensive: no inline <style>/<script> should remain (capture removed
		// them), but the render pipeline strips them anyway.
		$rendered_html = preg_replace( '#<style\b[^>]*>[\s\S]*?</style>#i', '', $rendered_html );
		$rendered_html = preg_replace( '#<script\b[^>]*>[\s\S]*?</script>#i', '', $rendered_html );

		// CSS: rename selector class tokens, declaration bodies verbatim.
		$renamed_css = $this->rewrite_css_selectors( (string) $job['sourceCss'], $map );
		// The wrappers we just dropped may have carried Elementor's global CSS
		// variables (`.elementor-kit-NNN{--e-global-...}`). Hoist those custom
		// properties to :root so `var(--…)` references still resolve and the
		// colors/fonts stay identical after the unwrap.
		$renamed_css = $this->hoist_kit_variables_to_root( $renamed_css );
		$custom_css  = '';
		if ( '' !== trim( $renamed_css ) ) {
			$custom_css = "/* --- AI Generated Styles --- */\n" . trim( $renamed_css ) . "\n/* --- End AI Generated Styles --- */";
		}

		error_log( sprintf( '[AMB-MIGRATE] finalize: renamedClasses=%d renderedHtml=%d customCss=%d customJs=%d cdn=%d behavior=%s', count( $map ), strlen( $rendered_html ), strlen( $custom_css ), strlen( (string) $job['customJs'] ), count( (array) $job['cdnResources'] ), $job['behavior'] ) ); // phpcs:ignore

		// Target: replace in place, or duplicate first.
		$target_id = (int) $job['postId'];
		if ( 'duplicate' === $job['behavior'] ) {
			$new_id = $this->duplicate_page( $post );
			if ( is_wp_error( $new_id ) ) {
				return $new_id;
			}
			$target_id = (int) $new_id;
		}

		$this->save_builder_meta(
			$target_id,
			array(
				'renderedHtml' => $rendered_html,
				'customCss'    => $custom_css,
				'customJs'     => (string) $job['customJs'],
				'cdnResources' => is_array( $job['cdnResources'] ) ? $job['cdnResources'] : array(),
			)
		);

		// Scope the page CSS/JS by putting Elementor's page wrapper classes on
		// <body> (no extra container in the content). Both the editor canvas
		// and the front-end template apply importedBodyAttributes['class'] to
		// <body>, and it round-trips through edits with the rest of the settings.
		$page_settings = $this->sanitize_builder_page_settings_array(
			array(
				'layoutMode'             => 'canvas',
				'importedBodyAttributes' => array( 'class' => $scope_class ),
			)
		);
		update_post_meta( $target_id, '_ambld_page_settings', wp_slash( wp_json_encode( $page_settings ) ) );

		// Keep post_content in sync for non-builder fallbacks.
		wp_update_post(
			array(
				'ID'           => $target_id,
				'post_content' => wp_slash( $this->sanitize_builder_html_value( $rendered_html ) ),
			)
		);

		return array(
			'targetId' => $target_id,
			'editUrl'  => add_query_arg( 'post_id', $target_id, admin_url( 'admin.php?page=ambld-editor' ) ),
		);
	}

	/**
	 * Rewrite class tokens in every element's class attribute via the map.
	 *
	 * @param  \DOMDocument $dom  Document.
	 * @param  \DOMElement  $root Root to walk.
	 * @param  array        $map  Original class → new class.
	 * @return void
	 */
	private function rewrite_html_classes( $dom, $root, $map ) {
		if ( empty( $map ) || ! ( $root instanceof \DOMElement || $root instanceof \DOMDocument ) ) {
			return;
		}
		$xpath = new \DOMXPath( $dom );
		foreach ( iterator_to_array( $xpath->query( './/*[@class]', $root ) ) as $el ) {
			$tokens = preg_split( '/\s+/', trim( (string) $el->getAttribute( 'class' ) ) );
			$out    = array();
			foreach ( $tokens as $tok ) {
				if ( '' === $tok ) {
					continue;
				}
				$out[] = isset( $map[ $tok ] ) ? $map[ $tok ] : $tok;
			}
			$el->setAttribute( 'class', implode( ' ', $out ) );
		}
	}

	/**
	 * Build the final builder HTML from the resolved section nodes ONLY,
	 * dropping the surrounding scaffolding (main / .page-content /
	 * [data-elementor-type="wp-page"]). Each section node is:
	 *   - forced to a `<section>` tag (even if it was a `<div>`),
	 *   - given `data-amb-section="<id>"` and a matching class `<id>`,
	 *   - wrapped in `<!-- [AMB:id] --> … <!-- [/AMB:id] -->` markers.
	 *
	 * @param  \DOMDocument  $dom      Document (needed to create <section> nodes).
	 * @param  \DOMElement[] $nodes    Pre-resolved section nodes (plan order).
	 * @param  array         $sections Map index → section id.
	 * @return string
	 */
	private function build_migrated_sections_html( $dom, $nodes, $sections ) {
		$parts = array();
		foreach ( $nodes as $i => $node ) {
			if ( ! ( $node instanceof \DOMElement ) ) {
				continue;
			}
			$id = isset( $sections[ $i ] ) ? $sections[ $i ] : ( 'section-' . ( $i + 1 ) );

			// Force a real <section> tag (converts <div> → <section> in place).
			if ( 'section' !== strtolower( $node->nodeName ) ) {
				$node = $this->convert_element_to_section( $dom, $node );
			}
			$node->setAttribute( 'data-amb-section', $id );
			// The section's class mirrors its data-amb-section value.
			$this->ensure_leading_class( $node, $id );

			$html = (string) $dom->saveHTML( $node );
			if ( '' === trim( $html ) ) {
				continue;
			}
			$parts[] = '<!-- [AMB:' . $id . '] -->' . "\n" . $html . "\n" . '<!-- [/AMB:' . $id . '] -->';
		}
		return implode( "\n", $parts );
	}

	/**
	 * Replace an element with a `<section>` carrying the same attributes and
	 * children. Returns the new <section> node.
	 *
	 * @param  \DOMDocument $dom  Document.
	 * @param  \DOMElement  $node Element to convert.
	 * @return \DOMElement
	 */
	private function convert_element_to_section( $dom, $node ) {
		$section = $dom->createElement( 'section' );
		if ( $node->hasAttributes() ) {
			foreach ( iterator_to_array( $node->attributes ) as $attr ) {
				$section->setAttribute( $attr->name, $attr->value );
			}
		}
		while ( $node->firstChild ) {
			$section->appendChild( $node->firstChild );
		}
		if ( $node->parentNode ) {
			$node->parentNode->replaceChild( $section, $node );
		}
		return $section;
	}

	/**
	 * Ensure $class is the first class on an element (added if missing).
	 *
	 * @param  \DOMElement $node  Element.
	 * @param  string      $class Class to lead with.
	 * @return void
	 */
	private function ensure_leading_class( $node, $class ) {
		$existing = preg_split( '/\s+/', trim( (string) $node->getAttribute( 'class' ) ), -1, PREG_SPLIT_NO_EMPTY );
		$existing = array_values( array_diff( $existing, array( $class ) ) );
		array_unshift( $existing, $class );
		$node->setAttribute( 'class', implode( ' ', $existing ) );
	}

	/**
	 * Hoist Elementor "kit" custom-property definitions to :root.
	 *
	 * Elementor defines its global colors/fonts as custom properties on a
	 * `.elementor-kit-NNN` element (usually the body or a wrapper). When we
	 * drop those wrappers, `var(--e-global-…)` references would break — so we
	 * copy every custom property declared on a kit selector into a `:root`
	 * block (prepended; lower priority than any surviving exact rule), keeping
	 * the design identical.
	 *
	 * @param  string $css CSS source.
	 * @return string
	 */
	private function hoist_kit_variables_to_root( $css ) {
		$vars = $this->extract_kit_variables( $css );
		if ( empty( $vars ) ) {
			return $css;
		}
		return $this->build_root_var_block( $vars ) . $css;
	}

	/**
	 * Extract custom-property declarations from Elementor "kit" style rules
	 * (selectors containing `elementor-kit-`). Used both to hoist inlined kit
	 * CSS and to harvest variables from the CDN'd global/kit files (whose host
	 * `.elementor-kit-NNN` body class we drop during the unwrap).
	 *
	 * @param  string $css CSS source.
	 * @return array Map of custom-property name => value.
	 */
	private function extract_kit_variables( $css ) {
		$vars = array();
		if ( '' === $css || false === stripos( $css, 'elementor-kit-' ) ) {
			return $vars;
		}

		$len     = strlen( $css );
		$i       = 0;
		$prelude = '';

		while ( $i < $len ) {
			if ( '/' === $css[ $i ] && $i + 1 < $len && '*' === $css[ $i + 1 ] ) {
				$end = strpos( $css, '*/', $i + 2 );
				$i   = ( false === $end ) ? $len : $end + 2;
				continue;
			}
			$ch = $css[ $i ];
			if ( '{' === $ch ) {
				$block   = $this->read_css_block( $css, $i, $len );
				$trimmed = ltrim( $prelude );
				if ( '' !== $trimmed && '@' !== $trimmed[0] && false !== stripos( $trimmed, 'elementor-kit-' ) ) {
					if ( preg_match_all( '/(--[A-Za-z0-9_-]+)\s*:\s*([^;]+);/', $block['inner'], $m, PREG_SET_ORDER ) ) {
						foreach ( $m as $decl ) {
							$vars[ trim( $decl[1] ) ] = trim( $decl[2] );
						}
					}
				}
				$prelude = '';
				$i       = $block['next'];
				continue;
			}
			if ( '}' === $ch ) {
				$prelude = '';
				$i++;
				continue;
			}
			$prelude .= $ch;
			$i++;
		}

		return $vars;
	}

	/**
	 * Build a `:root { … }` block from a custom-property map.
	 *
	 * @param  array $vars Map of name => value.
	 * @return string
	 */
	private function build_root_var_block( $vars ) {
		if ( empty( $vars ) ) {
			return '';
		}
		$root = ':root{';
		foreach ( $vars as $name => $value ) {
			$root .= $name . ':' . $value . ';';
		}
		return $root . "}\n";
	}

	/**
	 * Rewrite class tokens inside CSS SELECTORS only, carrying declaration
	 * blocks verbatim. String/comment/brace aware so values are never touched.
	 *
	 * @param  string $css CSS source.
	 * @param  array  $map Original class → new class.
	 * @return string
	 */
	private function rewrite_css_selectors( $css, $map ) {
		if ( empty( $map ) || '' === $css ) {
			return $css;
		}

		$len     = strlen( $css );
		$i       = 0;
		$out     = '';
		$prelude = '';

		while ( $i < $len ) {
			// Pass through comments verbatim.
			if ( '/' === $css[ $i ] && $i + 1 < $len && '*' === $css[ $i + 1 ] ) {
				$end      = strpos( $css, '*/', $i + 2 );
				$end      = ( false === $end ) ? $len : $end + 2;
				$prelude .= substr( $css, $i, $end - $i );
				$i        = $end;
				continue;
			}

			$ch = $css[ $i ];

			if ( '{' === $ch ) {
				$block   = $this->read_css_block( $css, $i, $len );
				$inner   = $block['inner'];
				$trimmed = ltrim( $prelude );
				$is_at   = ( '' !== $trimmed && '@' === $trimmed[0] );

				if ( $is_at && preg_match( '/^@(media|supports|container|layer|document|scope)\b/i', $trimmed ) ) {
					$out .= $prelude . '{' . $this->rewrite_css_selectors( $inner, $map ) . '}';
				} elseif ( $is_at && preg_match( '/^@(-webkit-)?(keyframes|font-face|page|counter-style|property|font-feature-values|viewport)\b/i', $trimmed ) ) {
					$out .= $prelude . '{' . $inner . '}';
				} else {
					$out .= $this->rewrite_selector_classes( $prelude, $map ) . '{' . $inner . '}';
				}

				$prelude = '';
				$i       = $block['next'];
				continue;
			}

			if ( '}' === $ch ) {
				$out    .= $prelude . '}';
				$prelude = '';
				$i++;
				continue;
			}

			$prelude .= $ch;
			$i++;
		}

		return $out . $prelude;
	}

	/**
	 * Read a balanced {...} block (string/comment aware) starting at $open.
	 *
	 * @param  string $css  CSS.
	 * @param  int    $open Index of the opening brace.
	 * @param  int    $len  CSS length.
	 * @return array  array{ inner:string, next:int }.
	 */
	private function read_css_block( $css, $open, $len ) {
		$depth = 0;
		for ( $i = $open; $i < $len; $i++ ) {
			$c = $css[ $i ];
			if ( '/' === $c && $i + 1 < $len && '*' === $css[ $i + 1 ] ) {
				$e = strpos( $css, '*/', $i + 2 );
				$i = ( false === $e ) ? $len - 1 : $e + 1;
				continue;
			}
			if ( '"' === $c || "'" === $c ) {
				$i = $this->skip_css_string( $css, $i, $len );
				continue;
			}
			if ( '{' === $c ) {
				$depth++;
			} elseif ( '}' === $c ) {
				$depth--;
				if ( 0 === $depth ) {
					return array(
						'inner' => substr( $css, $open + 1, $i - $open - 1 ),
						'next'  => $i + 1,
					);
				}
			}
		}
		return array(
			'inner' => substr( $css, $open + 1 ),
			'next'  => $len,
		);
	}

	/**
	 * Skip a CSS string starting at the quote at $i; returns the index of the
	 * closing quote.
	 *
	 * @param  string $css CSS.
	 * @param  int    $i   Index of the opening quote.
	 * @param  int    $len CSS length.
	 * @return int
	 */
	private function skip_css_string( $css, $i, $len ) {
		$q = $css[ $i ];
		for ( $i++; $i < $len; $i++ ) {
			if ( '\\' === $css[ $i ] ) {
				$i++;
				continue;
			}
			if ( $css[ $i ] === $q ) {
				return $i;
			}
		}
		return $len - 1;
	}

	/**
	 * Rewrite `.class` tokens in a selector string via the map, skipping class
	 * names inside attribute selectors and strings.
	 *
	 * @param  string $selector Selector text (the prelude before `{`).
	 * @param  array  $map      Original class → new class.
	 * @return string
	 */
	private function rewrite_selector_classes( $selector, $map ) {
		$len        = strlen( $selector );
		$out        = '';
		$i          = 0;
		$in_bracket = 0;
		$quote      = '';

		while ( $i < $len ) {
			$c = $selector[ $i ];

			if ( '' !== $quote ) {
				$out .= $c;
				if ( '\\' === $c && $i + 1 < $len ) {
					$out .= $selector[ $i + 1 ];
					$i   += 2;
					continue;
				}
				if ( $c === $quote ) {
					$quote = '';
				}
				$i++;
				continue;
			}

			if ( '"' === $c || "'" === $c ) {
				$quote = $c;
				$out  .= $c;
				$i++;
				continue;
			}
			if ( '[' === $c ) {
				$in_bracket++;
				$out .= $c;
				$i++;
				continue;
			}
			if ( ']' === $c ) {
				if ( $in_bracket > 0 ) {
					$in_bracket--;
				}
				$out .= $c;
				$i++;
				continue;
			}

			if ( '.' === $c && 0 === $in_bracket ) {
				$j     = $i + 1;
				$ident = '';
				while ( $j < $len ) {
					$cj = $selector[ $j ];
					if ( '\\' === $cj && $j + 1 < $len ) {
						$ident .= $selector[ $j + 1 ];
						$j     += 2;
						continue;
					}
					if ( preg_match( '/[A-Za-z0-9_-]/', $cj ) || ord( $cj ) >= 0x80 ) {
						$ident .= $cj;
						$j++;
						continue;
					}
					break;
				}
				$out .= '.' . ( ( '' !== $ident && isset( $map[ $ident ] ) ) ? $map[ $ident ] : $ident );
				$i    = $j;
				continue;
			}

			$out .= $c;
			$i++;
		}

		return $out;
	}

	/**
	 * Call the AI expecting a JSON object response, parsed robustly.
	 *
	 * @param  string $system System prompt.
	 * @param  string $user   User prompt.
	 * @param  int    $max    Max output tokens.
	 * @return array|null
	 */
	private function ai_json( $system, $user, $max ) {
		// NOTE: we do NOT set json_mode. Several OpenAI-compatible providers
		// (e.g. opencodezen, some xAI/DeepSeek models) reject the
		// `response_format: json_object` parameter with a 400, which would make
		// the whole call fail. The prompt asks for JSON and the parser below
		// extracts the first {...} object from plain text, so this is robust.
		$response = $this->call_ai_api(
			$system,
			$user,
			array(
				'max_tokens'  => (int) $max,
				'temperature' => 0.2,
			)
		);

		if ( is_wp_error( $response ) ) {
			error_log( '[AMB-MIGRATE] ai_json: call failed: ' . $response->get_error_message() ); // phpcs:ignore
			return null;
		}
		if ( empty( $response['content'] ) ) {
			error_log( '[AMB-MIGRATE] ai_json: empty content' ); // phpcs:ignore
			return null;
		}

		$content = trim( (string) $response['content'] );
		// Unwrap ```json fences.
		if ( preg_match( '/```(?:json)?\s*([\s\S]*?)```/i', $content, $m ) ) {
			$content = trim( $m[1] );
		}
		// Isolate the first {...} object.
		$start = strpos( $content, '{' );
		$end   = strrpos( $content, '}' );
		if ( false !== $start && false !== $end && $end > $start ) {
			$content = substr( $content, $start, $end - $start + 1 );
		}

		$parsed = json_decode( $content, true );
		return is_array( $parsed ) ? $parsed : null;
	}

	/**
	 * Save migration behavior preference.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function save_migration_behavior( $request ) {
		$behavior = sanitize_key( $request->get_param( 'behavior' ) );

		if ( ! in_array( $behavior, array( 'replace', 'duplicate' ), true ) ) {
			$behavior = 'duplicate';
		}

		update_option( 'ambld_migration_behavior', $behavior, false );

		return new \WP_REST_Response(
			array( 'message' => __( 'Migration behavior saved successfully.', 'antimanual-builder' ) ),
			200
		);
	}

	/**
	 * Default AI token budget. Used both as the form's initial values
	 * and as the runtime fallback whenever the saved option is missing
	 * a key (so partial saves don't break call sites).
	 *
	 * - max_output_tokens: 0 = "auto", i.e. use the active model's full output
	 *   ceiling (read from the parent catalog at call time). The user can lower
	 *   it to cut cost at the risk of truncation. chat_stream still enforces a
	 *   per-pass minimum so a too-small value can't produce truncated output.
	 * - reasoning_effort: 'medium' = balanced. Resolved per-model at call time
	 *   (clamped to the levels the selected model advertises, or omitted for
	 *   non-reasoning models, and translated to a Gemini thinking budget).
	 *
	 * @since  1.5.0
	 * @return array
	 */
	public static function default_ai_budget() {
		return array(
			'max_output_tokens' => 0,
			'reasoning_effort'  => 'medium',
		);
	}

	/**
	 * Read the saved AI token budget option, merged with defaults.
	 *
	 * @since  1.5.0
	 * @return array Always-complete `{max_output_tokens, reasoning_effort}`.
	 */
	public static function get_ai_budget() {
		$saved    = get_option( 'ambld_ai_budget', array() );
		$defaults = self::default_ai_budget();
		if ( ! is_array( $saved ) ) {
			return $defaults;
		}

		// Legacy-schema migration. The pre-model-aware version stored a
		// `thinking_budget` and an absolute `max_output_tokens` clamped to
		// <= 32,000. That key only ever appears on a stale option, so its
		// presence marks a cap that predates per-model limits — reset the cap
		// to "auto" (0) so the model's real ceiling (e.g. 128k) applies instead
		// of the old 32k clamp, and drop the dead key.
		if ( array_key_exists( 'thinking_budget', $saved ) ) {
			unset( $saved['thinking_budget'] );
			$saved['max_output_tokens'] = 0;
		}

		$merged = array_merge( $defaults, $saved );

		// `0` means "auto" (use the model's full ceiling). A positive value is
		// the user's chosen cap; the call site clamps it down to the active
		// model's real limit. The wide upper bound just guards against absurd
		// saved data — the per-model clamp does the real work.
		$max                         = (int) $merged['max_output_tokens'];
		$merged['max_output_tokens'] = $max <= 0 ? 0 : max( 1000, min( 1000000, $max ) );
		$merged['reasoning_effort']  = AMBLD_Antimanual_Ai_Provider::normalize_reasoning_effort( $merged['reasoning_effort'] );
		unset( $merged['thinking_budget'] );

		return $merged;
	}

	/**
	 * Persist AI token budget preferences.
	 *
	 * Accepts a partial body — missing fields fall back to the saved or
	 * default value, so the user can update one knob without resending
	 * the others.
	 *
	 * @since  1.5.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function save_ai_budget( $request ) {
		$current = self::get_ai_budget();

		$max = $request->get_param( 'maxOutputTokens' );
		if ( null !== $max && '' !== $max ) {
			$max                          = (int) $max;
			$current['max_output_tokens'] = $max <= 0 ? 0 : max( 1000, min( 1000000, $max ) );
		}

		$effort = $request->get_param( 'reasoningEffort' );
		if ( ! empty( $effort ) ) {
			$current['reasoning_effort'] = sanitize_key( $effort );
		}

		update_option( 'ambld_ai_budget', $current, false );

		return new \WP_REST_Response(
			array(
				'message' => __( 'AI token budget saved.', 'antimanual-builder' ),
				'budget'  => $current,
			),
			200
		);
	}

	/**
	 * Re-run the shipped RAG seed corpus indexing.
	 *
	 * `mode=cron` (default) schedules a background job and returns
	 * immediately — the right choice from the admin UI because embedding
	 * the corpus can take ~30s. `mode=sync` runs inline; reserved for
	 * WP-CLI / debugging.
	 *
	 * @since  1.7.0
	 * @param  WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function reseed_rag_corpus( $request ) {
		if ( ! class_exists( 'AMBLD_Rag_Indexer' ) ) {
			return new \WP_Error(
				'rag_unavailable',
				__( 'The RAG indexer is unavailable.', 'antimanual-builder' ),
				array( 'status' => 500 )
			);
		}

		$mode = (string) $request->get_param( 'mode' );

		if ( 'sync' === $mode ) {
			if ( ! AMBLD_Rag_Indexer::can_embed() ) {
				return new \WP_Error(
					'no_embedder',
					AMBLD_Antimanual_Ai_Provider::get_status_message(),
					array( 'status' => 400 )
				);
			}
			$indexer = new AMBLD_Rag_Indexer();
			$result  = $indexer->seed_corpus();
			if ( is_wp_error( $result ) ) {
				return $result;
			}
			delete_option( AMBLD_Rag_Indexer::OPT_SEED_PENDING );
			update_option( AMBLD_Rag_Indexer::OPT_SEED_COMPLETED_AT, current_time( 'mysql', true ), false );
			return new \WP_REST_Response(
				array(
					'message' => __( 'Shipped corpus reindex complete.', 'antimanual-builder' ),
					'mode'    => 'sync',
					'result'  => $result,
				),
				200
			);
		}

		AMBLD_Rag_Indexer::schedule_seed_embed( 5 );
		return new \WP_REST_Response(
			array(
				'message'   => __( 'Shipped corpus reindex scheduled.', 'antimanual-builder' ),
				'mode'      => 'cron',
				'scheduled' => true,
			),
			200
		);
	}

	/**
	 * Status payload for the admin "Knowledge base" panel.
	 *
	 * @since  1.7.0
	 * @return WP_REST_Response
	 */
	public function get_rag_status( $request ) {
		unset( $request );
		$counts = class_exists( 'AMBLD_Rag_Store' ) ? AMBLD_Rag_Store::counts_by_type() : array();
		$total  = 0;
		foreach ( $counts as $n ) {
			$total += (int) $n;
		}

		$pending  = class_exists( 'AMBLD_Rag_Indexer' )
			? (int) get_option( AMBLD_Rag_Indexer::OPT_SEED_PENDING, 0 ) === 1
			: false;
		$next_run = class_exists( 'AMBLD_Rag_Indexer' )
			? wp_next_scheduled( AMBLD_Rag_Indexer::CRON_HOOK )
			: false;

		return new \WP_REST_Response(
			array(
				'embedderAvailable' => class_exists( 'AMBLD_Rag_Indexer' ) && AMBLD_Rag_Indexer::can_embed(),
				'totalChunks'       => $total,
				'countsByType'      => $counts,
				'seedPending'       => $pending,
				'seedRetryCount'    => (int) get_option( 'ambld_seed_retry_count', 0 ),
				'seedCompletedAt'   => (string) get_option( 'ambld_seed_completed_at', '' ),
				'seedLastError'     => (string) get_option( 'ambld_seed_last_error', '' ),
				'nextScheduledAt'   => $next_run ? (int) $next_run : null,
				'schemaVersion'     => (string) get_option( 'ambld_rag_schema_version', '' ),
			),
			200
		);
	}

	/**
	 * Diagnostic probe — runs one retrieval pass for an arbitrary query and
	 * returns the raw top-K hits. Bypasses the chat pipeline entirely so you
	 * can verify the RAG plumbing is wired correctly without firing a full
	 * AI turn.
	 *
	 * Usage from the browser (logged in as admin):
	 *   /wp-json/ambld/v1/preferences/rag-probe?q=hero+section
	 *   /wp-json/ambld/v1/preferences/rag-probe?q=pricing+cards&k=10
	 *   /wp-json/ambld/v1/preferences/rag-probe?q=container+rule&types=seed_convention,doc
	 *
	 * @since  1.8.0
	 * @return WP_REST_Response|WP_Error
	 */
	public function probe_rag( $request ) {
		if ( ! class_exists( 'AMBLD_Rag_Retriever' ) || ! class_exists( 'AMBLD_Rag_Store' ) ) {
			return new \WP_Error(
				'rag_unavailable',
				__( 'RAG subsystem unavailable.', 'antimanual-builder' ),
				array( 'status' => 500 )
			);
		}

		$query   = (string) $request->get_param( 'q' );
		$k       = max( 1, min( 25, (int) $request->get_param( 'k' ) ) );
		$types_p = trim( (string) $request->get_param( 'types' ) );

		// Default to the same 9-type whitelist the harness uses, so a probe
		// matches what a real chat turn would see.
		$default_types = array(
			'template', 'component', 'shortcode', 'doc',
			'seed_archetype', 'seed_pattern', 'seed_convention', 'seed_copy', 'seed_doc',
		);
		$types = '' === $types_p
			? $default_types
			: array_values( array_filter( array_map( 'trim', explode( ',', $types_p ) ) ) );

		$t_start    = microtime( true );
		$hits       = AMBLD_Rag_Retriever::retrieve( $query, $types, $k );
		$elapsed_ms = (int) round( ( microtime( true ) - $t_start ) * 1000 );

		// Trim the chunk content for the response — full content per chunk
		// can be many KB and this is a diagnostic endpoint, not a content
		// pipe. Include a preview so the human can sanity-check what was
		// retrieved.
		$results = array();
		foreach ( $hits as $hit ) {
			$content = isset( $hit['content'] ) ? (string) $hit['content'] : '';
			$results[] = array(
				'source_type'    => (string) $hit['source_type'],
				'source_id'      => (string) $hit['source_id'],
				'source_label'   => (string) $hit['source_label'],
				'score'          => round( (float) $hit['score'], 4 ),
				'cosine'         => isset( $hit['cosine'] ) ? round( (float) $hit['cosine'], 4 ) : null,
				'helpfulness'    => isset( $hit['helpfulness'] ) ? (int) $hit['helpfulness'] : 0,
				'content_chars'  => strlen( $content ),
				'content_preview' => substr( preg_replace( '/\s+/', ' ', $content ), 0, 200 ),
			);
		}

		return new \WP_REST_Response(
			array(
				'query'             => $query,
				'k'                 => $k,
				'types'             => $types,
				'elapsed_ms'        => $elapsed_ms,
				'embedder_available' => class_exists( 'AMBLD_Rag_Indexer' ) && AMBLD_Rag_Indexer::can_embed(),
				'embedding_provider' => class_exists( '\Antimanual\AIProvider' ) && method_exists( '\Antimanual\AIProvider', 'get_embedding_provider_name' )
					? (string) \Antimanual\AIProvider::get_embedding_provider_name()
					: 'unknown',
				'total_in_store'    => AMBLD_Rag_Store::count_chunks(),
				'hit_count'         => count( $results ),
				'results'           => $results,
			),
			200
		);
	}

	/**
	 * Generate a design preset by analyzing a website URL with AI.
	 *
	 * Fetches the target URL's HTML, extracts design signals (meta tags,
	 * inline styles, CSS snippets, DOM structure), and sends a condensed
	 * summary to the configured AI provider to infer design properties.
	 *
	 * @since  1.2.0
	 * @param  WP_REST_Request $request REST request with 'url' param.
	 * @return WP_REST_Response|WP_Error Generated preset or error.
	 */
	public function generate_preset( $request ) {
		$url = $request->get_param( 'url' );

		if ( empty( $url ) ) {
			return new \WP_Error(
				'missing_url',
				__( 'Please provide a valid website URL.', 'antimanual-builder' ),
				array( 'status' => 400 )
			);
		}

		// SSRF guard. An authenticated user with `edit_pages` should NOT be
		// able to make the server fetch internal services, cloud metadata
		// endpoints (AWS 169.254.169.254 / GCP / Azure), or RFC1918 networks.
		// fetch_validated_external_url() validates the URL, PINS the
		// validated IPs for the request (closing the DNS-rebinding TOCTOU
		// window), and re-validates every redirect hop.
		$response = self::fetch_validated_external_url(
			(string) $url,
			array(
				'timeout'    => 15,
				'user-agent' => 'AntimanualBuilder/1.0 (WordPress)',
				// `sslverify => true` is mandatory on arbitrary outbound
				// fetches — disabling TLS verification on a user-supplied
				// URL is itself a vulnerability (MITM + impersonation).
				'sslverify'  => true,
			)
		);

		if ( is_wp_error( $response ) ) {
			return new \WP_Error(
				'fetch_failed',
				__( 'Could not fetch the website. Please check the URL and try again.', 'antimanual-builder' ),
				array( 'status' => 400 )
			);
		}

		$http_code = (int) wp_remote_retrieve_response_code( $response );
		if ( $http_code >= 400 ) {
			return new \WP_Error(
				'fetch_error',
				/* translators: %d: HTTP status code. */
				sprintf( __( 'The website returned HTTP %d. Please check the URL.', 'antimanual-builder' ), $http_code ),
				array( 'status' => 400 )
			);
		}

		$html = wp_remote_retrieve_body( $response );
		if ( empty( trim( $html ) ) ) {
			return new \WP_Error(
				'empty_response',
				__( 'The website returned empty content.', 'antimanual-builder' ),
				array( 'status' => 400 )
			);
		}

		// Extract design-relevant snippets for the AI.
		$design_snippet = $this->extract_design_snippet( $html, $url );

		$system_prompt = "You are a web design analyst. The user will provide HTML/CSS snippets from a website. "
			. "Analyze the visual design and return ONLY a valid JSON object (no markdown, no backticks, no explanation) with these exact keys:\n"
			. "{\n"
			. "  \"name\": \"A short 2-3 word human-readable preset name describing the design vibe\",\n"
			. "  \"tone\": \"Describe the overall tone in 1-3 words (e.g. 'professional', 'bold creative', 'warm casual')\",\n"
			. "  \"designStyle\": \"Describe the visual design style in 2-5 words (e.g. 'clean minimal', 'bold geometric', 'glassmorphism', 'editorial minimal with serif accents')\",\n"
			. "  \"headingFont\": \"Name of the heading/display font family detected or closest match (e.g. 'Inter', 'Playfair Display', 'DM Sans')\",\n"
			. "  \"bodyFont\": \"Name of the body/paragraph font family detected or closest match (e.g. 'Inter', 'Lato', 'Open Sans')\",\n"
			. "  \"brandColors\": { \"primary\": \"#hex\", \"secondary\": \"#hex\" },\n"
			. "  \"borderRadius\": \"Describe the corner style (e.g. 'sharp', 'softly rounded 8px', 'fully pill-shaped', 'mixed with rounded cards and sharp buttons')\",\n"
			. "  \"spacing\": \"Describe the spacing/density feel (e.g. 'compact', 'generous airy whitespace', 'balanced with tight sections and airy hero')\"\n"
			. "}\n\n"
			. "Rules:\n"
			. "- Extract the PRIMARY brand color from buttons, links, or accent elements.\n"
			. "- Extract the SECONDARY color from secondary buttons, borders, or muted elements.\n"
			. "- Identify the actual fonts used on the site — do NOT pick from a preset list.\n"
			. "- Describe the design style naturally — do NOT limit yourself to predefined categories.\n"
			. "- Return ONLY the JSON object, nothing else.";

		$user_prompt = "Analyze this website design (" . $url . "):\n\n" . $design_snippet;

		$ai_response = $this->call_ai_api( $system_prompt, $user_prompt );

		if ( is_wp_error( $ai_response ) ) {
			return $ai_response;
		}

		$content = isset( $ai_response['content'] ) ? trim( $ai_response['content'] ) : '';

		// Strip markdown code fences if the AI wrapped them.
		$content = preg_replace( '/^```(?:json)?\s*/i', '', $content );
		$content = preg_replace( '/\s*```$/', '', $content );

		$preset = json_decode( $content, true );

		if ( ! is_array( $preset ) ) {
			return new \WP_Error(
				'parse_failed',
				__( 'AI returned an invalid response. Please try again.', 'antimanual-builder' ),
				array( 'status' => 500 )
			);
		}

		// Validate and sanitize the preset against allowed values.
		$validated = $this->validate_preset_data( $preset );
		$validated['sourceUrl'] = $url;

		return new \WP_REST_Response( $validated, 200 );
	}

	/**
	 * Extract design-relevant snippets from page HTML for AI analysis.
	 *
	 * Pulls meta tags, inline/linked CSS color declarations, font references,
	 * and a condensed body structure to give the AI enough design context.
	 *
	 * @since  1.2.0
	 * @param  string $html Full page HTML.
	 * @param  string $url  Source URL for resolving relative paths.
	 * @return string Condensed design snippet for AI prompt.
	 */
	private function extract_design_snippet( $html, $url ) {
		$parts = array();

		// 1. Extract <head> meta and style info.
		if ( preg_match( '/<head[^>]*>(.*?)<\/head>/si', $html, $head_match ) ) {
			$head = $head_match[1];

			// Theme color meta.
			if ( preg_match( '/<meta[^>]*name=["\']theme-color["\'][^>]*content=["\']([^"\']*)["\']/i', $head, $tc ) ) {
				$parts[] = 'Theme color: ' . sanitize_text_field( $tc[1] );
			}

			// Title.
			if ( preg_match( '/<title[^>]*>([^<]*)<\/title>/i', $head, $title ) ) {
				$parts[] = 'Page title: ' . sanitize_text_field( $title[1] );
			}

			// Google Fonts references.
			if ( preg_match_all( '/fonts\.googleapis\.com\/css2?\?family=([^"\'\'\s>]+)/i', $head, $fonts ) ) {
				$font_list = array();
				foreach ( $fonts[1] as $font_param ) {
					$decoded = urldecode( $font_param );
					if ( preg_match_all( '/family=([^&:]+)/i', 'family=' . $decoded, $fnames ) ) {
						foreach ( $fnames[1] as $fname ) {
							$font_list[] = sanitize_text_field( str_replace( '+', ' ', $fname ) );
						}
					}
				}
				if ( ! empty( $font_list ) ) {
					$parts[] = 'Google Fonts: ' . implode( ', ', array_unique( $font_list ) );
				}
			}

			// Inline <style> blocks — extract color/font/border-radius declarations.
			if ( preg_match_all( '/<style[^>]*>(.*?)<\/style>/si', $head, $styles ) ) {
				$css_snippet = '';
				foreach ( $styles[1] as $style_block ) {
					// Extract lines with design-relevant properties.
					if ( preg_match_all( '/[^{}]*\{[^}]*((?:color|background|border|font-family|border-radius|--[a-z-]+)[^}]*?)\}/i', $style_block, $css_matches ) ) {
						foreach ( $css_matches[0] as $rule ) {
							$css_snippet .= trim( $rule ) . "\n";
						}
					}
				}
				if ( ! empty( $css_snippet ) ) {
					$parts[] = "Inline CSS (design-relevant rules):\n" . mb_substr( $css_snippet, 0, 3000 );
				}
			}
		}

		// 2. Extract condensed body structure.
		if ( preg_match( '/<body[^>]*>(.*?)<\/body>/si', $html, $body_match ) ) {
			$body = $body_match[1];

			// Remove scripts and SVG to reduce noise.
			$body = preg_replace( '/<script[^>]*>.*?<\/script>/si', '', $body );
			$body = preg_replace( '/<svg[^>]*>.*?<\/svg>/si', '', $body );
			$body = preg_replace( '/<noscript[^>]*>.*?<\/noscript>/si', '', $body );

			// Trim to a reasonable size.
			$body = mb_substr( trim( $body ), 0, 6000 );
			$parts[] = "Body HTML (condensed):\n" . $body;
		}

		$snippet = implode( "\n\n", $parts );

		// Hard cap on total snippet size.
		return mb_substr( $snippet, 0, 10000 );
	}

	/**
	 * Validate and sanitize AI-generated preset data.
	 *
	 * Accepts freeform descriptive values from AI — sanitizes but does
	 * not restrict to predefined allowlists. The chat API consumer
	 * already handles unknown values by passing them through as
	 * natural language descriptions.
	 *
	 * @since  1.2.0
	 * @param  array $preset Raw preset data from AI.
	 * @return array Sanitized preset.
	 */
	private function validate_preset_data( $preset ) {
		$validated = array();

		$validated['name']         = isset( $preset['name'] ) ? sanitize_text_field( $preset['name'] ) : __( 'Custom Preset', 'antimanual-builder' );
		$validated['tone']         = isset( $preset['tone'] ) ? sanitize_text_field( $preset['tone'] ) : 'professional';
		$validated['designStyle']  = isset( $preset['designStyle'] ) ? sanitize_text_field( $preset['designStyle'] ) : '';
		$validated['headingFont']  = isset( $preset['headingFont'] ) ? sanitize_text_field( $preset['headingFont'] ) : '';
		$validated['bodyFont']     = isset( $preset['bodyFont'] ) ? sanitize_text_field( $preset['bodyFont'] ) : '';
		$validated['borderRadius'] = isset( $preset['borderRadius'] ) ? sanitize_text_field( $preset['borderRadius'] ) : '';
		$validated['spacing']      = isset( $preset['spacing'] ) ? sanitize_text_field( $preset['spacing'] ) : '';

		// Preserve fontPreset for backward compatibility with built-in presets.
		if ( isset( $preset['fontPreset'] ) ) {
			$validated['fontPreset'] = sanitize_text_field( $preset['fontPreset'] );
		}

		$validated['brandColors'] = array(
			'primary'   => '',
			'secondary' => '',
		);
		if ( isset( $preset['brandColors'] ) && is_array( $preset['brandColors'] ) ) {
			if ( isset( $preset['brandColors']['primary'] ) ) {
				$validated['brandColors']['primary'] = sanitize_hex_color( $preset['brandColors']['primary'] ) ?: '#0079ff';
			}
			if ( isset( $preset['brandColors']['secondary'] ) ) {
				$validated['brandColors']['secondary'] = sanitize_hex_color( $preset['brandColors']['secondary'] ) ?: '#64748b';
			}
		}

		return $validated;
	}

	/**
	 * Save a custom preset to the database.
	 *
	 * Appends a new preset to the ambld_custom_presets option array.
	 * Enforces a maximum of 8 custom presets (FIFO).
	 *
	 * @since  1.2.0
	 * @param  WP_REST_Request $request REST request with preset data.
	 * @return WP_REST_Response|WP_Error Saved preset with ID.
	 */
	public function save_custom_preset( $request ) {
		$payload = $request->get_json_params();

		if ( ! is_array( $payload ) || empty( $payload['name'] ) ) {
			return new \WP_Error(
				'invalid_preset',
				__( 'Invalid preset data.', 'antimanual-builder' ),
				array( 'status' => 400 )
			);
		}

		$validated = $this->validate_preset_data( $payload );
		$validated['id']        = 'custom_' . time() . '_' . wp_rand( 100, 999 );
		$validated['sourceUrl'] = isset( $payload['sourceUrl'] ) ? esc_url_raw( $payload['sourceUrl'] ) : '';

		$presets = get_option( 'ambld_custom_presets', array() );
		if ( ! is_array( $presets ) ) {
			$presets = array();
		}

		// Enforce max 8 custom presets.
		while ( count( $presets ) >= 8 ) {
			array_shift( $presets );
		}

		$presets[] = $validated;
		update_option( 'ambld_custom_presets', $presets, false );

		return new \WP_REST_Response( $validated, 200 );
	}

	/**
	 * Delete a custom preset by ID.
	 *
	 * @since  1.2.0
	 * @param  WP_REST_Request $request REST request with 'id' param.
	 * @return WP_REST_Response
	 */
	public function delete_custom_preset( $request ) {
		$id = sanitize_key( $request->get_param( 'id' ) );

		$presets = get_option( 'ambld_custom_presets', array() );
		if ( ! is_array( $presets ) ) {
			$presets = array();
		}

		$presets = array_values(
			array_filter(
				$presets,
				function ( $preset ) use ( $id ) {
					return ! isset( $preset['id'] ) || $preset['id'] !== $id;
				}
			)
		);

		update_option( 'ambld_custom_presets', $presets, false );

		return new \WP_REST_Response(
			array( 'message' => __( 'Preset deleted successfully.', 'antimanual-builder' ) ),
			200
		);
	}

	/**
	 * Create a draft duplicate of a page.
	 *
	 * Copies the post's core fields into a new draft post.
	 * The original page remains untouched.
	 *
	 * @since  1.0.0
	 * @param  WP_Post $post The source post to duplicate.
	 * @return int|WP_Error  New post ID on success, WP_Error on failure.
	 */
	private function duplicate_page( $post ) {
		$new_post_data = array(
			'post_title'     => $post->post_title,
			'post_content'   => $post->post_content,
			'post_excerpt'   => $post->post_excerpt,
			'post_type'      => $post->post_type,
			'post_status'    => 'draft',
			'post_author'    => get_current_user_id(),
			'post_parent'    => $post->post_parent,
			// Preserve ordering and discussion settings so the copy behaves
			// like the original wherever menu order / comments matter.
			'menu_order'     => $post->menu_order,
			'comment_status' => $post->comment_status,
			'ping_status'    => $post->ping_status,
		);

		$new_id = wp_insert_post( $new_post_data, true );

		if ( is_wp_error( $new_id ) ) {
			return new \WP_Error(
				'duplicate_failed',
				__( 'Failed to create duplicate page.', 'antimanual-builder' ),
				array( 'status' => 500 )
			);
		}

		// Preserve the selected page template (e.g. full-width / no-sidebar)
		// so the copy renders within the same theme layout as the original.
		$page_template = get_post_meta( $post->ID, '_wp_page_template', true );
		if ( ! empty( $page_template ) && 'default' !== $page_template ) {
			update_post_meta( $new_id, '_wp_page_template', $page_template );
		}

		// Carry over the original's taxonomy terms.
		foreach ( get_object_taxonomies( $post->post_type ) as $taxonomy ) {
			$terms = wp_get_object_terms( $post->ID, $taxonomy, array( 'fields' => 'ids' ) );
			if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
				wp_set_object_terms( $new_id, $terms, $taxonomy );
			}
		}

		// Copy featured image if set.
		$thumbnail_id = get_post_thumbnail_id( $post->ID );
		if ( $thumbnail_id ) {
			set_post_thumbnail( $new_id, $thumbnail_id );
		}

		return $new_id;
	}

	/**
	 * Plan endpoint — returns a structured JSON plan for a user prompt.
	 *
	 * Phase 1 of the plan→execute architecture. The planner reasons about
	 * the request, matches it to a preset, decomposes it into tasks, and
	 * surfaces clarifying questions when intent is ambiguous. The actual
	 * HTML generation happens in Phase 2 (a separate endpoint to be wired
	 * once the frontend plan card lands).
	 *
	 * Security: gated by can_use_ai (edit_pages + API key configured).
	 * All inputs are sanitized; large context fields are truncated before
	 * being sent to the provider to prevent runaway token spend and
	 * limit exposure of page content to the provider.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function plan( $request ) {
		$prompt               = (string) $request->get_param( 'prompt' );
		$current_html         = (string) $request->get_param( 'currentHtml' );
		$current_css          = (string) $request->get_param( 'currentCss' );
		$current_js           = (string) $request->get_param( 'currentJs' );
		$js_errors            = $request->get_param( 'jsErrors' );
		$selected_section     = (string) $request->get_param( 'selectedSection' );
		$page_settings        = $request->get_param( 'pageSettings' );
		$conversation_history = $request->get_param( 'conversationHistory' );
		$use_kb_context       = rest_sanitize_boolean( $request->get_param( 'useKnowledgeBase' ) );
		$attachments          = $request->get_param( 'attachments' );

		if ( ! is_array( $page_settings ) ) {
			$page_settings = array();
		}
		if ( ! is_array( $conversation_history ) ) {
			$conversation_history = array();
		}
		if ( ! is_array( $attachments ) ) {
			$attachments = array();
		}
		if ( ! is_array( $js_errors ) ) {
			$js_errors = array();
		}

		$attachment_context = $this->build_planner_attachment_context( $attachments );
		$image_attachments  = $this->filter_image_attachments( $attachments );

		if ( '' === trim( $prompt ) ) {
			return new \WP_Error(
				'empty_prompt',
				__( 'Please provide a prompt to plan.', 'antimanual-builder' ),
				array( 'status' => 400 )
			);
		}

		// Respect the editor toggle: when the user has not selected the
		// Knowledge Base for this run, the planner must not inject any KB
		// context. Otherwise the approved plan can still be influenced by
		// brand/content facts the user explicitly opted out of, which then
		// bleeds into downstream generation even if Phase 2 is gated.
		$knowledge_base_context = '';
		if ( $use_kb_context && class_exists( 'AMBLD_Antimanual_Knowledge_Base' ) ) {
			$kb_query = trim(
				implode(
					"\n",
					array_filter(
						array(
							$prompt,
							wp_strip_all_tags( (string) $selected_section ),
						)
					)
				)
			);
			if ( '' !== $kb_query ) {
				$knowledge_base_context = AMBLD_Antimanual_Knowledge_Base::build_context_for_query( $kb_query );
			}
		}

		// Auto-enrich short/vague prompts for non-technical users (zero latency, no AI call).
		$prompt = $this->maybe_auto_enrich_prompt( $prompt, $current_html, $attachments );

		$system_prompt = $this->build_planner_system_prompt(
			$page_settings,
			(string) $request->get_param( 'entityType' ),
			(string) $request->get_param( 'templateKind' )
		);
		$user_prompt   = $this->build_planner_user_prompt(
			array(
				'prompt'                 => $prompt,
				'current_html'           => $current_html,
				'current_css'            => $current_css,
				'current_js'             => $current_js,
				'js_errors'              => $js_errors,
				'selected_section'       => $selected_section,
				'page_settings'          => $page_settings,
				'conversation_history'   => $conversation_history,
				'knowledge_base_context' => $knowledge_base_context,
				'attachment_context'     => $attachment_context,
			)
		);

		$start   = microtime( true );
		$options = array(
			'json_mode'   => true,
			'temperature' => 0.4,
			'max_tokens'  => 8000,
		);
		if ( ! empty( $image_attachments ) ) {
			$options['image_attachments'] = $image_attachments;
		}

		// --- Agentic investigation loop ------------------------------------
		// The planner sees a cheap baseline (complete section registry + JS map
		// + captured errors + truncated HTML/CSS). When that is not enough to
		// plan confidently it may reply with {"action":"investigate","requests":
		// [...]} naming read tools (get_section / get_page_code / search_section);
		// we run those against the LIVE page state passed in this request, feed
		// the results back, and ask again. Bounded to 2 investigation rounds so
		// the "Plan" button stays responsive (most requests still plan in one
		// call — the model is told to investigate only when it changes the plan).
		$max_investigations = 2;
		$investigation_log  = '';
		$plan               = null;
		$raw_content        = '';

		for ( $round = 0; ; $round++ ) {
			$force_plan   = ( $round >= $max_investigations );
			$round_prompt = $user_prompt . $investigation_log;
			if ( $force_plan && '' !== $investigation_log ) {
				$round_prompt .= "\n\nYou have gathered enough context. Respond NOW with the final plan object (\"action\":\"plan\", …). Do NOT request more investigation.";
			}

			$response = $this->call_ai_api( $system_prompt, $round_prompt, $options );

			// Single transient retry on the first round only.
			if ( is_wp_error( $response ) ) {
				$this->planner_debug_log( 'call_error', array( 'round' => $round, 'code' => $response->get_error_code(), 'message' => $response->get_error_message() ) );
				if ( 0 !== $round ) {
					return $response;
				}
				$response = $this->call_ai_api( $system_prompt, $round_prompt, $options );
				if ( is_wp_error( $response ) ) {
					return $response;
				}
			}

			$raw_content = isset( $response['content'] ) ? (string) $response['content'] : '';
			$decision    = $this->parse_planner_response( $raw_content );

			// JSON parse failure → one stricter retry, then bail.
			if ( null === $decision ) {
				$this->planner_debug_log( 'json_parse_failed', array( 'round' => $round, 'raw' => $raw_content ) );
				$retry_response = $this->call_ai_api(
					$system_prompt,
					$round_prompt . "\n\nReminder: Respond with ONLY a single JSON object matching the schema. No prose, no code fences.",
					$options
				);
				if ( ! is_wp_error( $retry_response ) ) {
					$raw_content = isset( $retry_response['content'] ) ? (string) $retry_response['content'] : '';
					$decision    = $this->parse_planner_response( $raw_content );
				}
				if ( null === $decision ) {
					$this->planner_debug_log( 'json_parse_failed_final', array( 'round' => $round ) );
					return new \WP_Error(
						'planner_invalid_response',
						__( 'The planner returned an invalid response. Please try again.', 'antimanual-builder' ),
						array( 'status' => 502 )
					);
				}
			}

			// Investigation request — fetch the requested slices and loop. On
			// the forced round we ignore any further investigate request and
			// fall through to treat the response as the plan.
			$action = isset( $decision['action'] ) ? strtolower( (string) $decision['action'] ) : '';
			if ( 'investigate' === $action && ! $force_plan ) {
				$requests = isset( $decision['requests'] ) && is_array( $decision['requests'] ) ? $decision['requests'] : array();
				$results  = $this->execute_planner_reads( $requests, $current_html, $current_css, $current_js );
				$this->planner_debug_log( 'investigate', array(
					'round'    => $round,
					'requests' => count( $requests ),
					'reason'   => isset( $decision['reason'] ) ? (string) $decision['reason'] : '',
				) );
				if ( '' === trim( $results ) ) {
					$results = '(No matching data was found for those requests — plan with the baseline you already have.)';
				}
				$investigation_log .= "\n\nINVESTIGATION RESULTS (round " . ( $round + 1 ) . "):\n" . $results;
				continue;
			}

			$plan = $decision;
			break;
		}

		$plan = $this->validate_plan( $plan );

		// Deterministic per-section fan-out. When the user asks to change
		// "all/every/each section", coverage must NOT depend on the model
		// having seen every section in the (truncated) HTML summary — expand
		// to one task per registry section straight from the page, keeping any
		// global setup tasks (attach a library + init script) up front.
		$section_registry = $this->extract_section_registry( $current_html );
		if ( count( $section_registry ) > 1
			&& in_array( $plan['intent'], array( 'refine', 'edit_section' ), true )
		) {
			$registry_ids = array_values( array_filter( array_map(
				static function ( $s ) {
					return isset( $s['id'] ) ? (string) $s['id'] : '';
				},
				$section_registry
			) ) );

			$is_js_wide  = $this->is_page_wide_js_request( $prompt );
			$is_all       = $this->is_all_sections_request( $prompt );

			// Choose fan-out targets. A page-wide JS/bug request fans out only
			// over sections that actually OWN JS (from the marker map) — fixing
			// a JS-less section is wasted work, and if the JS is one global
			// script (no marked sections) we leave the model's plan alone so its
			// single script task can repair it via get_page_code. A generic
			// "all/every section" request fans out over every section.
			$targets   = array();
			$reason    = '';
			if ( $is_js_wide ) {
				$js_ids  = $this->extract_js_section_ids( $current_js );
				$targets = array_values( array_intersect( $registry_ids, $js_ids ) );
				$reason  = 'js';
			} elseif ( $is_all ) {
				$targets = $registry_ids;
				$reason  = 'all';
			}

			if ( count( $targets ) > 1 ) {
				$default_hint  = ( 'js' === $reason ) ? 'fix_js' : 'patch_html';
				$before_count  = count( $plan['tasks'] );
				$plan['tasks'] = $this->expand_per_section_tasks( $plan['tasks'], $targets, $prompt, $default_hint );
				$plan['estimated_sections'] = count( $targets );
				if ( ! isset( $plan['assumptions'] ) || ! is_array( $plan['assumptions'] ) ) {
					$plan['assumptions'] = array();
				}
				$plan['assumptions'][] = ( 'js' === $reason )
					? sprintf( 'Fixing JavaScript across the %d sections that own JS (one task each).', count( $targets ) )
					: sprintf( 'Applying across all %d sections on the page (one task each).', count( $targets ) );
				$this->planner_debug_log( 'per_section_fanout', array(
					'reason'        => $reason,
					'sections'      => count( $targets ),
					'tasks_before'  => $before_count,
					'tasks_after'   => count( $plan['tasks'] ),
				) );
			}
		}

		$elapsed = round( ( microtime( true ) - $start ) * 1000 );

		$this->planner_debug_log(
			'plan_ready',
			array(
				'intent'      => $plan['intent'],
				'confidence'  => $plan['confidence'],
				'task_count'  => count( $plan['tasks'] ),
				'elapsed_ms'  => $elapsed,
				'preset'      => isset( $plan['preset_match']['id'] ) ? $plan['preset_match']['id'] : null,
			)
		);

		return new \WP_REST_Response(
			array(
				'plan'      => $plan,
				'elapsedMs' => $elapsed,
				'usage'     => isset( $response['usage'] ) ? $response['usage'] : array(),
			),
			200
		);
	}

	/**
	 * Deterministically enrich a short, vague prompt for non-technical users.
	 *
	 * Fires only when the prompt is under 150 characters and no existing page
	 * HTML is present (fresh generation). Uses a static keyword-to-industry
	 * lookup — zero API calls, zero latency. Returns the original prompt
	 * unchanged if no industry is detected.
	 *
	 * @since  1.0.0
	 * @param  string $prompt       Raw user prompt.
	 * @param  string $current_html Existing page HTML (empty for new pages).
	 * @param  array  $attachments  Attachment list.
	 * @return string Enriched prompt, or original if no match.
	 */
	private function maybe_auto_enrich_prompt( $prompt, $current_html, $attachments ) {
		$prompt = trim( (string) $prompt );

		// Only enrich short prompts on pages with no existing content and no file context.
		if ( strlen( $prompt ) >= 150 || ! empty( trim( (string) $current_html ) ) || ! empty( $attachments ) ) {
			return $prompt;
		}

		$industries = array(
			'restaurant|bakery|cafe|coffee|pizza|food|bar|bistro|catering|eatery|diner|brasserie' => array(
				'type'     => 'restaurant/food',
				'sections' => 'hero (storefront ambience photo + tagline), menu/signature-items grid (with prices), about us (story + team faces), hours & location (with map area), reservations/order CTA, testimonials',
				'design'   => 'warm earthy palette, appetite-inducing imagery, readable menu typography',
				'must'     => 'menu section with items and prices, opening hours, address/directions',
			),
			'freelancer|designer|developer|illustrator|photographer|artist|creative|portfolio|animator|videographer' => array(
				'type'     => 'portfolio/freelance',
				'sections' => 'hero (name + role + availability status), selected work grid (4-6 projects), about/skills, services & rates, client testimonials, contact form',
				'design'   => 'clean work-forward layout — let the portfolio pieces speak, minimal chrome',
				'must'     => 'project gallery, contact form, skills/tools list',
			),
			'saas|software|app|tool|platform|startup|product' => array(
				'type'     => 'SaaS/product',
				'sections' => 'hero (headline + subheadline + CTA + product screenshot), social proof bar (logo strip), features grid (3-col), how-it-works (3-step), testimonials, pricing table, FAQ, final CTA',
				'design'   => 'clean technical aesthetic, screenshot-forward, trust signals prominent',
				'must'     => 'pricing section, feature highlights, demo/signup CTA',
			),
			'salon|spa|beauty|hair|nail|wellness|massage|yoga|fitness|gym|pilates|skincare' => array(
				'type'     => 'wellness/beauty',
				'sections' => 'hero (tranquil ambience photo + tagline), services menu with prices, about the team, before/after or portfolio, booking/appointment CTA, testimonials, hours & contact',
				'design'   => 'calming palette and whitespace, luxury feel or energetic depending on venue type',
				'must'     => 'services with prices, booking/appointment CTA, team section',
			),
			'agency|studio|consulting|marketing|branding|pr|firm|bureau' => array(
				'type'     => 'agency/consulting',
				'sections' => 'hero (bold statement + CTA), services overview, case studies/work portfolio, process/methodology, team, client logos, testimonials, contact',
				'design'   => 'confident, opinionated, high-contrast, premium feel',
				'must'     => 'case studies, services list, contact form',
			),
			'event|wedding|conference|festival|concert|party|ceremony|summit|hackathon' => array(
				'type'     => 'event',
				'sections' => 'hero (event name + date + venue + CTA), speaker/performer lineup, schedule/agenda, venue & logistics, tickets/RSVP CTA, sponsors/partners, FAQ',
				'design'   => 'high energy or elegant depending on event type; countdown timer; strong date/time visibility',
				'must'     => 'date/time/venue, RSVP/ticket CTA, schedule or lineup',
			),
			'dog|pet|grooming|veterinary|vet|animal|kennel|petsitting' => array(
				'type'     => 'pet services',
				'sections' => 'hero (happy pets photo + tagline), services (grooming, boarding, walking) with prices, about the team, safety & certifications, testimonials with pet photos, booking/contact',
				'design'   => 'friendly, warm, pet-photo-forward',
				'must'     => 'services with pricing, booking CTA, trust/certifications section',
			),
			'lawyer|attorney|law firm|legal|solicitor|barrister|counsel' => array(
				'type'     => 'legal/law firm',
				'sections' => 'hero (firm name + expertise statement + CTA), practice areas, about attorneys, case results/stats, testimonials, process (how we work), contact/consultation CTA',
				'design'   => 'professional, trust-building, conservative palette (navy/white/gold), strong typographic hierarchy',
				'must'     => 'practice areas, attorney profiles, contact/consultation CTA',
			),
			'real estate|property|realtor|realty|housing|homes for sale|apartments' => array(
				'type'     => 'real estate',
				'sections' => 'hero (featured property photo + search/filter), featured listings grid, about agent/agency, areas served, testimonials, market stats, contact',
				'design'   => 'photography-first, trust-building, clean property-card grid',
				'must'     => 'listings or property grid, agent bio, contact form',
			),
			'church|mosque|temple|synagogue|religious|faith|ministry|parish' => array(
				'type'     => 'religious/community',
				'sections' => 'hero (community photo + welcome message), service times & location, about us/beliefs, upcoming events, sermons/resources, community/gallery, contact & directions',
				'design'   => 'warm, welcoming, community-focused, accessible',
				'must'     => 'service times, location/directions, contact',
			),
		);

		$detected = null;
		foreach ( $industries as $pattern => $context ) {
			if ( preg_match( '/\b(' . $pattern . ')\b/i', $prompt ) ) {
				$detected = $context;
				break;
			}
		}

		if ( null === $detected ) {
			return $prompt; // No industry detected — pass through unchanged.
		}

		$enriched  = $prompt . "\n\n";
		$enriched .= '[AUTO-CONTEXT for planner — industry: ' . $detected['type'] . "]\n";
		$enriched .= 'Typical sections for this type of page: ' . $detected['sections'] . "\n";
		$enriched .= 'Design direction: ' . $detected['design'] . "\n";
		$enriched .= 'Must-have elements: ' . $detected['must'] . "\n";
		$enriched .= "The user's prompt above overrides any of the above when there is a conflict. Use this context as intelligent defaults only.";

		return $enriched;
	}

	/**
	 * Build the planner system prompt.
	 *
	 * The planner's job is to reason HARD about what would make an
	 * exceptional result — it's not constrained by token budget like
	 * the generator. This prompt pushes for specificity, taste, and
	 * thoughtful interactivity over generic "hero + features + CTA".
	 *
	 * @since  1.0.0
	 * @return string
	 */
	private function build_planner_system_prompt( $page_settings = array(), $entity_type = 'page', $template_kind = '' ) {
		$prompt  = "You are an elite product designer and senior front-end architect working inside a visual page builder. Your job is to THINK before anyone writes code. You produce a structured JSON plan that a downstream generator will use to build the page, section by section.\n\n";

		// --- Artifact scope: planning a reusable template, not a page ---
		if ( 'template' === $entity_type ) {
			if ( 'header' === $template_kind ) {
				$prompt .= "## PLANNING A SITEWIDE HEADER TEMPLATE\n";
				$prompt .= "You are planning a reusable HEADER (injected at the top of many pages), NOT a full page. Plan ONLY header concerns: branding/logo, primary navigation (a clean <li><a> list the user can later connect to a WordPress menu), optional CTA, and mobile nav. Do NOT plan hero sections, page bodies, or a footer. Keep the task list short and header-scoped.\n\n";
			} elseif ( 'footer' === $template_kind ) {
				$prompt .= "## PLANNING A SITEWIDE FOOTER TEMPLATE\n";
				$prompt .= "You are planning a reusable FOOTER (injected at the bottom of many pages), NOT a full page. Plan ONLY footer concerns: link columns (clean <li><a> lists connectable to WordPress menus), contact details, social icons, newsletter, and a copyright bar. Do NOT plan hero sections, page bodies, or a header. Keep the task list short and footer-scoped.\n\n";
			} else {
				$prompt .= "## PLANNING A REUSABLE TEMPLATE\n";
				$prompt .= "You are planning a reusable template fragment, not a standalone page. Avoid page-level chrome unless explicitly asked.\n\n";
			}
		}

		// --- Template attachment constraints (highest priority — surfaced early) ---
		$tpl_state = is_array( $page_settings )
			? \AMBLD_Render_Template_Resolver::resolve_attachment_state( $page_settings )
			: array();
		$header_attached = ! empty( $tpl_state['header']['attached'] );
		$footer_attached = ! empty( $tpl_state['footer']['attached'] );

		if ( $header_attached || $footer_attached ) {
			$prompt .= "## CRITICAL — Header / Footer Templates Already Attached (PLANNER RULES)\n";
			if ( $header_attached ) {
				$prompt .= "- A reusable HEADER template is attached. The page will receive a fully built header / top nav / masthead injected at render time.\n";
			}
			if ( $footer_attached ) {
				$prompt .= "- A reusable FOOTER template is attached. The page will receive a fully built footer / closing band injected at render time.\n";
			}
			$prompt .= "PLANNER MUST NOT emit any task that builds the attached zones:\n";
			if ( $header_attached ) {
				$prompt .= "- DO NOT plan a 'sticky header', 'top navigation', 'site nav', 'logo + menu', 'announcement bar', or any masthead-style task. The header template owns this.\n";
			}
			if ( $footer_attached ) {
				$prompt .= "- DO NOT plan a 'footer', 'site footer', 'copyright strip', 'social-icons row', 'footer link grid', or any closing band task. The footer template owns this.\n";
			}
			$prompt .= "- This rule overrides any user prompt, design preset, or industry convention. If the user explicitly asks for a header/footer while a template is attached, OMIT it from tasks AND list it as an assumption ('header omitted because the page has an attached header template').\n";
			$prompt .= "- Adjust `estimated_sections` accordingly: subtract 1 for an attached header, 1 for an attached footer, from the typical 11–13 industry count. The first task you emit should be the HERO (or whatever first content section is appropriate), NOT a header/nav.\n\n";
		}


		$prompt .= "QUALITY STANDARD — this is non-negotiable:\n";
		$prompt .= "- Your plan must lead to designs that look hand-crafted — NOT templated, NOT generic, NOT 'typical SaaS page'.\n";
		$prompt .= "- Favour editorial layouts, considered typography pairings, intentional asymmetry, expressive color, tactile micro-interactions.\n";
		$prompt .= "- Every section must have a REASON to exist — no filler. If a section isn't earning its place, leave it out.\n";
		$prompt .= "- Interactivity should feel designed, not bolted on: things like hover state progressions, scroll-linked reveals, tabs that animate meaningfully, accordions with taste, marquees, subtle parallax, pointer-reactive gradients. Pick interactions that match the subject matter.\n";
		$prompt .= "- Use placeholder imagery via https://placehold.co/{w}x{h} — plan sizes and ratios deliberately (e.g. 1440x720 editorial hero, 480x600 portrait cards, 96x96 avatars).\n\n";

		$prompt .= "## NON-TECHNICAL USER PROTOCOL (mandatory)\n";
		$prompt .= "Users range from experienced developers to complete beginners who have never coded. Beginners send short, vague prompts like \"make a bakery website\" or \"something like Airbnb for dog walkers\". They do NOT know what they don't know — asking them technical questions feels like the tool is broken.\n\n";
		$prompt .= "RULE — short prompts on empty pages: When the prompt is UNDER 120 characters and currentHtml is empty, set needs_clarification=false and confidence >= 0.75 regardless of ambiguity. Proceed with intelligent assumptions. Document ALL assumptions in the `assumptions` array so the user can spot-correct them from the plan card UI.\n\n";
		$prompt .= "RULE — brand references: When a prompt references a brand or aesthetic style (\"like Airbnb\", \"like Apple\", \"like Stripe\"), translate the brand to concrete design_direction choices:\n";
		$prompt .= "- Airbnb → warm photography, coral accent, trust signals, card-grid, search-bar prominence\n";
		$prompt .= "- Apple → minimal, white-space dominant, large product imagery, restrained sans typography, flat shadows\n";
		$prompt .= "- Stripe → deep navy/purple gradient, technical precision, dense-but-clean grids, Inter feel, dark sections\n";
		$prompt .= "- Linear → dark mode, subtle neon-purple accents, monospace elements, developer-forward\n";
		$prompt .= "- Notion → off-white background, ultra-minimal, clean sans, soft grey hierarchy\n";
		$prompt .= "Map the brand to concrete color_story, vibe, and typography choices in design_direction. Do NOT ask what brand they mean.\n\n";
		$prompt .= "RULE — NEVER ask about technical preferences (fonts by name, specific hex values, grid systems) — make those decisions yourself and document them as assumptions.\n\n";
		$prompt .= "## FEW-SHOT EXAMPLES\n\n";
		$prompt .= "EXAMPLE 1 — Short industry prompt, empty page:\n";
		$prompt .= "User: \"make me a website for my bakery\"\n";
		$prompt .= "→ intent=generate, confidence=0.85, needs_clarification=false\n";
		$prompt .= "→ design_direction.vibe = \"Warm, artisanal, appetite-driven — hand-crafted quality and neighbourhood trust\"\n";
		$prompt .= "→ design_direction.color_story = \"Cream #FAF6F0 background, chocolate brown #4A2C17 headings, golden amber #D4830A accent\"\n";
		$prompt .= "→ tasks: foundation (global), hero (storefront photo + tagline \"Baked fresh daily\"), menu/signature-items grid, about (baker story + face photo), hours & location (embedded map area), testimonials, CTA\n";
		$prompt .= "→ assumptions: [\"Placeholder bakery items used (Sourdough, Croissants, Cinnamon Rolls)\", \"Business name used as text logo\", \"Assumed in-store pickup + delivery model\"]\n\n";
		$prompt .= "EXAMPLE 2 — Brand reference, empty page:\n";
		$prompt .= "User: \"something like Airbnb but for dog walkers\"\n";
		$prompt .= "→ intent=generate, confidence=0.82, needs_clarification=false\n";
		$prompt .= "→ Map Airbnb's warmth + trust + search-first layout to dog-care context\n";
		$prompt .= "→ tasks: foundation, hero (search/filter bar for finding walkers by zip + date), how-it-works (3-step booking), walker profiles grid (avatar, stars, price/hr, breeds accepted), trust/safety (background checks, insurance), testimonials with pet photos, CTA\n\n";
		$prompt .= "YOU THINK IN SECTIONS, NOT PAGES. A page builder constructs ONE page as a vertical stack of sections. Never reason about multi-page site structure, sitemaps, or 'how many pages' — the unit of work is always the section. Every task you emit corresponds to ONE section (or a global/style/script concern), never to a separate page.\n\n";

		$prompt .= "YOUR RESPONSIBILITIES:\n";
		$prompt .= "1. Classify intent: 'generate' (brand-new page), 'refine' (edits to an existing page as a whole), 'edit_section' (scoped to a selected section), or 'answer' (pure question — no build).\n";
		$prompt .= "2. KNOWLEDGE-BASE FIRST: the planner ALWAYS queries the knowledge base. When KNOWLEDGE BASE EXCERPTS are present in the user message, mine them HARD for brand voice, positioning, product facts, audience, proof points, and any stated section/content requirements BEFORE deciding direction. Let the KB anchor tasks, copy, and section choices — do not ignore it, do not paraphrase it as 'the brand', and do not default to generic industry tropes when the KB gives you something specific. Cite KB-derived assumptions in the assumptions array so it's clear what came from KB vs. inference. If KNOWLEDGE BASE EXCERPTS are empty or absent, note that in assumptions and reason from the prompt alone.\n";
		$prompt .= "3. Judge your own confidence. For SHORT prompts (under 120 chars) on EMPTY pages: set needs_clarification=false and confidence >= 0.75, proceed with your best interpretation, and document all assumptions. Only ask clarifying_questions when the user's goal is STRUCTURALLY ambiguous (not just stylistically vague) AND the KB does not resolve it — \"make it better\" is NOT structurally ambiguous, it means refine. When you do ask, produce 1–2 questions max with concrete choice options. NEVER ask about technical preferences (fonts, colors, grid systems). Still return your best provisional plan.\n";
		$prompt .= "4. REASON ABOUT SECTIONS EXPLICITLY before writing tasks. Ask yourself: what sections does THIS specific page need to earn the user's goal? For each section, name its job (what the visitor should think/do after scrolling it), its content density, and why it's ordered where it is.\n";
		$prompt .= "   SECTION-COUNT GUIDANCE: a well-built marketing/landing page ships with roughly 11–13 sections INCLUDING the header (nav) and footer — that is the industry sweet-spot that balances narrative completeness, SEO surface, social-proof density, and scroll momentum. Do NOT default to the minimal 6–7 section skeleton (nav, hero, features, cta, footer) — that is a wireframe, not a page. Reach for the fuller range unless the user EXPLICITLY asked for something short. Typical beats a strong page earns include: sticky header/nav, hero, logo strip / social proof bar, value-prop trio, feature deep-dive #1, feature deep-dive #2 (different layout from #1), how-it-works / process, testimonial or case-study spotlight, metrics / stats, pricing or comparison, FAQ, final CTA / newsletter, and footer. Pick the 11–13 beats that fit THIS specific brief — never the generic list above verbatim. Fewer than 11 is only acceptable when the prompt is a one-pager, splash, or explicitly minimal; more than 13 is only acceptable when the content (e.g. long-form editorial, ecommerce PDP with specs) genuinely demands it.\n";
		$prompt .= "   Set `estimated_sections` honestly to this exact count (it is a SECTION count, never a page count, and header + footer DO count).\n";
		$prompt .= "5. Match to a preset if one fits (saas-landing, marketing-site, portfolio, editorial-article, pricing, app-showcase, agency, ecommerce-pdp, event, docs-home). Provide a score 0–1; leave null if no preset fits cleanly.\n";
		$prompt .= "6. Decide the design_direction — vibe, layout approach, color story (reference hexes), typography pairing (use the system font stack only — never name a remote-hosted font), imagery approach, and 2–4 signature interactive touches.\n";
		$prompt .= "7. Decompose into an ordered task list following this MANDATORY task order:\n";
		$prompt .= "   POSITION 1 — ALWAYS FIRST (required): A Foundation/Global-CSS task with scope 'global' and tool_hint 'foundation'. This task is ALWAYS the first item in the tasks array, no matter what. It defines the HTML skeleton, :root CSS variables (colors, type scale, spacing tokens), global typography, and reset styles that every section will depend on. Do NOT put this task at the end — sections that reference var(--brand-primary) or global utility classes will break if this runs last.\n";
		$prompt .= "   POSITIONS 2–N: ONE section task per visible section in page-scroll order (hero, features, pricing, testimonials, cta, footer, etc.).\n";
		$prompt .= "   LAST (optional): A 'script' scope task if the page needs a shared interactivity module. Script tasks always run after all sections are on the page.\n";
		$prompt .= "   FORBIDDEN ordering: global/style tasks anywhere after a section task. They must precede all section tasks.\n";
		$prompt .= "   Each task must have a sharp, specific `label` (NOT 'hero section' but 'Hero: oversized serif headline with vertical metadata rail and ambient looping video placeholder').\n";
		$prompt .= "   Each task MUST also carry a RICH `rationale` of 3–5 full sentences that covers: (a) the visitor outcome — what should they think, feel, or do after scrolling this section; (b) the content hook — concrete copy direction, not generic; if the KB has a relevant fact, quote or paraphrase it here; (c) the layout signature — how this section's composition is distinct from every other section on the page (avoid look-alikes); (d) the interaction or motion beat — exactly one signature interaction, if any, and why it fits; (e) any KB anchor or assumption being leaned on. A shallow rationale like 'hero section at the top' is a failure. Make every rationale specific enough that a different designer could build the section from the rationale alone.\n";
		$prompt .= "   Each task MUST also carry a `details` array of 4–8 concrete bullets the generator will lift straight into the section: exact headline/subheadline seeds, number of cards/items, font weights (system stack only), hex accents, placeholder image sizes, grid columns, button labels, etc. No vague words like 'stylish' or 'modern' — write the actual string of the headline, the actual column count, the actual placehold.co URL.\n\n";

		$prompt .= "## INVESTIGATE BEFORE PLANNING (use the codebase, don't guess)\n";
		$prompt .= "You are given a CHEAP BASELINE about the current page: the COMPLETE section list, a PAGE JAVASCRIPT MAP, captured runtime errors, and TRUNCATED HTML/CSS. For most requests that baseline is enough — plan directly. But when you genuinely need to SEE the current code to decide scope, task count, or what already exists (e.g. 'fix the layout of every broken section', 'make the pricing match the features', 'remove duplicate styles', 'the FAQ is messed up'), DO NOT guess from the truncated view — gather the real code first by responding with an investigation request INSTEAD of a plan:\n";
		$prompt .= "{\n";
		$prompt .= "  \"action\": \"investigate\",\n";
		$prompt .= "  \"reason\": \"one line — what you need to see and why it changes the plan\",\n";
		$prompt .= "  \"requests\": [ {\"tool\":\"get_section\",\"section_id\":\"pricing\"}, {\"tool\":\"get_page_code\"}, {\"tool\":\"search_section\",\"keyword\":\"faq\"} ]\n";
		$prompt .= "}\n";
		$prompt .= "READ TOOLS (read-only — they never change the page): get_section{section_id} → that section's full HTML + its JS; get_page_code → the ENTIRE page HTML + CSS + JS; search_section{keyword} → find a section by keyword and return its code. The results come back under 'INVESTIGATION RESULTS' and you'll be asked again.\n";
		$prompt .= "LIMITS: you may investigate AT MOST TWICE, so batch EVERY read you need into as few rounds as possible (one `requests` array can hold many). NEVER investigate for a brand-new page (intent 'generate') — there is nothing to read. Investigate ONLY when the answer materially changes your tasks; otherwise plan now.\n\n";

		$prompt .= "CRITICAL: When you are ready to plan, respond with ONLY a single JSON object (set \"action\":\"plan\"). No prose, no commentary, no markdown code fences. JSON schema:\n\n";
		$prompt .= "{\n";
		$prompt .= "  \"action\": \"plan\",\n";
		$prompt .= "  \"intent\": \"generate\" | \"refine\" | \"edit_section\" | \"add_section\" | \"answer\",\n";
		$prompt .= "  \"confidence\": 0.0–1.0,\n";
		$prompt .= "  \"needs_clarification\": boolean,\n";
		$prompt .= "  \"clarifying_questions\": [\"string\", ...],   // empty array if needs_clarification=false\n";
		$prompt .= "  \"preset_match\": { \"id\": \"string\", \"name\": \"string\", \"score\": 0.0–1.0 } | null,\n";
		$prompt .= "  \"design_direction\": {\n";
		$prompt .= "    \"vibe\": \"string — one-line overall feel\",\n";
		$prompt .= "    \"layout\": \"string — layout strategy\",\n";
		$prompt .= "    \"color_story\": \"string — palette with hex values\",\n";
		$prompt .= "    \"typography\": \"string — heading/body font names + weights\",\n";
		$prompt .= "    \"imagery\": \"string — imagery direction + placeholder sizes\",\n";
		$prompt .= "    \"interactivity\": \"string — 2–4 signature interactions\"\n";
		$prompt .= "  },\n";
		$prompt .= "  \"tasks\": [\n";
		$prompt .= "    { \"id\": \"t1\", \"label\": \"specific, evocative task description\", \"scope\": \"section:<name>\" | \"global\" | \"style\" | \"script\" | \"add_section:<name>\", \"rationale\": \"3–5 sentences covering visitor outcome, content hook, layout signature, interaction beat, and KB/assumption anchor\", \"details\": [\"4–8 concrete bullets the generator will implement verbatim — exact copy seeds, column counts, font weights, hex accents, placehold.co URLs, etc.\"], \"required\": true, \"tool_hint\": \"patch_css\" | \"replace_section\" | \"append_section\" | \"delete_section\" | \"search_section\" | \"section_emit\" | \"foundation\" }\n";
		$prompt .= "  ],\n";
		$prompt .= "  \"assumptions\": [\"string\", ...],  // hidden choices you're making\n";
		$prompt .= "  \"estimated_sections\": integer\n";
		$prompt .= "}\n\n";

		$prompt .= "If intent is 'answer', tasks may be an empty array. If needs_clarification=true, the tasks list should still be your best provisional plan — the user can refine it.\n";
		$prompt .= "If intent is 'edit_section', tasks should cover just that section and its style/script ripples. Use tool_hint='replace_section', 'patch_html', or 'patch_css'.\n";
		$prompt .= "If intent is 'refine', tasks should each be scoped to ONE section with tool_hint='patch_css', 'patch_html', or 'replace_section'.\n";
		$prompt .= "If intent is 'add_section', tasks should use tool_hint='append_section' with scope:'add_section:<name>' and full content brief in details.\n";
		$prompt .= "\nTOOL HINTS — pick the smallest:\n";
		$prompt .= "- tool_hint='patch_html' for MARKUP-only tweaks (add/remove classes incl. animation classes, set attributes, change a tag, insert/wrap/remove a small element) — it edits via selector-scoped ops without re-emitting the section, so it never truncates.\n";
		$prompt .= "- tool_hint='patch_css' for style-only; 'patch_js' for behavior-only; 'replace_section' ONLY for a genuine structural redesign.\n";
		$prompt .= "CROSS-CUTTING 'every/all sections' requests (e.g. 'add WOW.js animations to all sections', 'add a divider to each section'):\n";
		$prompt .= "- If a SINGLE shared rule covers it (a CSS rule on [data-amb-section], or one JS listener over all sections), emit ONE global task (scope:'style' or 'script') — do NOT fan out.\n";
		$prompt .= "- If it requires per-element MARKUP in each section (animation classes tuned per element, an inserted element), emit ONE task PER section in scroll order, each scope:'section:<id>' with tool_hint='patch_html'. Add a leading global task for any shared setup (e.g. attach_cdn for the library + an init script). Never put all sections in one task.\n\n";
		$prompt .= "PAGE-WIDE JS / BUG-FIX requests (e.g. 'fix the JS of the entire page', 'fix any console errors', 'make all the interactions work'):\n";
		$prompt .= "- USE THE 'PAGE JAVASCRIPT MAP' and 'CAPTURED RUNTIME JS ERRORS' in the user message to decide scope — do NOT guess. This is the current state of the codebase; plan against it.\n";
		$prompt .= "- Emit ONE task per section that owns JS (listed in the map), scope:'section:<id>', tool_hint='fix_js'. If captured errors implicate specific sections, prioritise and tightly scope those tasks to the named failure.\n";
		$prompt .= "- If the map says unmarked/global script is present, add ONE task scope:'script' (tool_hint='replace_section' is wrong here — the executor will get_page_code and repair the shared script).\n";
		$prompt .= "- A single vague 'fix all JavaScript' task is a FAILURE — it forces the executor to thrash on one section. Decompose by the JS map so each task is independently fixable.\n";
		$prompt .= "- If the map shows NO section JS and NO global script, there is nothing to fix — return intent='answer' and say so.\n";

		return $prompt;
	}

	/**
	 * Build the planner user prompt from the request payload.
	 *
	 * @since  1.0.0
	 * @param  array $payload Sanitized request payload.
	 * @return string
	 */
	private function build_planner_user_prompt( $payload ) {
		$lines = array();

		$lines[] = 'USER REQUEST:';
		$lines[] = $payload['prompt'];

		$page_settings = is_array( $payload['page_settings'] ) ? $payload['page_settings'] : array();

		// In "From Prompt" design mode the planner ignores per-page design
		// hints entirely. Default mode falls through with whatever fields
		// reached this endpoint (legacy globals are not merged here).
		$planner_design_mode = isset( $page_settings['designMode'] ) ? sanitize_key( (string) $page_settings['designMode'] ) : 'default';
		if ( 'prompt' === $planner_design_mode ) {
			$page_settings = array();
		}

		if ( ! empty( $page_settings ) ) {
			$setting_bits = array();

			if ( ! empty( $page_settings['tone'] ) ) {
				$setting_bits[] = 'tone=' . sanitize_text_field( (string) $page_settings['tone'] );
			}
			if ( ! empty( $page_settings['designStyle'] ) ) {
				$setting_bits[] = 'style=' . sanitize_text_field( (string) $page_settings['designStyle'] );
			}
			if ( ! empty( $page_settings['borderRadius'] ) ) {
				$setting_bits[] = 'radius=' . sanitize_text_field( (string) $page_settings['borderRadius'] );
			}
			if ( ! empty( $page_settings['spacing'] ) ) {
				$setting_bits[] = 'spacing=' . sanitize_text_field( (string) $page_settings['spacing'] );
			}
			if ( ! empty( $page_settings['fontPreset'] ) ) {
				$setting_bits[] = 'fontPreset=' . sanitize_text_field( (string) $page_settings['fontPreset'] );
			}
			if ( ! empty( $page_settings['brandColors'] ) && is_array( $page_settings['brandColors'] ) ) {
				$colors = array();
				foreach ( $page_settings['brandColors'] as $role => $value ) {
					if ( is_string( $value ) && '' !== $value ) {
						$colors[] = sanitize_key( $role ) . ':' . sanitize_hex_color( $value );
					}
				}
				if ( ! empty( $colors ) ) {
					$setting_bits[] = 'brand=[' . implode( ', ', $colors ) . ']';
				}
			}

			if ( ! empty( $setting_bits ) ) {
				$lines[] = '';
				$lines[] = 'USER DESIGN PREFERENCES: ' . implode( ' | ', $setting_bits );
			}
		}

		$kb_context = isset( $payload['knowledge_base_context'] ) ? (string) $payload['knowledge_base_context'] : '';
		if ( '' !== trim( $kb_context ) ) {
			$lines[] = '';
			$lines[] = 'KNOWLEDGE BASE EXCERPTS (authoritative brand, content, and product guidance — reason over this when deciding sections, copy direction, and design choices. If it contains information relevant to the user request, prefer it over generic assumptions):';
			$lines[] = $this->truncate_for_planner( $kb_context, 6000 );
		}

		$attachment_context = isset( $payload['attachment_context'] ) ? (string) $payload['attachment_context'] : '';
		if ( '' !== trim( $attachment_context ) ) {
			$lines[] = '';
			$lines[] = 'ATTACHED FILES (provided by the user — treat these as authoritative content/design references. The text-file contents are inlined below; any "### Image:" entries refer to images that have been passed to the vision model in this same message, so describe and apply what you see in them):';
			$lines[] = $this->truncate_for_planner( $attachment_context, 12000 );
		}

		if ( ! empty( $payload['selected_section'] ) ) {
			$lines[] = '';
			$lines[] = 'SELECTED SECTION (the user has this specific section focused — treat intent as edit_section unless the prompt clearly says otherwise):';
			$lines[] = $this->truncate_for_planner( $payload['selected_section'], 3000 );
		}

		if ( ! empty( $payload['current_html'] ) ) {
			$lines[] = '';
			$lines[] = 'CURRENT PAGE HTML (summary — use to judge intent and avoid re-doing existing work):';
			$lines[] = $this->truncate_for_planner( $payload['current_html'], 4000 );

			// Complete, deterministic list of EVERY editable section — not
			// truncated. The HTML summary above can cut off mid-page, so this
			// is the authoritative answer to "which sections exist". A request
			// for "all / every / each section" means every id listed here.
			$section_registry = $this->extract_section_registry( (string) $payload['current_html'] );
			if ( ! empty( $section_registry ) ) {
				$reg_lines = array();
				foreach ( $section_registry as $i => $sec ) {
					$cls         = ! empty( $sec['classes'] ) ? ' .' . $sec['classes'][0] : '';
					$reg_lines[] = sprintf( '%d. %s  (<%s>%s)', $i + 1, $sec['id'], $sec['tag'], $cls );
				}
				$lines[] = '';
				$lines[] = sprintf(
					'SECTIONS ON THIS PAGE — complete list (%d total, document order). Use these EXACT ids; never invent others. "all/every/each section" = every id below:',
					count( $section_registry )
				);
				$lines[] = implode( "\n", $reg_lines );
			}
		}

		if ( ! empty( $payload['current_css'] ) ) {
			$lines[] = '';
			$lines[] = 'CURRENT PAGE CSS (summary):';
			$lines[] = $this->truncate_for_planner( $payload['current_css'], 2000 );
		}

		// PAGE JAVASCRIPT MAP — the planner cannot read the full JS, but it MUST
		// know which sections own JS so a "fix the JS of the whole page" request
		// is decomposed into one task per JS-bearing section (not one vague
		// catch-all). Marker blocks (`// [AMB:id]`) are section-scoped JS; any
		// remainder is global/unmarked script that a single task should own.
		$current_js = isset( $payload['current_js'] ) ? (string) $payload['current_js'] : '';
		if ( '' !== trim( $current_js ) ) {
			$js_ids   = $this->extract_js_section_ids( $current_js );
			$js_chars = strlen( $current_js );
			// Rough "is there unmarked global JS?" check: strip marker regions
			// and see whether non-trivial code remains.
			$unmarked = trim( preg_replace(
				'/\/\/\s*\[AMB:[^\]]+\][\s\S]*?\/\/\s*\[\/AMB:[^\]]+\]/',
				'',
				$current_js
			) );
			$has_global_js = ( strlen( $unmarked ) > 40 );

			$lines[] = '';
			$lines[] = sprintf(
				'PAGE JAVASCRIPT MAP (%d chars total). Sections with their OWN JS (marker-scoped): %s. Unmarked/global script present: %s.',
				$js_chars,
				! empty( $js_ids ) ? implode( ', ', $js_ids ) : '(none — JS is not section-scoped yet)',
				$has_global_js ? 'YES' : 'no'
			);
			$lines[] = 'For a page-wide JS request: emit ONE section task per id listed above (tool_hint=fix_js/patch_js). If "Unmarked/global script present" is YES, add ONE global/script task to repair the shared script (it will use get_page_code). Do NOT collapse the whole page into a single vague "fix all JS" task.';
		}

		// CAPTURED RUNTIME JS ERRORS — the real failures thrown in the live
		// preview. They name the exact sections/lines to target, so the plan can
		// be scoped to what is actually broken rather than guessing.
		$js_errors = isset( $payload['js_errors'] ) && is_array( $payload['js_errors'] ) ? $payload['js_errors'] : array();
		if ( ! empty( $js_errors ) ) {
			$err_lines = array();
			foreach ( array_slice( $js_errors, 0, 10 ) as $err ) {
				if ( ! is_array( $err ) ) {
					continue;
				}
				$msg = isset( $err['message'] ) ? wp_strip_all_tags( (string) $err['message'] ) : '';
				if ( '' === trim( $msg ) ) {
					continue;
				}
				$loc = '';
				if ( ! empty( $err['source'] ) ) {
					$loc = ' [' . wp_strip_all_tags( (string) $err['source'] );
					if ( isset( $err['lineno'] ) ) {
						$loc .= ':' . (int) $err['lineno'];
					}
					$loc .= ']';
				}
				$err_lines[] = '- ' . $this->truncate_for_planner( $msg, 300 ) . $loc;
			}
			if ( ! empty( $err_lines ) ) {
				$lines[] = '';
				$lines[] = 'CAPTURED RUNTIME JS ERRORS (real errors from the live preview — these are the bugs to fix; scope tasks to the sections they implicate):';
				$lines[] = implode( "\n", $err_lines );
			}
		}

		$history = is_array( $payload['conversation_history'] ) ? $payload['conversation_history'] : array();
		if ( ! empty( $history ) ) {
			$recent     = array_slice( $history, -6 );
			$history_lines = array();
			foreach ( $recent as $message ) {
				if ( ! is_array( $message ) ) {
					continue;
				}
				$role    = isset( $message['role'] ) && in_array( $message['role'], array( 'user', 'assistant' ), true ) ? $message['role'] : 'user';
				$content = isset( $message['content'] ) ? (string) $message['content'] : '';
				if ( '' === trim( $content ) ) {
					continue;
				}
				$history_lines[] = strtoupper( $role ) . ': ' . $this->truncate_for_planner( $content, 400 );
			}
			if ( ! empty( $history_lines ) ) {
				$lines[] = '';
				$lines[] = 'RECENT CONVERSATION:';
				$lines[] = implode( "\n", $history_lines );
			}
		}

		return implode( "\n", $lines );
	}

	/**
	 * Deterministically enumerate every `data-amb-section` on the page.
	 * Cheap regex scan (mirrors the tool-edit registry) — returns one entry
	 * per section: { id, tag, classes[] }, in document order.
	 *
	 * @param  string $full_html Page HTML.
	 * @return array
	 */
	private function extract_section_registry( $full_html ) {
		if ( empty( $full_html ) ) {
			return array();
		}

		$out = array();
		if ( preg_match_all(
			'/<([a-z][a-z0-9]*)\b[^>]*\bdata-amb-section\s*=\s*"([^"]+)"[^>]*>/i',
			$full_html,
			$matches,
			PREG_SET_ORDER
		) ) {
			foreach ( $matches as $match ) {
				$open_tag = $match[0];
				$classes  = array();
				if ( preg_match( '/\bclass\s*=\s*"([^"]*)"/i', $open_tag, $class_match ) ) {
					$classes = array_values( array_filter( preg_split( '/\s+/', trim( $class_match[1] ) ) ) );
				}
				$out[] = array(
					'id'      => trim( $match[2] ),
					'tag'     => strtolower( $match[1] ),
					'classes' => $classes,
				);
			}
		}
		return $out;
	}

	/**
	 * Section ids that own a `// [AMB:id]` JS marker block in the page JS.
	 *
	 * This is the planner's view of "which sections have their own JavaScript",
	 * so a page-wide JS fix can be decomposed into one task per JS-bearing
	 * section instead of a single vague catch-all task. Page generation stores
	 * JS unmarked, so an empty result means the JS is one global script (handle
	 * it as a single global task, not a per-section fan-out).
	 *
	 * @param  string $js Page customJs blob.
	 * @return string[] Section ids in first-seen order.
	 */
	private function extract_js_section_ids( $js ) {
		$js = (string) $js;
		if ( '' === trim( $js ) ) {
			return array();
		}
		$ids = array();
		if ( preg_match_all( '/\/\/\s*\[AMB:([^\]]+)\]/', $js, $m ) ) {
			foreach ( $m[1] as $id ) {
				$id = trim( $id );
				// Skip the closing markers (`[/AMB:id]`).
				if ( '' !== $id && '/' !== $id[0] ) {
					$ids[ $id ] = true;
				}
			}
		}
		return array_keys( $ids );
	}

	/**
	 * Run the planner's read-tool requests against the LIVE page state and
	 * return their results as text to feed back into the next planner turn.
	 *
	 * Read-only by design — the planner only inspects; it never mutates. All
	 * data comes from the HTML/CSS/JS passed in THIS request (the editor's
	 * unsaved live state), so no DB access and no staleness.
	 *
	 * @param  array  $requests Decoded `requests` array from the model.
	 * @param  string $html     Live page HTML.
	 * @param  string $css      Live page CSS.
	 * @param  string $js       Live page JS.
	 * @return string Concatenated tool results.
	 */
	private function execute_planner_reads( $requests, $html, $css, $js ) {
		$out   = array();
		$count = 0;
		foreach ( (array) $requests as $req ) {
			if ( ! is_array( $req ) ) {
				continue;
			}
			if ( $count >= 6 ) {
				$out[] = '(Further requests ignored — max 6 reads per round.)';
				break;
			}
			$count++;
			$tool = isset( $req['tool'] ) ? strtolower( trim( (string) $req['tool'] ) ) : '';
			switch ( $tool ) {
				case 'get_page_code':
					$out[] = $this->planner_render_page_code( $html, $css, $js );
					break;
				case 'get_section':
					$sid   = isset( $req['section_id'] ) ? (string) $req['section_id'] : ( isset( $req['id'] ) ? (string) $req['id'] : '' );
					$out[] = $this->planner_render_section( $sid, $html, $js );
					break;
				case 'search_section':
					$kw    = isset( $req['keyword'] ) ? (string) $req['keyword'] : '';
					$out[] = $this->planner_search_section( $kw, $html, $js );
					break;
				default:
					$out[] = 'Unknown read tool "' . $tool . '" (valid: get_page_code, get_section, search_section).';
			}
		}
		return implode( "\n\n", $out );
	}

	/**
	 * Render the full page code for a planner get_page_code read (size-capped
	 * so a large page can't blow the planner's token budget).
	 *
	 * @param  string $html Live HTML.
	 * @param  string $css  Live CSS.
	 * @param  string $js   Live JS.
	 * @return string
	 */
	private function planner_render_page_code( $html, $css, $js ) {
		$parts = array( "FULL PAGE HTML:\n" . $this->truncate_for_planner( (string) $html, 14000 ) );
		if ( '' !== trim( (string) $css ) ) {
			$parts[] = "FULL PAGE CSS:\n" . $this->truncate_for_planner( (string) $css, 8000 );
		}
		if ( '' !== trim( (string) $js ) ) {
			$parts[] = "FULL PAGE JS:\n" . $this->truncate_for_planner( (string) $js, 8000 );
		}
		return implode( "\n\n", $parts );
	}

	/**
	 * Render one section's HTML + its marker-scoped JS for a planner
	 * get_section read.
	 *
	 * @param  string $id   Section id.
	 * @param  string $html Live HTML.
	 * @param  string $js   Live JS.
	 * @return string
	 */
	private function planner_render_section( $id, $html, $js ) {
		$id = trim( (string) $id );
		if ( '' === $id ) {
			return 'get_section: missing section_id.';
		}
		$section_html = $this->slice_section_html( (string) $html, $id );
		if ( null === $section_html ) {
			$ids = array_column( $this->extract_section_registry( (string) $html ), 'id' );
			return 'get_section "' . $id . '": not found. Valid ids: ' . ( empty( $ids ) ? '(none)' : implode( ', ', $ids ) );
		}

		$out = 'SECTION "' . $id . "\" HTML:\n" . $this->truncate_for_planner( $section_html, 6000 );

		$esc = preg_quote( $id, '/' );
		if ( preg_match( '/\/\/\s*\[AMB:' . $esc . '\][\s\S]*?\/\/\s*\[\/AMB:' . $esc . '\]/', (string) $js, $m ) ) {
			$out .= "\n\nSECTION \"" . $id . "\" JS:\n" . $this->truncate_for_planner( $m[0], 4000 );
		} else {
			$out .= "\n\n(Section \"" . $id . '" has no marker-scoped JS; call get_page_code if you need the global script.)';
		}
		return $out;
	}

	/**
	 * Find sections whose id matches a keyword and render the best match's code
	 * for a planner search_section read.
	 *
	 * @param  string $keyword Search keyword.
	 * @param  string $html    Live HTML.
	 * @param  string $js      Live JS.
	 * @return string
	 */
	private function planner_search_section( $keyword, $html, $js ) {
		$keyword = strtolower( trim( (string) $keyword ) );
		if ( '' === $keyword ) {
			return 'search_section: missing keyword.';
		}
		$ids     = array_column( $this->extract_section_registry( (string) $html ), 'id' );
		$matches = array();
		foreach ( $ids as $sid ) {
			if ( false !== strpos( strtolower( (string) $sid ), $keyword ) ) {
				$matches[] = (string) $sid;
			}
		}
		if ( empty( $matches ) ) {
			return 'search_section "' . $keyword . '": no section id matched. Valid ids: ' . ( empty( $ids ) ? '(none)' : implode( ', ', $ids ) );
		}
		$out = 'search_section "' . $keyword . '" matched: ' . implode( ', ', $matches ) . ".\n\n";
		$out .= $this->planner_render_section( $matches[0], $html, $js );
		return $out;
	}

	/**
	 * Extract a single `data-amb-section="id"` element's outerHTML from a page
	 * blob via DOM. Returns null when the section is not found.
	 *
	 * @param  string $html Page HTML.
	 * @param  string $id   Section id.
	 * @return string|null
	 */
	private function slice_section_html( $html, $id ) {
		if ( '' === trim( (string) $html ) || '' === (string) $id ) {
			return null;
		}
		$prev = libxml_use_internal_errors( true );
		$dom  = new \DOMDocument();
		$ok   = $dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_plan_slice">' . $html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev );
		if ( ! $ok ) {
			return null;
		}
		$xpath = new \DOMXPath( $dom );
		$nodes = $xpath->query( sprintf( '//*[@data-amb-section="%s"]', addslashes( $id ) ) );
		if ( ! $nodes || 0 === $nodes->length ) {
			return null;
		}
		$out = $dom->saveHTML( $nodes->item( 0 ) );
		return is_string( $out ) ? trim( $out ) : null;
	}

	/**
	 * Does the prompt ask to apply a change to ALL/EVERY section (vs. one)?
	 * Conservative — matches "all/every/each … section(s)" in either order,
	 * plus a few set phrases. Triggers deterministic per-section fan-out.
	 *
	 * @param  string $prompt User prompt.
	 * @return bool
	 */
	private function is_all_sections_request( $prompt ) {
		$p = strtolower( (string) $prompt );
		if ( preg_match( '/\b(all|every|each|whole|entire)\b[^.?!]{0,30}\bsections?\b/', $p ) ) {
			return true;
		}
		if ( preg_match( '/\bsections?\b[^.?!]{0,30}\b(all|every|each)\b/', $p ) ) {
			return true;
		}
		if ( preg_match( '/\b(per section|section by section|across all sections|to all sections|on all sections)\b/', $p ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Is this a PAGE-WIDE JavaScript / behavior bug-fix request?
	 *
	 * Distinct from is_all_sections_request: it must be (a) about JS / behavior
	 * / console errors AND (b) explicitly page-wide ("entire page", "all the
	 * JS", "every section", "everywhere"). A single-section bug report like
	 * "fix the tab, it's not working" is deliberately NOT matched — only the
	 * page-wide scope triggers the per-section JS fan-out. Drives scoping the
	 * fan-out to JS-bearing sections in plan().
	 *
	 * @param  string $prompt User prompt.
	 * @return bool
	 */
	private function is_page_wide_js_request( $prompt ) {
		$p = strtolower( (string) $prompt );

		// (a) Topic must be JS / behavior / errors.
		$is_js_topic = (bool) preg_match(
			'/\b(js|javascript|scripts?|console|errors?|interactions?|functionalit\w*|behaviou?rs?|handlers?)\b/',
			$p
		) || (bool) preg_match( '/\b(not working|isn.?t working|doesn.?t work|broken)\b/', $p );
		if ( ! $is_js_topic ) {
			return false;
		}

		// (b) Scope must be explicitly page-wide (not a single named element).
		if ( preg_match( '/\b(entire|whole)\s+page\b/', $p ) ) {
			return true;
		}
		if ( preg_match( '/\b(everywhere|page-wide|throughout the page|across the (whole |entire )?page)\b/', $p ) ) {
			return true;
		}
		// "all/every … js/javascript/interactions/console errors" (either order).
		if ( preg_match( '/\b(all|every|each)\b[^.?!]{0,24}\b(js|javascript|scripts?|interactions?|functionalit\w*|console|errors?)\b/', $p ) ) {
			return true;
		}
		if ( preg_match( '/\b(js|javascript|scripts?|interactions?|functionalit\w*|console|errors?)\b[^.?!]{0,24}\b(all|every|each|everywhere)\b/', $p ) ) {
			return true;
		}
		// "every/all section" phrasings reuse the section detector.
		return $this->is_all_sections_request( $p );
	}

	/**
	 * Replace the plan's section tasks with one task PER registry section, so
	 * coverage is guaranteed (not dependent on the model having seen every
	 * section). Global/setup tasks (attach a library, init script, :root
	 * tokens, add_section) are preserved up front. The recipe (details /
	 * rationale / tool_hint) is taken from the model's first section task, so
	 * the model still decides WHAT to do — code only guarantees it runs on
	 * every section. Per-element tailoring happens at execution time when each
	 * section's tool-edit pass sees its own markup.
	 *
	 * @param  array  $tasks             Model-generated tasks.
	 * @param  array  $registry_ids      Section ids in document order.
	 * @param  string $prompt            Original user prompt (recipe fallback).
	 * @param  string $default_tool_hint tool_hint to use when the model emitted
	 *                                   no section task to copy (e.g. 'fix_js'
	 *                                   for a page-wide JS fix). Default
	 *                                   'patch_html'.
	 * @return array
	 */
	private function expand_per_section_tasks( $tasks, $registry_ids, $prompt, $default_tool_hint = 'patch_html' ) {
		if ( empty( $registry_ids ) ) {
			return $tasks;
		}

		$global   = array();
		$template = null;
		foreach ( (array) $tasks as $t ) {
			if ( ! is_array( $t ) ) {
				continue;
			}
			$scope      = isset( $t['scope'] ) ? (string) $t['scope'] : '';
			$is_section = ( 0 === strpos( $scope, 'section:' ) || 0 === strpos( $scope, 'edit_section:' ) );
			if ( $is_section ) {
				if ( null === $template ) {
					$template = $t;
				}
			} else {
				$global[] = $t;
			}
		}

		$details   = ( $template && ! empty( $template['details'] ) && is_array( $template['details'] ) ) ? $template['details'] : array();
		$rationale = ( $template && ! empty( $template['rationale'] ) ) ? (string) $template['rationale'] : 'Apply the requested change to this section, tailored to its own content.';
		$tool_hint = ( $template && ! empty( $template['tool_hint'] ) ) ? (string) $template['tool_hint'] : $default_tool_hint;
		if ( empty( $details ) ) {
			$details = array( 'Apply the user request to this section: "' . wp_strip_all_tags( (string) $prompt ) . '"' );
		}

		$per_section = array();
		foreach ( $registry_ids as $sid ) {
			$per_section[] = array(
				'id'        => 'sec-' . $sid,
				'label'     => 'Apply to section: ' . $sid,
				'scope'     => 'section:' . $sid,
				'rationale' => $rationale,
				'details'   => $details,
				'required'  => true,
				'tool_hint' => $tool_hint,
			);
		}

		return array_merge( $global, $per_section );
	}

	/**
	 * Parse the planner AI response into a plan array.
	 *
	 * Handles providers that still wrap JSON in markdown fences or
	 * prepend/append prose despite json_mode. Returns null if no
	 * valid JSON object can be extracted.
	 *
	 * @since  1.0.0
	 * @param  string $raw Raw content from the provider.
	 * @return array|null
	 */
	private function parse_planner_response( $raw ) {
		$raw = trim( (string) $raw );
		if ( '' === $raw ) {
			return null;
		}

		// Strip ```json / ``` fences if present.
		if ( preg_match( '/```(?:json)?\s*(\{[\s\S]*?\})\s*```/i', $raw, $match ) ) {
			$raw = $match[1];
		}

		// If the model prepended prose, start from the first '{'.
		$first = strpos( $raw, '{' );
		if ( false !== $first ) {
			$raw = substr( $raw, $first );
		}

		$decoded = json_decode( $raw, true );
		if ( is_array( $decoded ) ) {
			return $decoded;
		}

		// Response was likely truncated mid-stream (hit max_tokens).
		// Attempt progressive repair: walk the string character-by-character,
		// tracking depth; whenever depth drops to zero we have a candidate
		// self-contained object. Also try closing unfinished strings/objects.
		$repaired = $this->attempt_json_repair( $raw );
		if ( null !== $repaired ) {
			$decoded = json_decode( $repaired, true );
			if ( is_array( $decoded ) ) {
				return $decoded;
			}
		}

		return null;
	}

	/**
	 * Best-effort repair of a JSON string that was cut off mid-stream.
	 *
	 * Walks the string tracking brace/bracket depth and string state.
	 * If we reach end-of-input still inside a string, we close it; then we
	 * close any outstanding arrays/objects. Also strips a trailing comma that
	 * would otherwise make the closing brace invalid.
	 *
	 * @param  string $raw Raw (possibly truncated) JSON.
	 * @return string|null Repaired JSON, or null if unrecoverable.
	 */
	private function attempt_json_repair( $raw ) {
		$len        = strlen( $raw );
		$stack      = array(); // Track '{' and '[' opens.
		$in_string  = false;
		$escape     = false;
		$last_good  = -1;

		for ( $i = 0; $i < $len; $i++ ) {
			$ch = $raw[ $i ];

			if ( $in_string ) {
				if ( $escape ) {
					$escape = false;
				} elseif ( '\\' === $ch ) {
					$escape = true;
				} elseif ( '"' === $ch ) {
					$in_string = false;
				}
				continue;
			}

			if ( '"' === $ch ) {
				$in_string = true;
			} elseif ( '{' === $ch || '[' === $ch ) {
				$stack[] = $ch;
			} elseif ( '}' === $ch || ']' === $ch ) {
				array_pop( $stack );
				if ( empty( $stack ) ) {
					$last_good = $i; // Balanced object ends here.
				}
			}
		}

		// If a balanced object was already seen, truncate to it.
		if ( $last_good >= 0 ) {
			return substr( $raw, 0, $last_good + 1 );
		}

		// Otherwise attempt to close what's open.
		$repaired = $raw;
		if ( $in_string ) {
			$repaired .= '"';
		}
		// Strip trailing comma/whitespace before closing braces.
		$repaired = preg_replace( '/,\s*$/', '', $repaired );
		while ( ! empty( $stack ) ) {
			$open     = array_pop( $stack );
			$repaired .= ( '{' === $open ) ? '}' : ']';
		}

		return $repaired;
	}

	/**
	 * Validate and normalize a plan — fill missing fields with safe defaults,
	 * coerce types, cap array lengths, sanitize strings.
	 *
	 * The downstream frontend relies on this shape being stable.
	 *
	 * @since  1.0.0
	 * @param  array $plan Decoded plan.
	 * @return array
	 */
	private function validate_plan( $plan ) {
		$allowed_intents = array( 'generate', 'refine', 'edit_section', 'answer' );
		$intent          = isset( $plan['intent'] ) && in_array( $plan['intent'], $allowed_intents, true ) ? $plan['intent'] : 'generate';

		$confidence = isset( $plan['confidence'] ) ? (float) $plan['confidence'] : 0.5;
		$confidence = max( 0.0, min( 1.0, $confidence ) );

		$needs_clarification = ! empty( $plan['needs_clarification'] );

		$questions = array();
		if ( isset( $plan['clarifying_questions'] ) && is_array( $plan['clarifying_questions'] ) ) {
			foreach ( array_slice( $plan['clarifying_questions'], 0, 5 ) as $q ) {
				$clean = sanitize_text_field( (string) $q );
				if ( '' !== $clean ) {
					$questions[] = $clean;
				}
			}
		}

		$preset = null;
		if ( isset( $plan['preset_match'] ) && is_array( $plan['preset_match'] ) && ! empty( $plan['preset_match']['id'] ) ) {
			$preset = array(
				'id'    => sanitize_key( (string) $plan['preset_match']['id'] ),
				'name'  => isset( $plan['preset_match']['name'] ) ? sanitize_text_field( (string) $plan['preset_match']['name'] ) : '',
				'score' => isset( $plan['preset_match']['score'] ) ? max( 0.0, min( 1.0, (float) $plan['preset_match']['score'] ) ) : 0.0,
			);
		}

		$direction_keys = array( 'vibe', 'layout', 'color_story', 'typography', 'imagery', 'interactivity' );
		$direction      = array();
		$raw_direction  = isset( $plan['design_direction'] ) && is_array( $plan['design_direction'] ) ? $plan['design_direction'] : array();
		foreach ( $direction_keys as $key ) {
			$direction[ $key ] = isset( $raw_direction[ $key ] ) ? sanitize_textarea_field( (string) $raw_direction[ $key ] ) : '';
		}

		$allowed_scopes = array( 'global', 'style', 'script' );
		$tasks          = array();
		$raw_tasks      = isset( $plan['tasks'] ) && is_array( $plan['tasks'] ) ? $plan['tasks'] : array();
		$task_index     = 0;
		foreach ( array_slice( $raw_tasks, 0, 20 ) as $task ) {
			if ( ! is_array( $task ) ) {
				continue;
			}
			$task_index++;
			$label = isset( $task['label'] ) ? sanitize_textarea_field( (string) $task['label'] ) : '';
			if ( '' === $label ) {
				continue;
			}

			$scope_raw = isset( $task['scope'] ) ? (string) $task['scope'] : 'section:untitled';
			$scope     = 'section:untitled';
			if ( 0 === strpos( $scope_raw, 'section:' ) ) {
				$section_id = sanitize_key( substr( $scope_raw, 8 ) );
				if ( '' === $section_id ) {
					$section_id = 'untitled';
				}
				$scope = 'section:' . $section_id;
			} elseif ( in_array( $scope_raw, $allowed_scopes, true ) ) {
				$scope = $scope_raw;
			}

			$details = array();
			if ( isset( $task['details'] ) && is_array( $task['details'] ) ) {
				foreach ( array_slice( $task['details'], 0, 12 ) as $detail ) {
					$clean_detail = sanitize_textarea_field( (string) $detail );
					if ( '' !== $clean_detail ) {
						$details[] = $clean_detail;
					}
				}
			}

			$tasks[] = array(
				'id'        => isset( $task['id'] ) && '' !== trim( (string) $task['id'] ) ? sanitize_key( (string) $task['id'] ) : 't' . $task_index,
				'label'     => $label,
				'scope'     => $scope,
				'rationale' => isset( $task['rationale'] ) ? sanitize_textarea_field( (string) $task['rationale'] ) : '',
				'details'   => $details,
				'required'  => ! isset( $task['required'] ) || ! empty( $task['required'] ),
			);
		}

		$assumptions = array();
		if ( isset( $plan['assumptions'] ) && is_array( $plan['assumptions'] ) ) {
			foreach ( array_slice( $plan['assumptions'], 0, 8 ) as $a ) {
				$clean = sanitize_text_field( (string) $a );
				if ( '' !== $clean ) {
					$assumptions[] = $clean;
				}
			}
		}

		$estimated = isset( $plan['estimated_sections'] ) ? (int) $plan['estimated_sections'] : count( $tasks );
		$estimated = max( 0, min( 20, $estimated ) );

		return array(
			'intent'               => $intent,
			'confidence'           => round( $confidence, 2 ),
			'needs_clarification'  => $needs_clarification,
			'clarifying_questions' => $questions,
			'preset_match'         => $preset,
			'design_direction'     => $direction,
			'tasks'                => $tasks,
			'assumptions'          => $assumptions,
			'estimated_sections'   => $estimated,
		);
	}

	/**
	 * Sanitize attachments REST arg. Mirrors the chat endpoint's rules
	 * (max 8 attachments, max 15MB per data URL, must be a data: URL).
	 *
	 * @param mixed $value Raw input.
	 * @return array
	 */
	public function sanitize_attachments_arg( $value ) {
		if ( ! is_array( $value ) ) {
			return array();
		}
		$max_attachments = 8;
		$max_data_url    = 15 * 1024 * 1024;
		$result          = array();
		$count           = 0;
		foreach ( $value as $attachment ) {
			if ( ! is_array( $attachment ) ) {
				continue;
			}
			if ( $count >= $max_attachments ) {
				break;
			}
			$data_url = isset( $attachment['dataUrl'] ) ? (string) $attachment['dataUrl'] : '';
			if ( '' !== $data_url ) {
				if ( strlen( $data_url ) > $max_data_url ) {
					continue;
				}
				if ( 0 !== strpos( $data_url, 'data:' ) ) {
					continue;
				}
			}
			$result[] = array(
				'id'      => isset( $attachment['id'] ) ? sanitize_text_field( (string) $attachment['id'] ) : '',
				'name'    => isset( $attachment['name'] ) ? sanitize_file_name( (string) $attachment['name'] ) : '',
				'type'    => isset( $attachment['type'] ) ? sanitize_text_field( (string) $attachment['type'] ) : '',
				'size'    => isset( $attachment['size'] ) ? absint( $attachment['size'] ) : 0,
				'dataUrl' => $data_url,
			);
			$count++;
		}
		return $result;
	}

	/**
	 * Extract readable text from text-style attachments (md, txt, json, csv,
	 * xml). Each file is capped to prevent prompt bloat. Binary/image files
	 * are noted by name only.
	 *
	 * @param  array $attachments Sanitized attachments.
	 * @return string Concatenated context block, or empty string.
	 */
	private function build_planner_attachment_context( $attachments ) {
		if ( empty( $attachments ) || ! is_array( $attachments ) ) {
			return '';
		}

		$parts = array();
		foreach ( $attachments as $attachment ) {
			if ( ! is_array( $attachment ) || empty( $attachment['dataUrl'] ) ) {
				continue;
			}
			$name     = isset( $attachment['name'] ) ? sanitize_file_name( $attachment['name'] ) : 'unknown';
			$type     = isset( $attachment['type'] ) ? sanitize_text_field( $attachment['type'] ) : '';
			$data_url = $attachment['dataUrl'];

			$is_text = false !== strpos( $type, 'text/' )
				|| 'application/json' === $type
				|| 'application/xml' === $type
				|| false !== strpos( $type, '+xml' )
				|| false !== strpos( $type, 'csv' )
				|| false !== strpos( $type, 'markdown' );

			if ( $is_text ) {
				$comma_pos = strpos( $data_url, ',' );
				if ( false === $comma_pos ) {
					continue;
				}
				$base64_data = substr( $data_url, $comma_pos + 1 );
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding user-uploaded file content.
				$decoded = base64_decode( $base64_data, true );
				if ( false === $decoded || '' === trim( $decoded ) ) {
					continue;
				}
				$max_chars = 8000;
				$text      = mb_substr( trim( $decoded ), 0, $max_chars );
				if ( mb_strlen( $decoded ) > $max_chars ) {
					$text .= "\n…[truncated]";
				}
				$parts[] = '### File: ' . $name . ' (' . $type . ")\n" . $text;
			} elseif ( 0 === strpos( $type, 'image/' ) ) {
				$parts[] = '### Image: ' . $name . ' (' . $type . ') — content passed to the vision model.';
			} else {
				$size_kb = isset( $attachment['size'] ) ? round( absint( $attachment['size'] ) / 1024, 1 ) : 0;
				$parts[] = '### File: ' . $name . ' (' . $type . ', ' . $size_kb . 'KB) — binary, content not extractable.';
			}
		}

		return empty( $parts ) ? '' : implode( "\n\n", $parts );
	}

	/**
	 * Filter to image attachments only (data:image/*).
	 *
	 * @param  array $attachments Sanitized attachments.
	 * @return array
	 */
	private function filter_image_attachments( $attachments ) {
		if ( empty( $attachments ) || ! is_array( $attachments ) ) {
			return array();
		}
		return array_values(
			array_filter(
				$attachments,
				function ( $a ) {
					return is_array( $a )
						&& isset( $a['type'], $a['dataUrl'] )
						&& 0 === strpos( (string) $a['type'], 'image/' )
						&& '' !== $a['dataUrl'];
				}
			)
		);
	}

	/**
	 * Build OpenAI vision content parts (`image_url`) from image attachments.
	 * Caps at 4 images per request.
	 *
	 * @param  array $images Image attachments.
	 * @return array
	 */
	private function build_openai_image_parts( $images ) {
		if ( empty( $images ) || ! is_array( $images ) ) {
			return array();
		}
		$parts = array();
		$count = 0;
		foreach ( $images as $img ) {
			if ( $count >= 4 ) {
				break;
			}
			$data_url = isset( $img['dataUrl'] ) ? (string) $img['dataUrl'] : '';
			if ( '' === $data_url || 0 !== strpos( $data_url, 'data:' ) ) {
				continue;
			}
			$parts[] = array(
				'type'      => 'image_url',
				'image_url' => array( 'url' => $data_url ),
			);
			$count++;
		}
		return $parts;
	}

	/**
	 * Truncate long context fields to a hard character cap for the planner.
	 *
	 * @since  1.0.0
	 * @param  string $value Input value.
	 * @param  int    $max   Max character length.
	 * @return string
	 */
	private function truncate_for_planner( $value, $max ) {
		$value = (string) $value;
		if ( strlen( $value ) <= $max ) {
			return $value;
		}
		return substr( $value, 0, $max ) . '…[truncated]';
	}

	/**
	 * Debug logger for the planner. Writes to PHP error log when
	 * WP_DEBUG is on OR the `ambld_debug_ai_planner` filter returns true.
	 *
	 * @since  1.0.0
	 * @param  string $event Event name.
	 * @param  array  $data  Extra context.
	 * @return void
	 */
	private function planner_debug_log( $event, $data = array() ) {
		$enabled = ( defined( 'WP_DEBUG' ) && WP_DEBUG ) || apply_filters( 'ambld_debug_ai_planner', false );
		if ( ! $enabled ) {
			return;
		}

		$payload = wp_json_encode(
			array(
				'ts'    => gmdate( 'c' ),
				'event' => $event,
				'data'  => $data,
			)
		);

		if ( false !== $payload ) {
			error_log( '[AMB planner] ' . $payload ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		}
	}

	/**
	 * Refine an existing page.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function refine( $request ) {
		$prompt           = $request->get_param( 'prompt' );
		$html             = $request->get_param( 'html' );
		$selected_section = $request->get_param( 'selectedSection' );
		$page_settings    = $request->get_param( 'pageSettings' ) ?: array();

		$system_prompt = $this->build_refinement_system_prompt( $page_settings );

		$user_prompt = "Here is the current page HTML:\n\n" . $html;

		if ( ! empty( $selected_section ) ) {
			$user_prompt .= "\n\nThe user selected this specific section to modify:\n" . $selected_section;
		}

		$user_prompt .= "\n\nUser's refinement request: " . $prompt;

		$response = $this->call_ai_api( $system_prompt, $user_prompt );

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		return new \WP_REST_Response(
			array(
				'html'    => $response['content'],
				'message' => __( 'Page refined successfully.', 'antimanual-builder' ),
			),
			200
		);
	}

	/**
	 * Enhance a user prompt.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public function enhance_prompt( $request ) {
		$prompt = $request->get_param( 'prompt' );

		$system_prompt  = 'You are a senior product designer and web architect. The user will give you a brief description of a web page they want to build. ';
		$system_prompt .= 'Expand it into a detailed, opinionated build brief that a front-end engineer could implement directly. Cover: ';
		$system_prompt .= '(1) page purpose & target audience; ';
		$system_prompt .= '(2) section-by-section outline in order (hero, features, social proof, pricing, FAQ, CTA, footer, etc. — choose what fits); ';
		$system_prompt .= '(3) concrete copy direction (headlines, subheads, CTA text) — specific, not generic; ';
		$system_prompt .= '(4) visual system (color palette with hex, typography pairing, spacing scale, radius, shadow style, overall mood — e.g. "clean minimal", "bold brutalist", "glassy/futuristic"); ';
		$system_prompt .= '(5) imagery plan — where to Use placeholder imagery via https://placehold.co/{w}x{h} — plan sizes and ratios deliberately (e.g. 1440x720 editorial hero, 480x600 portrait cards, 96x96 avatars) placeholder images and at what dimensions (e.g. 1200x600 hero, 600x400 feature cards, 96x96 avatars); ';
		$system_prompt .= '(6) interactivity/JS behavior needed (tabs, accordion, modal, sticky nav, mobile menu, scroll reveal, form validation, etc.); ';
		$system_prompt .= '(7) responsive/mobile considerations; ';
		$system_prompt .= '(8) accessibility notes (alt text, focus states, semantic landmarks). ';
		$system_prompt .= 'Keep it concise but specific — no filler. Return ONLY the enhanced prompt text, nothing else.';

		$response = $this->call_ai_api( $system_prompt, $prompt );

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		return new \WP_REST_Response(
			array(
				'enhancedPrompt' => $response['content'],
			),
			200
		);
	}

	/**
	 * Convert HTML to block JSON.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function html_to_blocks( $request ) {
		$html = $request->get_param( 'html' );

		// Parse HTML into block structure.
		$blocks = $this->parse_html_to_blocks( $html );

		return new \WP_REST_Response(
			array(
				'blocks' => $blocks,
			),
			200
		);
	}

	/**
	 * Load project HTML into a DOMDocument using an HTML5-spec parser.
	 *
	 * Uses masterminds/html5-php when available so that the same error-recovery
	 * rules browsers apply (auto-closing unclosed tags, correct sectioning-element
	 * handling) are used during import. Falls back to DOMDocument::loadHTML()
	 * (libxml HTML4 parser) when the library is not present.
	 *
	 * @since  1.0.0
	 * @param  string $html Raw HTML content.
	 * @return \DOMDocument|null
	 */
	public function load_project_html_document( $html ) {
		if ( ! is_string( $html ) || '' === trim( $html ) ) {
			return null;
		}

		if ( class_exists( '\Masterminds\HTML5' ) ) {
			$html5 = new \Masterminds\HTML5( array( 'disable_html_ns' => true ) );
			$dom   = $html5->loadHTML( $html );
			return ( $dom instanceof \DOMDocument ) ? $dom : null;
		}

		// Fallback: libxml HTML4 parser.
		$dom                  = new \DOMDocument( '1.0', 'UTF-8' );
		$previous_error_state = libxml_use_internal_errors( true );
		$loaded               = $dom->loadHTML(
			'<?xml encoding="utf-8" ?>' . $html,
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
		);

		foreach ( iterator_to_array( $dom->childNodes ) as $node ) {
			if ( $node instanceof \DOMProcessingInstruction && 'xml' === strtolower( $node->target ) ) {
				$dom->removeChild( $node );
				break;
			}
		}

		libxml_clear_errors();
		libxml_use_internal_errors( $previous_error_state );

		if ( ! $loaded ) {
			return null;
		}

		return $dom;
	}

	/**
	 * Collect the renderable attributes of an imported <script> (minus `src`).
	 *
	 * Preserves load-affecting and data attributes so the standalone render is
	 * faithful: async/defer/type/integrity/crossorigin/nomodule/referrerpolicy/
	 * fetchpriority/id and any data-*. Boolean attributes (present with an empty
	 * value, e.g. `<script defer>`) are stored as `true`.
	 *
	 * @since 2.2.0
	 * @param \DOMElement $script_element Script node.
	 * @return array Attribute map { name: value|true }.
	 */
	public function collect_script_attributes( $script_element ) {
		$attrs = array();
		if ( ! $script_element->hasAttributes() ) {
			return $attrs;
		}
		$allowed = array( 'async', 'defer', 'type', 'integrity', 'crossorigin', 'nomodule', 'referrerpolicy', 'fetchpriority', 'id' );
		foreach ( iterator_to_array( $script_element->attributes ) as $attr ) {
			$name = strtolower( $attr->nodeName );
			if ( 'src' === $name ) {
				continue;
			}
			if ( in_array( $name, $allowed, true ) || 0 === strpos( $name, 'data-' ) ) {
				$attrs[ $name ] = ( '' === $attr->nodeValue ) ? true : $attr->nodeValue;
			}
		}
		return $attrs;
	}

	/**
	 * Extract the inner HTML of an element.
	 *
	 * @since  1.0.0
	 * @param  DOMNode $node DOM node.
	 * @return string
	 */
	public function get_inner_html( $node ) {
		$html = '';

		if ( ! ( $node instanceof \DOMNode ) ) {
			return $html;
		}

		foreach ( $node->childNodes as $child ) {
			$serialized = $node->ownerDocument->saveHTML( $child );
			if ( $child instanceof \DOMElement ) {
				$tag = strtolower( $child->nodeName );
				if ( 'script' === $tag || 'style' === $tag ) {
					$serialized = preg_replace_callback(
						'#(<' . $tag . '[^>]*>)(.*?)(</' . $tag . '>)#si',
						static function ( $m ) {
							return $m[1] . html_entity_decode( $m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) . $m[3];
						},
						$serialized
					);
				}
			}
			$html .= $serialized;
		}

		return $html;
	}

	/**
	 * Serialize a raw-text element (script/style) without HTML-encoding its content.
	 *
	 * DOMDocument::saveHTML() encodes &, <, > inside script/style when the document
	 * was loaded with an XML encoding declaration. This helper undoes that encoding
	 * for those two element types so inline JavaScript and CSS are preserved verbatim.
	 *
	 * @since  1.0.0
	 * @param  DOMDocument $dom     DOM document.
	 * @param  DOMElement  $element Element to serialize.
	 * @return string
	 */
	private function serialize_raw_text_element( $dom, $element ) {
		$html = $dom->saveHTML( $element );
		$tag  = strtolower( $element->nodeName );
		if ( 'script' === $tag || 'style' === $tag ) {
			$html = preg_replace_callback(
				'#(<' . $tag . '[^>]*>)(.*?)(</' . $tag . '>)#si',
				static function ( $m ) {
					return $m[1] . html_entity_decode( $m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) . $m[3];
				},
				$html
			);
		}
		return $html;
	}

	/**
	 * Derive a WordPress post slug from a source file path.
	 *
	 * Examples:
	 *   "about.html"           → "about"
	 *   "pages/contact.html"   → "pages-contact"
	 *   "index.html"           → ""
	 *   "pages/index.html"     → "pages"
	 *
	 * @since  1.0.0
	 * @param  string $source_file Source file path (relative, as stored in the prepared page).
	 * @return string
	 */
	/**
	 * Extract a compact attribute map from an imported HTML or BODY element.
	 *
	 * @since  1.0.0
	 * @param  DOMElement|null $element Source element.
	 * @return array
	 */
	public function extract_project_document_attributes( $element ) {
		if ( ! ( $element instanceof \DOMElement ) || ! $element->hasAttributes() ) {
			return array();
		}

		$attributes = array();

		foreach ( $element->attributes as $attribute ) {
			if ( ! ( $attribute instanceof \DOMAttr ) ) {
				continue;
			}

			$name  = strtolower( $attribute->name );
			$value = trim( (string) $attribute->value );

			if ( '' === $name || '' === $value ) {
				continue;
			}

			$attributes[ $name ] = $value;
		}

		return $attributes;
	}

	/**
	 * Serialize a DOMElement's attributes into an HTML attribute string.
	 *
	 * @param  \DOMElement|null $element Element to serialize.
	 * @return string Attribute string (leading space included when non-empty).
	 */
	public function serialize_element_attributes( $element ) {
		if ( ! ( $element instanceof \DOMElement ) || ! $element->hasAttributes() ) {
			return '';
		}

		$parts = array();

		foreach ( $element->attributes as $attr ) {
			if ( ! ( $attr instanceof \DOMAttr ) ) {
				continue;
			}
			$parts[] = $attr->name . '="' . htmlspecialchars( (string) $attr->value, ENT_QUOTES, 'UTF-8' ) . '"';
		}

		return $parts ? ' ' . implode( ' ', $parts ) : '';
	}

/**
	 * Build the generation system prompt.
	 *
	 * @since  1.0.0
	 * @param  array $page_settings Page settings array.
	 * @return string System prompt.
	 */
	private function build_generation_system_prompt( $page_settings = array() ) {
		$prompt  = 'You are a senior front-end engineer and UI/UX designer. Generate complete, production-ready, beautifully designed HTML pages based on user instructions. ';
		$prompt .= 'Your target quality bar is a modern marketing site (Stripe, Linear, Vercel, Apple) — not a generic template. ';
		$prompt .= 'Use semantic HTML5, responsive mobile-first layouts, strong visual hierarchy, generous whitespace, and thoughtful typography. ';
		$prompt .= 'Every top-level section MUST have a descriptive kebab-case class and a matching `data-amb-section` attribute (e.g. `<section class="hero-section" data-amb-section="hero">`). ';

		// Design quality bar.
		$prompt .= "\n\nDESIGN QUALITY (IMPORTANT):\n";
		$prompt .= "- Use a cohesive, modern color palette (define CSS variables for primary/accent/neutral/bg/text at :root).\n";
		$prompt .= "- Use a clear type scale (h1 ~ 2.5–4rem, h2 ~ 1.75–2.5rem, body ~ 1rem/1.6 line-height) with a modern system font stack (e.g. `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif`). Do NOT @import remote font files.\n";
		$prompt .= "- Add depth: subtle shadows, rounded corners (8–24px), gradients, glassmorphism or soft blurs where tasteful.\n";
		$prompt .= "- Include micro-interactions: hover states, smooth transitions (200–400ms), transform scale/translate on cards & buttons, focus-visible outlines for a11y.\n";
		$prompt .= "- Use CSS Grid / Flexbox for layout. Include at least one @media (max-width: 768px) breakpoint per layout-bearing rule.\n";
		$prompt .= "- Prefer real-looking, specific copy over Lorem Ipsum unless explicitly asked.\n";

		// Images rule — NEW.
		$prompt .= "\n\nIMAGE RULES (IMPORTANT):\n";
		$prompt .= "- Modern designs need images. Use placeholder images from https://placehold.co whenever a hero, feature, testimonial avatar, product shot, gallery, or card visual would improve the design.\n";
		$prompt .= "- URL pattern: `https://placehold.co/{width}x{height}` (e.g. `https://placehold.co/1200x600` for a hero, `https://placehold.co/600x400` for cards, `https://placehold.co/96x96` for avatars, `https://placehold.co/400x500` for portraits).\n";
		$prompt .= "- You MAY customize color + text: `https://placehold.co/600x400/1e293b/ffffff?text=Dashboard+Preview` (bg/fg hex without #, `text=` URL-encoded with `+` for spaces).\n";
		$prompt .= "- Always include meaningful descriptive `alt` text, explicit `width` and `height` attributes matching the URL, and `loading=\"lazy\"` on non-hero images.\n";
		$prompt .= "- Use `object-fit: cover` and `border-radius` in CSS so placeholders render cleanly inside cards.\n";
		$prompt .= "- Do not invent image URLs from other domains. Only use https://placehold.co for placeholders.\n";

		// Interactivity.
		$prompt .= "\n\nINTERACTIVITY RULES:\n";
		$prompt .= "- When the user's request implies interactivity (tabs, accordions, modals, sliders, mobile menus, form validation, counters, filters, tooltips, dark-mode toggle, scroll animations), include a single `<script>` block at the END of the HTML.\n";
		$prompt .= "- Vanilla JS only. NEVER add an external library via `<script src=\"…\">` (jQuery, GSAP, Swiper, Alpine, etc.) — external `<script src>` tags are STRIPPED on save and will NOT load, so the page would break. Write the behavior in plain vanilla JS instead. If a third-party library is genuinely required, the user adds it through the editor's CDN attacher (the chat `attach_cdn` tool) — you must NOT paste its `<script src>` into the HTML. Use `document.addEventListener('DOMContentLoaded', () => { ... })`. Scope queries to the section via `data-amb-section` when possible.\n";
		$prompt .= "- Use event delegation, IntersectionObserver for reveal-on-scroll, and `prefers-reduced-motion` guards for animations.\n";
		$prompt .= "- Keep scripts idempotent (safe to run multiple times — check for an `data-amb-inited` flag before binding).\n";

		// Style instructions — critical for persistence.
		$prompt .= "\n\nSTYLE RULES (IMPORTANT):\n";
		$prompt .= "- Place ALL CSS in a single <style> block at the very top of your HTML output.\n";
		$prompt .= "- Define design tokens at :root (--color-primary, --color-accent, --color-bg, --color-text, --radius, --shadow-sm/md/lg, --space-*).\n";
		$prompt .= "- Use class names for styling (e.g., .hero-section, .feature-card). BEM-ish naming is encouraged.\n";
		$prompt .= "- SECTION-SCOPE EVERY SELECTOR (mandatory). On the live page the WordPress theme and other plugins also load CSS; a bare single-class rule like `.feature-card{…}` ties on specificity with a theme's `.feature-card` and loses, so the design breaks. Give each `<section>` a root class (e.g. `.hero-section`) and prefix EVERY rule for an element inside it with that root class: write `.hero-section .feature-card{…}`, `.hero-section .container{…}`, `.hero-section .cta-btn:hover{…}` — NEVER bare `.feature-card{…}` / `.container{…}`. The section root rule itself stays one class (`.hero-section{…}`). Keep the scope inside @media too. Do NOT scope `:root` token blocks or `@keyframes`. This gives builder rules (0,2,0) specificity so they beat theme (0,1,0) rules and never leak onto the rest of the site.\n";
		$prompt .= "- Include responsive @media queries inside the <style> block.\n";
		$prompt .= "- For hover effects, transitions, and pseudo-elements, use the <style> block.\n";
		$prompt .= "- Do NOT add @import statements or <link> tags pointing at remote font/CSS hosts (fonts.googleapis.com, fonts.bunny.net, etc.). Rely on the system font stack only. External `<link rel=\"stylesheet\">` tags are STRIPPED and will NOT load — an external CSS library can only be added through the editor's CDN attacher, never pasted into the HTML.\n";
		$prompt .= "- Do NOT use inline style attributes on HTML elements.\n";
		$prompt .= "- The <style> block will be automatically extracted and stored separately.\n";

		// Icon rules.
		$prompt .= "\n\nICON RULES (IMPORTANT):\n";
		$prompt .= "- Always use inline SVG icons (Heroicons-style, 24x24 viewBox, stroke=\"currentColor\", fill=\"none\", stroke-width=\"1.75\") as the default for all icons.\n";
		$prompt .= "- Never use emoji characters as icons unless the user explicitly asks for emoji in their prompt.\n";
		$prompt .= "- For icon styling (size, color, stroke), add rules to the <style> block using the icon's class name.\n";

		$prompt .= "\n\nIMPORTANT: Return your response as a JSON object with two keys:\n";
		$prompt .= '- "title": A short, descriptive page title (3-8 words, suitable as a WordPress page title).';
		$prompt .= "\n";
		$prompt .= '- "html": The complete HTML content of the page (no doctype, no head/body tags — just the <style> block followed by the page content).';
		$prompt .= "\n\nExample format:\n";
		$prompt .= '{"title": "Modern SaaS Landing Page", "html": "<style>.hero-section{background:#f0f9ff;padding:80px 0;}.hero-section .container{max-width:1200px;margin:0 auto;}.hero-section .hero-title{font-size:3rem;}</style><section class=\"hero-section\" data-amb-section=\"hero\"><div class=\"container\"><h1 class=\"hero-title\">Welcome</h1></div></section>"}';
		$prompt .= "\n\nReturn ONLY the JSON object. No markdown fences, no explanations.";

		// --- Intent Classification ---
		$prompt .= "\n\n## Intent Classification\n";
		$prompt .= "The user's verb choice tells you what kind of change they want. Honor it strictly — do NOT substitute one for the other.\n\n";
		$prompt .= "ADD intent — produce a NEW section/element/component:\n";
		$prompt .= "  Verbs: add, attach, insert, include, append, create, drop in, throw in, put in, add a new, add another, can you add, also add, plus add.\n";
		$prompt .= "  Nouns of focus: \"a section\", \"a hero\", \"a CTA\", \"another row\", \"one more\".\n";
		$prompt .= "  Action: emit ONE new <section> (or use append_section in tool-edit). Never modify existing sections when the prompt is ADD intent.\n\n";
		$prompt .= "MODIFY intent — refine, tweak, or replace EXISTING content:\n";
		$prompt .= "  Verbs: change, update, improve, refine, redesign, enhance, restyle, redo, rework, modernize, revamp, upgrade, tweak, adjust, fix, polish, clean up, freshen, add hover, add transition.\n";
		$prompt .= "  Action: identify the affected section/selector and patch_css or replace_section. Do NOT emit a brand-new section.\n\n";
		$prompt .= "REMOVE intent — delete content:\n";
		$prompt .= "  Verbs: remove, delete, drop, get rid of, take out, hide, eliminate.\n";
		$prompt .= "  Action: use delete_section (or omit the targeted section from output).\n\n";
		$prompt .= "AMBIGUOUS — when both intents could apply (e.g., \"swap the hero for a cleaner one\"):\n";
		$prompt .= "  Prefer MODIFY (replace_section on the existing hero) unless the user explicitly says \"new\".\n\n";

		// Append template constraints if header/footer templates are attached.
		$tpl_state = \AMBLD_Render_Template_Resolver::resolve_attachment_state( $page_settings );
		$constraints_prompt = \AMBLD_Render_Template_Resolver::build_constraints_prompt( $tpl_state );
		if ( ! empty( $constraints_prompt ) ) {
			$prompt .= $constraints_prompt;
		}

		return $prompt;
	}

/**
	 * Build the system prompt for page refinement.
	 *
	 * @since  1.0.0
	 * @param  array $page_settings Page settings array.
	 * @return string System prompt.
	 */
	private function build_refinement_system_prompt( $page_settings = array() ) {
		$prompt  = 'You are a senior front-end engineer refining an existing production web page. The user will provide existing HTML (with a <style> block containing CSS) and request modifications. ';
		$prompt .= 'Apply the requested changes with a high design bar — Stripe/Linear/Vercel quality — while preserving the overall structure and style tokens of the page. ';
		$prompt .= 'If a specific section is selected, modify only that section. ';
		$prompt .= 'Always return the complete <style> block at the top, followed by all the HTML content. ';
		$prompt .= 'If you modify styles, update them in the <style> block — do not use inline style attributes. Reuse existing CSS variables/design tokens when present. ';
		$prompt .= "\n\nSECTION-SCOPED SELECTORS (mandatory): every CSS rule you write or change MUST be namespaced under its section's root class so the active theme cannot override it. Write `.pricing-section .price-card{…}`, never bare `.price-card{…}`; the section root rule stays one class (`.pricing-section{…}`). The mandatory `.container` wrapper is scoped too (`.pricing-section .container{…}`). Keep the scope inside @media queries. Do not scope `:root` token blocks or `@keyframes`. When you edit an existing rule that is still a bare single class, re-scope it under its section root as part of the edit. Rationale: `.section .child` (0,2,0) beats a theme's `.child` (0,1,0).";
		$prompt .= "\n\nADDITIVE EDITS: If the user's request contains the word \"add\" (e.g. \"add a testimonials section\", \"add a CTA button\"), treat it as an additive operation. ";
		$prompt .= 'Keep the ENTIRE existing page exactly as-is and only append or insert the new element. Do NOT rewrite, restructure, or remove any existing content. ';
		$prompt .= "\n\nIMAGES: Modern designs need imagery. When adding or enhancing sections that benefit from visuals (hero, features, testimonials, gallery, product, team, blog), include images from https://placehold.co using the pattern `https://placehold.co/{width}x{height}` (e.g. 1200x600 for hero, 600x400 for cards, 96x96 for avatars). You may add color/text: `https://placehold.co/600x400/1e293b/ffffff?text=Preview`. Always set descriptive alt, explicit width/height, and loading=\"lazy\" on non-hero images. Do not invent image URLs from other domains.";
		$prompt .= "\n\nINTERACTIVITY: If the request implies behavior (tabs, accordion, modal, slider, mobile menu, form validation, filter, toggle, counter, scroll reveal), add or extend a single <script> block at the END of the HTML. Vanilla JS only — NEVER add an external library via <script src=\"…\"> or <link rel=\"stylesheet\"> (jQuery, GSAP, Swiper, Alpine, icon/font CDNs, etc.); such tags are STRIPPED on save and will NOT load. Third-party libraries are added ONLY through the editor's CDN attacher (the chat `attach_cdn` tool), never pasted into the HTML. Wrap in DOMContentLoaded, keep idempotent (guard with a data-amb-inited flag), and respect prefers-reduced-motion.";
		$prompt .= "\n\nICON RULES: Always use inline SVG icons (Heroicons-style, 24x24 viewBox, stroke=\"currentColor\", stroke-width=\"1.75\", fill=\"none\") as the default. Never use emoji characters as icons unless the user explicitly says \"use emoji\". ";
		$prompt .= 'Return the complete modified HTML (with <style> block, and <script> block if interactivity is needed). Return ONLY the HTML content, no explanations or markdown.';

		// --- Intent Classification ---
		$prompt .= "\n\n## Intent Classification\n";
		$prompt .= "The user's verb choice tells you what kind of change they want. Honor it strictly — do NOT substitute one for the other.\n\n";
		$prompt .= "ADD intent — produce a NEW section/element/component:\n";
		$prompt .= "  Verbs: add, attach, insert, include, append, create, drop in, throw in, put in, add a new, add another, can you add, also add, plus add.\n";
		$prompt .= "  Nouns of focus: \"a section\", \"a hero\", \"a CTA\", \"another row\", \"one more\".\n";
		$prompt .= "  Action: emit ONE new <section>. Never modify existing sections when the prompt is ADD intent.\n\n";
		$prompt .= "MODIFY intent — refine, tweak, or replace EXISTING content:\n";
		$prompt .= "  Verbs: change, update, improve, refine, redesign, enhance, restyle, redo, rework, modernize, revamp, upgrade, tweak, adjust, fix, polish, clean up, freshen, add hover, add transition.\n";
		$prompt .= "  Action: identify the affected section/selector and patch_css or replace_section. Do NOT emit a brand-new section.\n\n";
		$prompt .= "REMOVE intent — delete content:\n";
		$prompt .= "  Verbs: remove, delete, drop, get rid of, take out, hide, eliminate.\n";
		$prompt .= "  Action: omit the targeted section from output.\n\n";
		$prompt .= "AMBIGUOUS — when both intents could apply (e.g., \"swap the hero for a cleaner one\"):\n";
		$prompt .= "  Prefer MODIFY (replace_section on the existing hero) unless the user explicitly says \"new\".\n\n";

		// Append template constraints if header/footer templates are attached.
		$tpl_state = \AMBLD_Render_Template_Resolver::resolve_attachment_state( $page_settings );
		$constraints_prompt = \AMBLD_Render_Template_Resolver::build_constraints_prompt( $tpl_state );
		if ( ! empty( $constraints_prompt ) ) {
			$prompt .= $constraints_prompt;
		}

		return $prompt;
	}

	/**
	 * Parse an AI page-generation response into title and HTML.
	 *
	 * @since  1.0.0
	 * @param  string $content Raw AI response content.
	 * @return array{title:string,html:string}
	 */
	private function parse_generated_page_response( $content ) {
		$title   = '';
		$html    = '';
		$trimmed = trim( (string) $content );

		// Unwrap a ```json … ``` fence if the model added one.
		if ( preg_match( '/^```(?:json)?\s*\n?(.*?)\n?```$/s', $trimmed, $fence_match ) ) {
			$trimmed = trim( $fence_match[1] );
		}

		if ( '' !== $trimmed && '{' === $trimmed[0] ) {
			// The generator is asked to return a JSON object { title, html }.
			$parsed = json_decode( $trimmed, true );
			if ( is_array( $parsed ) ) {
				if ( isset( $parsed['title'] ) ) {
					$title = sanitize_text_field( $parsed['title'] );
				}
				if ( isset( $parsed['html'] ) && is_string( $parsed['html'] ) ) {
					$html = $parsed['html'];
				}
			}
			// If it LOOKED like JSON but failed to decode — e.g. the response
			// was truncated by the output-token limit — $html is left empty on
			// purpose so the caller surfaces a real error instead of saving the
			// raw, unparsed JSON string ('{"title":…,"html":"…') as page content.
		} else {
			// Not JSON: treat the response as raw HTML.
			$html = $trimmed;
		}

		return array(
			'title' => $title,
			'html'  => $html,
		);
	}

	/**
	 * Call the AI API provider.
	 *
	 * @since  1.0.0
	 * @param  string $system_prompt System/context prompt.
	 * @param  string $user_prompt   User's message/prompt.
	 * @return array|WP_Error Response data or error.
	 */
	public function call_ai_api( $system_prompt, $user_prompt, $options = array() ) {
		$provider = AMBLD_Antimanual_Ai_Provider::get_provider();
		$model    = AMBLD_Antimanual_Ai_Provider::get_model( $provider );
		$api_key  = AMBLD_Antimanual_Ai_Provider::get_api_key( $provider );

		if ( empty( $api_key ) ) {
			return new \WP_Error(
				'ai_not_configured',
				AMBLD_Antimanual_Ai_Provider::get_status_message(),
				array( 'status' => 400 )
			);
		}

		// OpenAI-compatible providers (xAI, DeepSeek, OpenCodeZen) reuse the
		// OpenAI client with their own base URL.
		if ( AMBLD_Antimanual_Ai_Provider::is_openai_compatible( $provider ) ) {
			$options['base_url'] = AMBLD_Antimanual_Ai_Provider::get_base_url( $provider );
			// `reasoning_effort` is OpenAI-only; xAI/DeepSeek/OpenCodeZen reject it.
			if ( 'openai' === $provider ) {
				$ai_budget = self::get_ai_budget();
				$options['reasoning_effort'] = $ai_budget['reasoning_effort'];
			}
			return $this->call_openai( $api_key, $model, $system_prompt, $user_prompt, $options );
		}

		switch ( $provider ) {
			case 'gemini':
				return $this->call_gemini( $api_key, $model, $system_prompt, $user_prompt, $options );
			case 'anthropic':
				return $this->call_anthropic( $api_key, $model, $system_prompt, $user_prompt );
			default:
				return new \WP_Error(
					'invalid_provider',
					/* translators: %s: Provider name. */
					sprintf( __( 'Unknown AI provider: %s', 'antimanual-builder' ), $provider ),
					array( 'status' => 400 )
				);
		}
	}

	/**
	 * Call OpenAI API.
	 *
	 * @since  1.0.0
	 * @param  string $api_key       API key.
	 * @param  string $model         Model name.
	 * @param  string $system_prompt System prompt.
	 * @param  string $user_prompt   User prompt.
	 * @return array|WP_Error
	 */
	private function call_openai( $api_key, $model, $system_prompt, $user_prompt, $options = array() ) {
		$token_key = AMBLD_Antimanual_Ai_Provider::uses_max_completion_tokens( $model )
			? 'max_completion_tokens'
			: 'max_tokens';

		// When image attachments are present, the user message becomes a
		// vision-style content array so the model can actually see them.
		$image_parts = ! empty( $options['image_attachments'] )
			? $this->build_openai_image_parts( $options['image_attachments'] )
			: array();

		$user_message = empty( $image_parts )
			? array( 'role' => 'user', 'content' => $user_prompt )
			: array(
				'role'    => 'user',
				'content' => array_merge(
					array( array( 'type' => 'text', 'text' => $user_prompt ) ),
					$image_parts
				),
			);

		$payload = array(
			'model'    => $model,
			'messages' => array(
				array( 'role' => 'system', 'content' => $system_prompt ),
				$user_message,
			),
			$token_key => isset( $options['max_tokens'] ) ? (int) $options['max_tokens'] : 8000,
		);

		// Reasoning models (o1/o3/o4/gpt-5+) only accept the default
		// temperature (1) and reject any explicit value.
		if ( AMBLD_Antimanual_Ai_Provider::supports_custom_temperature( $model ) ) {
			$payload['temperature'] = isset( $options['temperature'] ) ? (float) $options['temperature'] : 0.7;
		}

		if ( ! empty( $options['json_mode'] ) ) {
			$payload['response_format'] = array( 'type' => 'json_object' );
		}

		if ( ! empty( $options['reasoning_effort'] ) ) {
			$payload['reasoning_effort'] = AMBLD_Antimanual_Ai_Provider::normalize_reasoning_effort( $options['reasoning_effort'] );
		}

		$base_url = ! empty( $options['base_url'] )
			? rtrim( (string) $options['base_url'], '/' )
			: 'https://api.openai.com/v1';

		$response = wp_remote_post(
			$base_url . '/chat/completions',
			array(
				'timeout' => 180,
				'headers' => array(
					'Authorization' => 'Bearer ' . $api_key,
					'Content-Type'  => 'application/json',
				),
				'body'    => wp_json_encode( $payload ),
			)
		);

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );

		if ( isset( $body['error'] ) ) {
			return new \WP_Error(
				'ai_error',
				isset( $body['error']['message'] ) ? $body['error']['message'] : __( 'AI API error.', 'antimanual-builder' ),
				array( 'status' => 500 )
			);
		}

		return array(
			'content' => isset( $body['choices'][0]['message']['content'] ) ? $body['choices'][0]['message']['content'] : '',
			'usage'   => isset( $body['usage'] ) ? $body['usage'] : array(),
		);
	}

	/**
	 * Call Anthropic (Claude) API.
	 *
	 * @since  1.0.0
	 * @param  string $api_key       API key.
	 * @param  string $model         Model name.
	 * @param  string $system_prompt System prompt.
	 * @param  string $user_prompt   User prompt.
	 * @return array|WP_Error
	 */
	private function call_anthropic( $api_key, $model, $system_prompt, $user_prompt ) {
		$response = wp_remote_post(
			'https://api.anthropic.com/v1/messages',
			array(
				'timeout'   => 180,
				'headers'   => array(
					'x-api-key'         => $api_key,
					'anthropic-version' => '2023-06-01',
					'Content-Type'      => 'application/json',
				),
				'body'    => wp_json_encode(
					array(
						'model'      => $model,
						'max_tokens' => 8000,
						'system'     => $system_prompt,
						'messages'   => array(
							array( 'role' => 'user', 'content' => $user_prompt ),
						),
					)
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );

		if ( isset( $body['error'] ) ) {
			return new \WP_Error(
				'ai_error',
				isset( $body['error']['message'] ) ? $body['error']['message'] : __( 'AI API error.', 'antimanual-builder' ),
				array( 'status' => 500 )
			);
		}

		return array(
			'content' => isset( $body['content'][0]['text'] ) ? $body['content'][0]['text'] : '',
			'usage'   => isset( $body['usage'] ) ? $body['usage'] : array(),
		);
	}

	/**
	 * Call Google Gemini API.
	 *
	 * @since  1.0.0
	 * @param  string $api_key       API key.
	 * @param  string $model         Model name.
	 * @param  string $system_prompt System prompt.
	 * @param  string $user_prompt   User prompt.
	 * @return array|WP_Error
	 */
	private function call_gemini( $api_key, $model, $system_prompt, $user_prompt, $options = array() ) {
		$url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . $api_key;

		$generation_config = array(
			'maxOutputTokens' => isset( $options['max_tokens'] ) ? (int) $options['max_tokens'] : 8000,
			'temperature'     => isset( $options['temperature'] ) ? (float) $options['temperature'] : 0.7,
		);

		if ( ! empty( $options['json_mode'] ) ) {
			$generation_config['responseMimeType'] = 'application/json';
		}

		$response = wp_remote_post(
			$url,
			array(
				'timeout' => 180,
				'headers' => array(
					'Content-Type' => 'application/json',
				),
				'body'    => wp_json_encode(
					array(
						'system_instruction' => array(
							'parts' => array(
								array( 'text' => $system_prompt ),
							),
						),
						'contents'           => array(
							array(
								'parts' => array(
									array( 'text' => $user_prompt ),
								),
							),
						),
						'generationConfig'   => $generation_config,
					)
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );

		if ( isset( $body['error'] ) ) {
			return new \WP_Error(
				'ai_error',
				isset( $body['error']['message'] ) ? $body['error']['message'] : __( 'AI API error.', 'antimanual-builder' ),
				array( 'status' => 500 )
			);
		}

		$text = '';
		if ( isset( $body['candidates'][0]['content']['parts'][0]['text'] ) ) {
			$text = $body['candidates'][0]['content']['parts'][0]['text'];
		}

		return array(
			'content' => $text,
			'usage'   => isset( $body['usageMetadata'] ) ? $body['usageMetadata'] : array(),
		);
	}

	// ─── Background Import Job ───────────────────────────────────────────────

	/**
	 * Start a background import job.
	 *
	 * Validates the ZIP, persists it to disk, stores a job transient, and
	 * fires a non-blocking loopback request to the /process endpoint.
	 * Returns the job ID immediately so the client can poll for status.
	 *
	 * @since  1.0.0
	 * @param  WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error
	 */
	// ─── CSS Scoping ─────────────────────────────────────────────────────────

	/**
	 * Parse HTML into a block structure.
	 *
	 * Basic HTML parser that converts HTML elements into
	 * the builder's block JSON format.
	 *
	 * @since  1.0.0
	 * @param  string $html HTML string.
	 * @return array Array of block definitions.
	 */
	private function parse_html_to_blocks( $html ) {
		// This is a simplified parser. A full implementation would use
		// DOMDocument for robust HTML parsing.
		$blocks = array();

		$blocks[] = array(
			'id'       => wp_generate_uuid4(),
			'type'     => 'html',
			'content'  => array(
				'html' => $html,
			),
			'styles'   => array(),
			'children' => array(),
		);

		return $blocks;
	}
}
