<?php
/**
 * Streaming Chat REST API controller.
 *
 * Provides an SSE-based chat endpoint that streams build stages, assistant
 * commentary, and generated page output so the editor can respond with page
 * generation, page refinement, or conversational answers as needed.
 *
 * @package Antimanual_Builder
 * @since   1.1.0
 */

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

/**
 * Class AMBLD_API_Chat_Api
 *
 * Streaming chat support for OpenAI and Gemini.
 *
 * @since 1.1.0
 */
class AMBLD_API_Chat_Api {

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

	/**
	 * Active agent harness for the current chat turn.
	 *
	 * Set at the top of `chat_stream()` and consumed by downstream helpers
	 * (tool-edit, refinement, page generation) so they can record tool calls
	 * and run guardrails without rethreading the harness through every
	 * private method signature.
	 *
	 * @var AMBLD_Agent_Harness|null
	 */
	private $ambld_harness = null;

	/**
	 * Constructor.
	 */
	public function __construct() {
		// No-op — initialization handled lazily.
	}

	/**
	 * Register routes.
	 *
	 * @return void
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/ai/chat',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'chat_stream' ),
				'permission_callback' => array( $this, 'can_use_ai' ),
				'args'                => array(
					'messages' => array(
						'required' => true,
						'type'     => 'array',
					),
					'currentHtml' => array(
						'default' => '',
					),
					'currentCss' => array(
						'default' => '',
					),
					'currentJs' => array(
						'default' => '',
					),
					'selectedSection' => array(
						'default' => '',
					),
					'pageTitle' => array(
						'default' => '',
					),
					'pageSettings' => array(
						'default' => array(),
					),
					'provider' => array(
						'default' => '',
					),
					'model' => array(
						'default' => '',
					),
					'useKnowledgeBase' => array(
						'default' => false,
					),
					'attachments' => array(
						'default'            => array(),
						'type'               => 'array',
						'items'              => array(
							'type'       => 'object',
							'properties' => array(
								'id'      => array( 'type' => 'string' ),
								'name'    => array( 'type' => 'string' ),
								'type'    => array( 'type' => 'string' ),
								'size'    => array( 'type' => 'integer' ),
								'dataUrl' => array( 'type' => 'string' ),
							),
						),
						'sanitize_callback'  => function ( $value ) {
							if ( ! is_array( $value ) ) {
								return array();
							}
							// Hard caps:
							//  - Max 8 attachments per request (UI cap is 4;
							//    8 is double for headroom).
							//  - Max 15 MB per dataUrl string (the file
							//    cap in the UI is 10 MB raw → ~13.3 MB
							//    base64-encoded; 15 MB leaves margin).
							//  - dataUrl MUST begin with "data:" or be
							//    empty. Blocks blob: / http: / file: /
							//    arbitrary strings sneaking into the
							//    base64_decode path downstream.
							$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 ) {
										// Drop oversized payloads silently rather than reject the whole request.
										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;
						},
					),
					'approvedPlan' => array(
						'default' => null,
					),
					'passDirective' => array(
						'default' => null,
					),
					'postId' => array(
						'default'           => 0,
						'sanitize_callback' => 'absint',
					),
					'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 );
						},
					),
				),
			)
		);
	}

	/**
	 * Permission check.
	 *
	 * @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 = AMBLD_Antimanual_Ai_Provider::get_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 API key for the specified provider.
	 *
	 * @param array  $settings AI settings.
	 * @param string $provider Provider name.
	 * @return string API key.
	 */
	private function get_api_key( $settings, $provider ) {
		unset( $settings );

		return AMBLD_Antimanual_Ai_Provider::get_api_key( $provider );
	}



	/**
	 * Extract readable text content from file attachment data URLs.
	 *
	 * For text-based files (plain text, documents), decodes the base64
	 * content and returns it. Images are described by name/type only
	 * since their content cannot be injected as text.
	 *
	 * @param array $attachments Array of attachment data.
	 * @return string Formatted attachment context for the system prompt.
	 */
	private function build_attachment_context( $attachments ) {
		error_log( '[AMB] build_attachment_context: received ' . ( is_array( $attachments ) ? count( $attachments ) : 0 ) . ' attachment(s)' );
		
		if ( empty( $attachments ) || ! is_array( $attachments ) ) {
			return '';
		}

		$context_parts = array();

		foreach ( $attachments as $attachment ) {
			if ( ! is_array( $attachment ) || empty( $attachment['dataUrl'] ) ) {
				error_log( '[AMB] build_attachment_context: skipping attachment (missing dataUrl or not array)' );
				continue;
			}

			$name = isset( $attachment['name'] ) ? sanitize_file_name( $attachment['name'] ) : 'unknown';
			$type = isset( $attachment['type'] ) ? sanitize_text_field( $attachment['type'] ) : '';
			$data_url = $attachment['dataUrl'];
			
			error_log( '[AMB] build_attachment_context: processing ' . $name . ' (' . $type . ')' );

			// Check if this is a text-based file we can extract content from.
			// Use precise XML matching (+xml suffix or application/xml) so that
			// Office Open XML types like application/vnd.openxmlformats-* are NOT
			// treated as extractable text (they are binary ZIP archives).
			$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 ) {
				// Extract base64 content from data URL.
				$comma_pos = strpos( $data_url, ',' );
				if ( false !== $comma_pos ) {
					$base64_data = substr( $data_url, $comma_pos + 1 );
					// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding user-uploaded file content from data URL.
					$decoded = base64_decode( $base64_data, true );

					if ( false !== $decoded && ! empty( trim( $decoded ) ) ) {
						// Cap text content to prevent prompt bloat.
						$max_chars = 8000;
						$text = mb_substr( trim( $decoded ), 0, $max_chars );
						if ( mb_strlen( $decoded ) > $max_chars ) {
							$text .= "\n... [truncated]";
						}

						$context_parts[] = "### File: " . $name . " (" . $type . ")\n" . $text;
						error_log( '[AMB] build_attachment_context: extracted text file, ' . strlen( $text ) . ' chars' );
					}
				}
			} else {
				// Non-text file (image, PDF, etc.) — describe it so the AI knows it exists.
				$size_kb = isset( $attachment['size'] ) ? round( absint( $attachment['size'] ) / 1024, 1 ) : 0;
				$context_parts[] = "### File: " . $name . " (" . $type . ", " . $size_kb . "KB) — binary file attached, content not extractable as text.";
				error_log( '[AMB] build_attachment_context: binary file ' . $name . ' (' . $size_kb . 'KB)' );
			}
		}

		if ( empty( $context_parts ) ) {
			error_log( '[AMB] build_attachment_context: no context parts generated' );
			return '';
		}

		$result = implode( "\n\n", $context_parts );
		error_log( '[AMB] build_attachment_context: built ' . strlen( $result ) . ' chars of context' );
		return $result;
	}

	/**
	 * Extract image attachments from the raw attachments array.
	 *
	 * @param array $attachments Raw attachments.
	 * @return array Image attachments with dataUrl.
	 */
	private function get_image_attachments( $attachments ) {
		if ( empty( $attachments ) || ! is_array( $attachments ) ) {
			return array();
		}
		return array_values( array_filter( $attachments, function( $a ) {
			return isset( $a['type'] ) && 0 === strpos( $a['type'], 'image/' )
				&& ! empty( $a['dataUrl'] );
		} ) );
	}

	/**
	 * Build OpenAI image content parts from attachments.
	 *
	 * @param array $images Image attachments.
	 * @return array OpenAI content parts.
	 */
	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'] ) ? $img['dataUrl'] : '';
			if ( empty( $data_url ) || 0 !== strpos( $data_url, 'data:' ) ) {
				continue;
			}
			$parts[] = array(
				'type' => 'image_url',
				'image_url' => array( 'url' => $data_url ),
			);
			$count++;
		}
		return $parts;
	}

	/**
	 * Build Gemini inline data parts from attachments.
	 *
	 * @param array $images Image attachments.
	 * @return array Gemini inline data parts.
	 */
	private function build_gemini_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'] ) ? $img['dataUrl'] : '';
			if ( empty( $data_url ) ) {
				continue;
			}
			$comma_pos = strpos( $data_url, ',' );
			if ( false === $comma_pos ) {
				continue;
			}
			// data URL format: data:<mime>[;base64],<data>
			// Extract the full media-type segment then strip the ";base64" encoding suffix.
			$prefix     = substr( $data_url, 5, $comma_pos - 5 ); // e.g. "image/png;base64"
			$semicolon  = strpos( $prefix, ';' );
			$mime       = ( false !== $semicolon ) ? substr( $prefix, 0, $semicolon ) : $prefix; // "image/png"
			if ( 0 !== strpos( $mime, 'image/' ) ) {
				continue;
			}
			$raw_base64 = substr( $data_url, $comma_pos + 1 );
			$parts[] = array(
				'inline_data' => array(
					'mime_type' => $mime,
					'data'      => $raw_base64,
				),
			);
			$count++;
		}
		return $parts;
	}

	/**
	 * Build the system prompt for the chat.
	 *
	 * @param string $current_html Existing page HTML (if any).
	 * @param string $current_css  Existing page CSS (if any).
	 * @param string $page_title   Current page title.
	 * @param string $selected     Selected section HTML (if any).
	 * @param array  $page_settings Dynamic page settings (colors, style, etc).
	 * @param string $knowledge_base_context Knowledge base context.
	 * @param string $attachment_context     Attachment file context.
	 * @param string $plan_context           Approved plan context (Phase 1).
	 * @param string $pass_context           Current-pass directive (Phase 2).
	 * @return string System prompt.
	 */
	private function build_system_prompt( $current_html, $current_css, $page_title, $selected, $page_settings = array(), $knowledge_base_context = '', $attachment_context = '', $plan_context = '', $pass_context = '', $pass_mode = '', $entity_type = 'page', $template_kind = '' ) {
		// Minify to reduce token count but send the FULL code — no truncation.
		$current_css   = $this->minify_content( $current_css );
		$current_html  = $this->minify_content( $current_html );
		$selected      = $this->minify_content( $selected );

		$is_section_edit = ! empty( $selected );
		$is_section_append = ( 'section' === $pass_mode );

		$prompt  = "You are the AI Assistant for Antimanual Builder (WordPress).\n";

		// MANDATORY image-URL rule. Stated early and forcefully because
		// models default to via.placeholder.com / dummyimage.com / picsum
		// from training-data muscle memory unless told otherwise. We ALSO
		// rewrite non-compliant URLs server-side as a safety net — but
		// the prompt rule keeps the model honest in the first place so
		// downstream consumers (operation_summary, assistant_message,
		// chat-history) show the correct URL too.
		$prompt .= "\n## MANDATORY: PLACEHOLDER IMAGE URLS (zero exceptions)\n";
		$prompt .= "Every `<img src>`, `srcset`, CSS `background-image: url(...)`, or `data-src` value that points to a placeholder image MUST use https://placehold.co — and ONLY https://placehold.co. Other placeholder hosts produce broken images on this site because the renderer does not allowlist them.\n";
		$prompt .= "URL pattern: `https://placehold.co/{W}x{H}` for plain grey. Add color + label like `https://placehold.co/{W}x{H}/{bgHex}/{fgHex}?text=Label+Here` (hex WITHOUT #, spaces in text encoded as `+`).\n";
		$prompt .= "Worked examples (copy these shapes verbatim, substituting sizes/colors as needed):\n";
		$prompt .= "  - Hero shot: https://placehold.co/1440x720\n";
		$prompt .= "  - Feature card: https://placehold.co/600x400\n";
		$prompt .= "  - Testimonial avatar: https://placehold.co/96x96\n";
		$prompt .= "  - Logo with label: https://placehold.co/200x60/f8fafc/64748b?text=Acme+Co\n";
		$prompt .= "FORBIDDEN HOSTS (will be rejected / silently rewritten — using them is wasted output):\n";
		$prompt .= "  via.placeholder.com, dummyimage.com, picsum.photos, source.unsplash.com, unsplash.com, loremflickr.com, placekitten.com, placeimg.com, fakeimg.pl, placehold.it, placehold.jp, lorempixel.com, fillmurray.com, baconmockup.com, and ANY other third-party image host.\n";
		$prompt .= "If you need a styled placeholder, encode the styling INTO the placehold.co URL — never reach for via.placeholder.com or dummyimage.com out of habit.\n\n";

		if ( ! $is_section_append && ! $is_section_edit ) {
			$prompt .= "## SEO & Accessibility (MANDATORY on full-page generation)\n";
			$prompt .= "Include in <head>: <title> (derived from page intent or business name), <meta name=\"description\" content=\"140-160 char summary\">, <meta property=\"og:title\">, <meta property=\"og:description\">, <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">.\n";
			$prompt .= "Use semantic landmarks in <body>: <header> wrapping site nav, <main> wrapping all content sections, <footer> wrapping the footer section.\n";
			$prompt .= "Every <img> must have a meaningful alt attribute. Every form <input> must have an associated <label>. Color contrast must meet WCAG AA (4.5:1 for normal text, 3:1 for large text).\n\n";
		}

		if ( $is_section_append ) {
			// Per-task incremental build: emit ONLY a new section snippet.
			// The frontend splices it into <main>; prior sections and the
			// foundation shell are preserved on the client side, so the AI
			// must not re-emit them (doing so caused <main> duplication and
			// whole-page regeneration on each task).
			$prompt .= "The user is running a multi-task plan execution and this call is a SECTION-APPEND PASS. You MUST return ONLY a single new section snippet — never a full HTML document.\n";
			$prompt .= "Output format (strict):\n";
			$prompt .= "- A single markdown fenced block starting with ```html and ending with ```\n";
			$prompt .= "- Inside the block: optionally a `<style>` block with CSS scoped to this section's classes, then exactly ONE `<section data-amb-section=\"<kebab-id>\">…</section>` element, then optionally a `<script>` block for that section's interactivity.\n";
			$prompt .= "- FORBIDDEN in this pass: <!doctype>, <html>, <head>, <meta>, <link>, <title>, <body>, <main>, <nav>, <footer> (unless the task's scope is literally a nav/footer section — then ONE matching top-level element only, no outer <body>/<main>).\n";
			$prompt .= "- FORBIDDEN in this pass: re-emitting any previously-built section, re-emitting :root CSS variables, re-emitting global typography/reset/utility classes, re-emitting font <link>s.\n";
			$prompt .= "- Reuse existing CSS variables (e.g. `var(--brand-primary)`) and foundation utility classes — they are already defined in the page.\n";
			$prompt .= "- Use a meaningful `data-amb-section` id derived from the task (hero, features, pricing, testimonials, cta, footer, …) and descriptive class names on every child element.\n";
			$prompt .= "- If the task scope is global/style/script (not a visible section), return ONLY the `<style>` and/or `<script>` block(s), no `<section>` element.\n\n";

			$prompt .= "## CORRECT vs WRONG output (study before responding)\n\n";
			$prompt .= "### CORRECT — emit EXACTLY this shape:\n";
			$prompt .= "```html\n";
			$prompt .= "<style>\n";
			$prompt .= ".features-section{padding:6rem 1.25rem;background:var(--surface-2)}\n";
			$prompt .= ".features-section .container{max-width:1200px;margin:0 auto}\n";
			$prompt .= ".features-section .features-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:2rem}\n";
			$prompt .= ".features-section .features-heading{font-size:2.5rem}\n";
			$prompt .= "</style>\n";
			$prompt .= "<section data-amb-section=\"features\" class=\"features-section\">\n";
			$prompt .= "  <div class=\"container\">\n";
			$prompt .= "    <h2 class=\"features-heading\">Why teams pick us</h2>\n";
			$prompt .= "    <div class=\"features-grid\">…</div>\n";
			$prompt .= "  </div>\n";
			$prompt .= "</section>\n";
			$prompt .= "```\n\n";
			$prompt .= "### WRONG — DO NOT do any of these:\n";
			$prompt .= "1. Emit a full document — `<!DOCTYPE html><html><head>…</head><body><main><section …>…</section></main></body></html>` will corrupt the page when spliced.\n";
			$prompt .= "2. Re-include sections from prior tasks — your output is APPENDED to existing markup; if you re-emit the hero, the page will end up with TWO heroes (or worse, the prior sections get nested inside this output and disappear from view).\n";
			$prompt .= "3. Wrap your section in `<body>` or `<main>` — those wrappers make the splicer treat your output as a full document.\n";
			$prompt .= "4. Include `:root { --foo: … }` declarations — variables already exist; redefining them overrides the foundation typography/colors and breaks visual continuity.\n";
			$prompt .= "5. Reset global typography or utility classes (e.g. `body { font-family: … }`, `*, *::before { box-sizing: border-box }`) — these are already declared by the foundation pass.\n\n";
			$prompt .= "### MENTAL MODEL\n";
			$prompt .= "The page is a vertical stack inside <main>. Each task adds ONE new entry to that stack. Your output for this task will be inserted before the closing </main> tag of the existing page. So your output must be a SNIPPET that survives that insertion — nothing that requires its own document boundaries.\n\n";
		} elseif ( $is_section_edit ) {
			// Detect a brand-new placeholder section (class "ambld-new-section" /
			// id "ambld-new-XXXX") so the AI knows to rename both based on the
			// user's prompt instead of preserving the placeholder identifiers.
			$is_placeholder_section = false !== strpos( $selected, 'ambld-new-section' ) || false !== strpos( $selected, 'data-amb-section="ambld-new-' );

			// --- Section-scoped editing mode ---
			$prompt .= "The user is editing ONE SPECIFIC SECTION of their page. You MUST return ONLY that section's HTML inside a single markdown code block starting with ```html and ending with ```.\n";
			$prompt .= "CRITICAL RULES for section editing:\n";
			$prompt .= "- Output ONLY the `<section data-amb-section=\"...\">...</section>` tag and its children\n";
			$prompt .= "- Include a `<style>` block at the top with ONLY the CSS rules for this section\n";
			$prompt .= "- Do NOT output the entire page — only the single section being edited\n";

			if ( $is_placeholder_section ) {
				$prompt .= "- This is a NEW placeholder section. Replace the `class=\"ambld-new-section\"` with a descriptive kebab-case class that reflects the section's purpose based on the user's prompt (e.g. `pricing-section`, `testimonials-section`, `cta-section`).\n";
				$prompt .= "- Replace the `data-amb-section=\"ambld-new-XXXX\"` value with a short meaningful kebab-case id derived from the prompt (e.g. `data-amb-section=\"pricing\"`, `data-amb-section=\"testimonials\"`).\n";
			} else {
				$prompt .= "- Preserve the `data-amb-section` attribute value on the section tag (do not rename it).\n";
			}

			$prompt .= "- Use class-based styling (no inline styles)\n\n";
		} else {
			$prompt .= "You must answer the user's request conversationally. If the user asks to generate a new page, build something new, or modify/refine the current page, you MUST output the complete raw HTML code inside a single markdown code block starting with ```html and ending with ```.\n";
			$prompt .= "Rules: All CSS MUST be in a <style> block at the top of the HTML. Use class-based styling (no inline styles). Semantic HTML5 + data-amb-section attributes on each major section.\n\n";
		}

		// --- Mandatory class-naming rules (apply to both modes) ---
		$prompt .= "## CSS Class Naming (MANDATORY)\n";
		$prompt .= "Every element you generate MUST have a descriptive, semantic CSS class. Never leave elements without classes.\n";
		$prompt .= "Rules:\n";
		$prompt .= "- Every `<section>` MUST have a class describing its purpose (e.g. `hero-section`, `features-section`, `pricing-section`, `cta-section`, `testimonials-section`)\n";
		$prompt .= "- **Container rule (MANDATORY, no exceptions):** the FIRST child element of every `<section data-amb-section=\"...\">` MUST be `<div class=\"container\">`. The class is literally the word `container` — never prefix it (NOT `.hero-container`, NOT `.feature-container`, NOT `.cta-container`). All section-specific wrappers go INSIDE that container with descriptive names. CORRECT: `<section data-amb-section=\"features\" class=\"features-section\"><div class=\"container\"><div class=\"features-grid\">…</div></div></section>`. WRONG: `<section data-amb-section=\"features\" class=\"features-section\"><div class=\"features-container\">…</div></section>`.\n";
		$prompt .= "- Every wrapper/content `<div>` (other than the section's `.container`) MUST have a descriptive class (e.g. `hero-content`, `features-grid`, `pricing-cards`, `footer-links`)\n";
		$prompt .= "- Every heading (`<h1>`–`<h6>`) MUST have a class (e.g. `hero-title`, `section-title`, `card-title`, `feature-heading`)\n";
		$prompt .= "- Every paragraph MUST have a class (e.g. `hero-description`, `feature-text`, `card-description`, `section-subtitle`)\n";
		$prompt .= "- Every button and link MUST have a class (e.g. `cta-button`, `nav-link`, `card-link`, `hero-btn`, `secondary-btn`)\n";
		$prompt .= "- Every image MUST have a class (e.g. `hero-image`, `feature-icon`, `team-photo`, `logo-img`)\n";
		$prompt .= "- Every list (`<ul>`, `<ol>`) and list item (`<li>`) MUST have a class (e.g. `feature-list`, `feature-item`, `nav-list`, `footer-list`)\n";
		$prompt .= "- Every card or repeating component MUST have a class (e.g. `pricing-card`, `team-card`, `testimonial-card`)\n";
		$prompt .= "- Use lowercase kebab-case (e.g. `hero-section`, NOT `HeroSection` or `hero_section`)\n";
		$prompt .= "- Class names MUST be descriptive of the element's purpose/role, NOT generic (avoid `.box`, `.item`, `.text`, `.wrapper` alone — always prefix with context like `feature-box`, `pricing-item`)\n";
		$prompt .= "- Target all styles via classes in the `<style>` block — NEVER use bare tag selectors like `h1 {}`, `p {}`, `a {}`, `section {}`\n\n";

		// --- Section-scoped selector rules (defeats theme/plugin overrides) ---
		$prompt .= "## Section-Scoped Selectors (MANDATORY — this is how the design survives the live theme)\n";
		$prompt .= "On the published page the active WordPress theme and other plugins also load CSS. A bare single-class rule like `.comparison-table { … }` has the SAME specificity as a theme's `.comparison-table` and loses the cascade, so the design breaks. You MUST namespace every rule under its section's root class.\n";
		$prompt .= "The section root class is the class on the `<section data-amb-section=\"…\">` element (e.g. a section `<section data-amb-section=\"comparison\" class=\"comparison-section\">` has root class `.comparison-section`).\n";
		$prompt .= "Rules:\n";
		$prompt .= "- The section root rule itself stays one class: `.comparison-section { … }`.\n";
		$prompt .= "- EVERY other rule for an element inside that section MUST be prefixed with the section root class as a descendant combinator:\n";
		$prompt .= "    CORRECT: `.comparison-section .comparison-table { … }`, `.comparison-section .container { … }`, `.comparison-section .cta-button:hover { … }`\n";
		$prompt .= "    WRONG:   `.comparison-table { … }`, `.container { … }`, `.cta-button:hover { … }`  ← single-class, the theme overrides these\n";
		$prompt .= "- This is REQUIRED for the mandatory `.container` wrapper too — write `.comparison-section .container { … }`, never bare `.container { … }` (themes almost always define their own `.container`).\n";
		$prompt .= "- Inside `@media` / `@supports` / `@container` queries, keep the scope: `@media (max-width:768px){ .comparison-section .comparison-grid { … } }`.\n";
		$prompt .= "- Do NOT scope `:root { … }` design-token declarations or `@keyframes` / `@font-face` — those stay global by definition.\n";
		$prompt .= "- Why: `.comparison-section .comparison-table` has specificity (0,2,0), which beats a theme's (0,1,0) `.comparison-table`. Namespacing also prevents the design from leaking onto the rest of the site.\n\n";

		// --- JavaScript rules ---
		$prompt .= "## JavaScript — MAXIMUM EFFORT WHEN NEEDED\n";
		$prompt .= "When the section benefits from interactivity, animation, or behavior, INCLUDE a `<script>` block. Do NOT hold back — a section that should be interactive MUST have JS. Sections that warrant JS:\n";
		$prompt .= "- Tabs, accordions, FAQ toggles, carousels, sliders → write the full interaction JS\n";
		$prompt .= "- Scroll-triggered reveal / fade-in (IntersectionObserver) → add JS, pair with CSS transition\n";
		$prompt .= "- Counter animations, number odometers → JS on scroll entry\n";
		$prompt .= "- Parallax, pointer-reactive gradients, magnetic buttons → JS\n";
		$prompt .= "- Sticky nav behavior, shrink-on-scroll → JS\n";
		$prompt .= "- Modals, lightboxes, overlays → JS\n";
		$prompt .= "- Form validation with real-time feedback → JS\n";
		$prompt .= "- Hamburger / mobile nav toggle → JS\n";
		$prompt .= "If the section is purely informational (static text + images with no interactive element), skip the `<script>` block.\n\n";
		$prompt .= "JS SAFETY RULES (mandatory for every <script> block):\n";
		$prompt .= "- Wrap ALL code in `document.addEventListener('DOMContentLoaded', function() { ... });`\n";
		$prompt .= "- Inside DOMContentLoaded, wrap in try/catch: `try { /* your code */ } catch(e) { console.warn('[AMB]', e); }`\n";
		$prompt .= "- Scope ALL selectors to the section: const sec = document.querySelector('[data-amb-section=\"SECTION_ID\"]'); if (!sec) return;\n";
		$prompt .= "- Use sec.querySelector() / sec.querySelectorAll() for all child elements\n";
		$prompt .= "- Guard every DOM query: if (!el) return;\n";
		$prompt .= "- Vanilla JS only (no jQuery, no external libraries)\n";
		$prompt .= "- No eval(), no document.write()\n\n";

		$prompt .= "## Step Markers (REQUIRED for page generation/refinement)\n";
		$prompt .= "Before the code block, you MUST output a numbered plan like:\n";
		$prompt .= "[STEP: Planning page structure]\n";
		$prompt .= "[STEP: Designing hero section with gradient background]\n";
		$prompt .= "[STEP: Building features grid with icon cards]\n";
		$prompt .= "[STEP: Adding responsive styles and media queries]\n";
		$prompt .= "[STEP: Final polish and accessibility]\n";
		$prompt .= "Then output the code block. Each [STEP: ...] line MUST be specific to the request (not generic). These steps tell the user what you're working on in real-time.\n\n";

		// --- Intent Classification (read user prompts through this lens) ---
		$prompt .= "## 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 (these \"add X to Y\" phrasings are MODIFY because they decorate something that exists).\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 in legacy mode, 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";
		$prompt .= "Important asymmetry: \"add hover effects to the buttons\" is MODIFY intent — they enhance existing elements. Only treat ADD as new-section creation when the prompt names a section/component-level noun.\n\n";

		// --- Page Settings (user-configured preferences from the sidebar) ---
		$this->append_page_settings_prompt( $prompt, $page_settings );

		// --- Entity context (page vs reusable header/footer template) ---
		// Tells the model what artifact it's authoring so it scopes output
		// correctly: a template is a sitewide fragment, not a standalone page.
		$this->append_entity_context_prompt( $prompt, $entity_type, $template_kind );

		// --- Template constraints (critical rule when 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 . "\n";
		}

		// --- STABLE ZONE ABOVE / DYNAMIC ZONE BELOW ---
		// Everything up to this divider is byte-identical across the tasks
		// of a single multi-task build (same pass_mode, same page settings,
		// same naming/JS/step rules, same approved brief). That lets the
		// provider's implicit prefix cache hit on ~60–70% of the prompt on
		// tasks 2–N. KEEP THIS ORDER: per-task data goes below the divider.
		//
		// Approved plan directive belongs to the STABLE zone because it is
		// identical for every task of a single build (it is set once at
		// plan-approval time and reused for every pass).
		if ( ! empty( $plan_context ) ) {
			$prompt .= "\n## Approved Editorial Brief (MUST FOLLOW)\n";
			$prompt .= "The user has reviewed and approved the following plan. Treat this as the binding brief — the design direction, task order, and assumptions below are authoritative. Do not second-guess the direction; execute it with craft.\n\n";
			$prompt .= "EXECUTION MODEL: this page is built INCREMENTALLY, one task per API call. The task list below is a roadmap, NOT a to-do for this single pass. In any given call you execute EXACTLY ONE task — identified by the 'Current Pass' block further down. Do not implement tasks that are not the current pass. Preserve work from previous passes verbatim. Leave upcoming tasks untouched.\n\n";
			$prompt .= $plan_context . "\n";
		}

		$prompt .= "\n/* -- dynamic per-task context below (changes each call; everything above is stable and cacheable) -- */\n";

		// --- DYNAMIC ZONE ---
		// Knowledge Base — query-dependent, may vary per task.
		if ( ! empty( $knowledge_base_context ) ) {
			$prompt .= "\n## Attached Knowledge Base Context\n";
			$prompt .= $knowledge_base_context . "\n";
			$prompt .= "Use this context when it is relevant to the user's request. Prefer it over making up brand details, product claims, technical specifics, or documentation facts.\n";
		}

		// User-attached files — typically only on the first turn of a build,
		// so technically per-task-variable.
		if ( ! empty( $attachment_context ) ) {
			$prompt .= "\n## User-Attached Files\n";
			$prompt .= "The user has attached the following files. Use their content when relevant to the request:\n\n";
			$prompt .= $attachment_context . "\n";
		}

		// Page State — the biggest dynamic block: outline + CSS registry grow
		// every task as sections are built. Must be in the dynamic zone.
		if ( $is_section_append ) {
			if ( ! empty( $page_title ) || ! empty( $current_html ) || ! empty( $current_css ) ) {
				$prompt .= "\n## Page State (REFERENCE ONLY — already on the canvas, DO NOT re-emit)\n";
				if ( ! empty( $page_title ) ) {
					$prompt .= "Title: " . $page_title . "\n";
				}
				if ( ! empty( $current_html ) ) {
					$prompt .= "Existing sections (outline):\n" . $current_html . "\n";
				}
				if ( ! empty( $current_css ) ) {
					$prompt .= "Existing CSS registry (selectors + variables already defined — reuse, do NOT redefine rules):\n" . $this->minify_content( $current_css ) . "\n";
				}
				$prompt .= "Reminder: your output is a NEW section snippet only. The items above are already in the page and will remain.\n";
			}
		} elseif ( $is_section_edit ) {
			$prompt .= "\n## Full Page HTML (REFERENCE — do NOT modify, do NOT re-emit)\n";
			if ( ! empty( $page_title ) ) {
				$prompt .= "Title: " . $page_title . "\n";
			}
			if ( ! empty( $current_html ) ) {
				$prompt .= $current_html . "\n";
			}
			if ( ! empty( $current_css ) ) {
				$prompt .= "Existing CSS: " . $current_css . "\n";
			}
			$prompt .= "\n## Section Being Edited (THIS is the only thing you modify and return)\n";
			$prompt .= $this->minify_content( $selected ) . "\n";
		} elseif ( ! empty( $current_html ) || ! empty( $current_css ) ) {
			$prompt .= "\n## Current Page Context\n";
			if ( ! empty( $page_title ) ) {
				$prompt .= "Title: " . $page_title . "\n";
			}
			if ( ! empty( $current_css ) ) {
				$prompt .= "CSS: " . $this->minify_content( $current_css ) . "\n";
			}
			if ( ! empty( $current_html ) ) {
				$prompt .= "HTML: " . $this->minify_content( $current_html ) . "\n";
			}
		}

		// Current pass directive — the most dynamic block (new every call).
		if ( ! empty( $pass_context ) ) {
			$prompt .= "\n" . $pass_context . "\n";
		}

		return $prompt;
	}

	/**
	 * Render the approved plan from Phase 1 into a prose directive.
	 *
	 * All string values are passed through wp_strip_all_tags + trimmed
	 * and hard-capped so we never inject unbounded or HTML-laden prose
	 * into the system prompt.
	 *
	 * @param mixed $plan Raw plan payload from the request.
	 * @return string Plan prose, or empty string when plan is absent/invalid.
	 */
	private function build_approved_plan_context( $plan ) {
		if ( empty( $plan ) || ! is_array( $plan ) ) {
			return '';
		}

		$clean = static function ( $value, $cap = 240 ) {
			if ( ! is_string( $value ) ) {
				return '';
			}
			$stripped = trim( wp_strip_all_tags( $value ) );
			if ( '' === $stripped ) {
				return '';
			}
			if ( strlen( $stripped ) > $cap ) {
				$stripped = substr( $stripped, 0, $cap ) . '…';
			}
			return $stripped;
		};

		$out = '';

		$intent = isset( $plan['intent'] ) ? $clean( $plan['intent'], 40 ) : '';
		if ( $intent ) {
			$out .= "Intent: " . $intent . "\n";
		}

		if ( ! empty( $plan['design_direction'] ) && is_array( $plan['design_direction'] ) ) {
			$dd     = $plan['design_direction'];
			$fields = array( 'vibe', 'layout', 'color_story', 'typography', 'imagery', 'interactivity' );
			$out   .= "\nDesign direction:\n";
			foreach ( $fields as $field ) {
				$value = isset( $dd[ $field ] ) ? $clean( $dd[ $field ], 260 ) : '';
				if ( '' !== $value ) {
					$label = str_replace( '_', ' ', $field );
					$out  .= "- " . ucfirst( $label ) . ": " . $value . "\n";
				}
			}
		}

		if ( ! empty( $plan['preset_match'] ) && is_array( $plan['preset_match'] ) ) {
			$preset_name = isset( $plan['preset_match']['name'] ) ? $clean( $plan['preset_match']['name'], 80 ) : '';
			if ( $preset_name ) {
				$out .= "\nPreset reference: " . $preset_name . " (use as a stylistic starting point, then push further).\n";
			}
		}

		if ( ! empty( $plan['tasks'] ) && is_array( $plan['tasks'] ) ) {
			$out .= "\nTask roadmap (the page is built one task per pass — the Current Pass block below tells you which single task to execute NOW):\n";
			$count = 0;
			foreach ( $plan['tasks'] as $task ) {
				if ( ! is_array( $task ) ) {
					continue;
				}
				$label     = isset( $task['label'] ) ? $clean( $task['label'], 160 ) : '';
				$scope     = isset( $task['scope'] ) ? $clean( $task['scope'], 48 ) : '';
				$rationale = isset( $task['rationale'] ) ? $clean( $task['rationale'], 200 ) : '';
				if ( '' === $label ) {
					continue;
				}
				$count++;
				$out .= $count . '. ' . $label;
				if ( '' !== $scope ) {
					$out .= ' [' . $scope . ']';
				}
				if ( '' !== $rationale ) {
					$out .= ' — ' . $rationale;
				}
				$out .= "\n";
				if ( $count >= 20 ) {
					break;
				}
			}
		}

		if ( ! empty( $plan['assumptions'] ) && is_array( $plan['assumptions'] ) ) {
			$out .= "\nAssumptions the plan is built on:\n";
			$count = 0;
			foreach ( $plan['assumptions'] as $assumption ) {
				$line = $clean( $assumption, 200 );
				if ( '' === $line ) {
					continue;
				}
				$out .= '- ' . $line . "\n";
				$count++;
				if ( $count >= 8 ) {
					break;
				}
			}
		}

		return trim( $out );
	}

	/**
	 * Render the current-pass directive from the multi-pass orchestrator.
	 *
	 * @param mixed $pass_directive Raw directive payload from the request.
	 * @return string Directive prose, or empty string when absent/invalid.
	 */
	private function build_pass_directive_context( $pass_directive ) {
		if ( empty( $pass_directive ) || ! is_array( $pass_directive ) ) {
			return '';
		}

		$index     = isset( $pass_directive['index'] ) ? absint( $pass_directive['index'] ) : 0;
		$total     = isset( $pass_directive['total'] ) ? absint( $pass_directive['total'] ) : 0;
		$label     = isset( $pass_directive['label'] ) ? sanitize_text_field( $pass_directive['label'] ) : '';
		$directive = isset( $pass_directive['directive'] ) ? wp_strip_all_tags( (string) $pass_directive['directive'] ) : '';

		$directive = trim( $directive );
		if ( '' === $directive && '' === $label ) {
			return '';
		}

		// Hard cap directive length so a stray payload can't balloon tokens.
		if ( strlen( $directive ) > 2000 ) {
			$directive = substr( $directive, 0, 2000 ) . '…';
		}

		$header = "## Current Pass";
		if ( $index > 0 && $total > 0 ) {
			$header .= sprintf( ' (%d of %d)', $index, $total );
		}
		if ( '' !== $label ) {
			$header .= ': ' . $label;
		}

		$out  = $header . "\n";
		$out .= "This is ONE task of a multi-pass incremental build. Implement ONLY the task described below in this pass. Do NOT implement other tasks from the roadmap — they each get their own pass. Preserve any work from prior passes verbatim (it is in the current page HTML, if provided). The user will see the result of this pass before the next pass runs.\n\n";
		if ( '' !== $directive ) {
			$out .= $directive . "\n";
		}

		return trim( $out );
	}

	/**
	 * Get the latest user-authored message content.
	 *
	 * @param array $messages Conversation messages.
	 * @return string
	 */
	private function get_latest_user_message( $messages ) {
		if ( ! is_array( $messages ) ) {
			return '';
		}

		for ( $index = count( $messages ) - 1; $index >= 0; $index-- ) {
			$message = $messages[ $index ];

			if (
				is_array( $message ) &&
				isset( $message['role'], $message['content'] ) &&
				'user' === $message['role'] &&
				is_string( $message['content'] )
			) {
				return $message['content'];
			}
		}

		return '';
	}

	/**
	 * Append structured page settings instructions to the system prompt.
	 *
	 * Translates the raw page settings (tone, sections, brand colors,
	 * design style, font preset) into clear natural-language instructions
	 * that the AI can act on.
	 *
	 * @param string $prompt        System prompt (passed by reference).
	 * @param array  $page_settings Page settings from the sidebar.
	 * @return void
	 */
	/**
	 * Fill empty page-setting slots with values from the global Design Defaults
	 * option (`ambld_design_defaults`). Per-page values always win — this is a
	 * left-merge that only writes keys the page hasn't set.
	 *
	 * `brandColors` is merged at the inner key level so a page that overrides
	 * only the primary color still inherits the default secondary/accent.
	 */
	private function merge_design_defaults( array $page_settings ) {
		// Three design modes drive how design preferences flow into the prompt:
		// - 'prompt'  : skip per-page + global; AI uses only the user's prompt.
		// - 'default' : drop per-page overrides and use globals only.
		// - 'custom'  : per-page wins; globals fill remaining holes.
		$design_mode = isset( $page_settings['designMode'] ) ? sanitize_key( (string) $page_settings['designMode'] ) : 'default';
		$design_keys = array( 'tone', 'designStyle', 'fontPreset', 'headingFont', 'bodyFont', 'borderRadius', 'spacing', 'brandColors' );

		if ( 'prompt' === $design_mode ) {
			foreach ( $design_keys as $key ) {
				unset( $page_settings[ $key ] );
			}
			return $page_settings;
		}

		if ( 'default' === $design_mode ) {
			foreach ( $design_keys as $key ) {
				unset( $page_settings[ $key ] );
			}
		}

		$defaults = get_option( 'ambld_design_defaults', array() );
		if ( ! is_array( $defaults ) || empty( $defaults ) ) {
			return $page_settings;
		}

		$scalar_keys = array( 'tone', 'designStyle', 'fontPreset', 'headingFont', 'bodyFont', 'borderRadius', 'spacing' );
		foreach ( $scalar_keys as $key ) {
			if ( empty( $page_settings[ $key ] ) && ! empty( $defaults[ $key ] ) ) {
				$page_settings[ $key ] = $defaults[ $key ];
			}
		}

		if ( ! empty( $defaults['brandColors'] ) && is_array( $defaults['brandColors'] ) ) {
			$page_brand    = isset( $page_settings['brandColors'] ) && is_array( $page_settings['brandColors'] )
				? $page_settings['brandColors']
				: array();
			$page_settings['brandColors'] = array_merge( $defaults['brandColors'], $page_brand );
		}

		return $page_settings;
	}

	/**
	 * Append entity/template context to the system prompt.
	 *
	 * Pages get no extra block (default behavior). Header/footer templates
	 * get explicit scoping rules so the model emits a single reusable
	 * fragment — not a whole page — and wires its nav as a list of links
	 * the user can later connect to a WordPress menu.
	 *
	 * @param string $prompt        System prompt (by reference).
	 * @param string $entity_type   'page' | 'component' | 'template'.
	 * @param string $template_kind 'header' | 'footer' | ''.
	 * @return void
	 */
	private function append_entity_context_prompt( &$prompt, $entity_type, $template_kind ) {
		if ( 'template' !== $entity_type ) {
			return;
		}

		if ( 'header' === $template_kind ) {
			$prompt .= "## ARTIFACT: SITEWIDE HEADER TEMPLATE\n";
			$prompt .= "You are building a reusable HEADER template that is injected at the TOP of many pages — NOT a standalone page. Scope your output accordingly:\n";
			$prompt .= "- Output a SINGLE root <header> element (with its nav, logo, and optional CTA). Do NOT emit <main>, page sections, hero blocks, or a footer.\n";
			$prompt .= "- Build the primary navigation as a real list: <nav><ul><li><a href=\"#\">Label</a></li>…</ul></nav>. Use 4–6 sensible sample links (Home, About, Services, Blog, Contact). The user will connect these to a WordPress menu afterwards, so keep the markup a clean, repeatable <li><a> pattern with consistent classes — that is what makes the menu binding preserve your link styles.\n";
			$prompt .= "- Keep it self-contained and responsive (mobile nav toggle is welcome). Assume it sits above arbitrary page content.\n\n";
			return;
		}

		if ( 'footer' === $template_kind ) {
			$prompt .= "## ARTIFACT: SITEWIDE FOOTER TEMPLATE\n";
			$prompt .= "You are building a reusable FOOTER template injected at the BOTTOM of many pages — NOT a standalone page. Scope your output accordingly:\n";
			$prompt .= "- Output a SINGLE root <footer> element. Do NOT emit <main>, page sections, hero blocks, or a header.\n";
			$prompt .= "- Footer link columns should be real lists: <ul><li><a href=\"#\">Label</a></li>…</ul> with consistent classes, so the user can connect any column to a WordPress menu and keep your link styling.\n";
			$prompt .= "- Typical footer content: link columns, contact details, social icons, newsletter, and a copyright bar. Keep it self-contained and responsive.\n\n";
			return;
		}

		// Generic template (no kind) — still a reusable fragment.
		$prompt .= "## ARTIFACT: REUSABLE TEMPLATE\n";
		$prompt .= "You are building a reusable template fragment, not a standalone page. Output a single self-contained block; avoid page-level chrome unless asked.\n\n";
	}

	private function append_page_settings_prompt( &$prompt, $page_settings ) {
		if ( empty( $page_settings ) || ! is_array( $page_settings ) ) {
			return;
		}

		// "From Prompt" mode skips every design preference so the AI works
		// strictly from the user's chat prompt. Default and Custom modes
		// both append the (already-merged) preferences.
		$design_mode = isset( $page_settings['designMode'] ) ? sanitize_key( (string) $page_settings['designMode'] ) : 'default';
		if ( 'prompt' === $design_mode ) {
			return;
		}

		$has_settings = false;

		// Content tone.
		$tone = ! empty( $page_settings['tone'] ) ? sanitize_text_field( $page_settings['tone'] ) : '';
		if ( $tone && 'professional' !== $tone ) {
			if ( ! $has_settings ) {
				$prompt .= "## User Preferences\n";
				$has_settings = true;
			}
			$prompt .= "- **Content Tone:** Write all text in a " . $tone . " tone.\n";
		}


		// Brand colors.
		$brand_colors = ! empty( $page_settings['brandColors'] ) && is_array( $page_settings['brandColors'] )
			? $page_settings['brandColors']
			: array();
		$has_brand_colors = ! empty( $brand_colors['primary'] ) || ! empty( $brand_colors['secondary'] ) || ! empty( $brand_colors['accent'] ) || ! empty( $brand_colors['background'] );
		if ( $has_brand_colors ) {
			if ( ! $has_settings ) {
				$prompt .= "## User Preferences\n";
				$has_settings = true;
			}
			$prompt .= "- **Brand Colors:** Use these colors throughout the design:";
			if ( ! empty( $brand_colors['primary'] ) ) {
				$prompt .= ' Primary=' . sanitize_text_field( $brand_colors['primary'] );
			}
			if ( ! empty( $brand_colors['secondary'] ) ) {
				$prompt .= ' Secondary=' . sanitize_text_field( $brand_colors['secondary'] );
			}
			if ( ! empty( $brand_colors['background'] ) ) {
				$prompt .= ' Background=' . sanitize_text_field( $brand_colors['background'] );
			}
			if ( ! empty( $brand_colors['accent'] ) ) {
				$prompt .= ' Accent=' . sanitize_text_field( $brand_colors['accent'] );
			}
			$prompt .= "\n";
		}

		// Design system preset.
		$design_style = ! empty( $page_settings['designStyle'] ) ? sanitize_text_field( $page_settings['designStyle'] ) : '';
		if ( $design_style ) {
			if ( ! $has_settings ) {
				$prompt .= "## User Preferences\n";
				$has_settings = true;
			}
			$style_descriptions = array(
				'neo-brutalism'     => 'chunky borders, offset shadows, punchy color blocks, oversized type, bold geometry, high contrast',
				'neumorphism'       => 'soft surfaces, subtle depth via inner/outer shadows, rounded cards, muted palette, tactile controls',
				'glassmorphism'     => 'frosted glass panels, layered translucency, background blur, airy gradients, glowing borders',
				'editorial-minimal' => 'structured typography, restrained color, generous whitespace, calm rhythm, serif accents, refined layout',
				'retro'             => 'nostalgic 90s/Y2K aesthetic, pixel-adjacent borders, retro color palettes (purple, orange, teal), blocky decorative text, checkered or star motifs, chunky shadows with warm offset colors',
				'flat-design'       => 'clean flat surfaces with no gradients or shadows, bold solid fills, simple geometric shapes, strong typography, minimal decoration, vibrant but limited color palette',
			);
			$style_desc = isset( $style_descriptions[ $design_style ] ) ? $style_descriptions[ $design_style ] : $design_style;
			$prompt .= "- **Design System:** " . ucfirst( $design_style ) . " — " . $style_desc . ".\n";
		}

		// Border radius / corner style.
		$border_radius = ! empty( $page_settings['borderRadius'] ) ? sanitize_text_field( $page_settings['borderRadius'] ) : '';
		if ( $border_radius ) {
			if ( ! $has_settings ) {
				$prompt .= "## User Preferences\n";
				$has_settings = true;
			}
			$radius_map = array(
				'sharp'   => 'Use sharp corners (0–2px border-radius) on buttons, cards, inputs, and sections',
				'rounded' => 'Use moderate rounded corners (8–12px border-radius) on buttons, cards, inputs, and sections',
				'pill'    => 'Use pill-shaped/fully-rounded corners (24–999px border-radius) on buttons, cards, inputs, and sections',
			);
			$radius_desc = isset( $radius_map[ $border_radius ] ) ? $radius_map[ $border_radius ] : $border_radius;
			$prompt .= "- **Corner Style:** " . $radius_desc . ".\n";
		}

		// Layout density / spacing.
		$spacing = ! empty( $page_settings['spacing'] ) ? sanitize_text_field( $page_settings['spacing'] ) : '';
		if ( $spacing ) {
			if ( ! $has_settings ) {
				$prompt .= "## User Preferences\n";
				$has_settings = true;
			}
			$spacing_map = array(
				'compact'  => 'Use compact spacing — tight padding (12–16px), small margins, dense information layout',
				'balanced' => 'Use balanced spacing — comfortable padding (24–32px), standard margins, clean breathing room',
				'airy'     => 'Use airy/generous spacing — large padding (48–80px), wide margins, luxury whitespace feel',
			);
			$spacing_desc = isset( $spacing_map[ $spacing ] ) ? $spacing_map[ $spacing ] : $spacing;
			$prompt .= "- **Layout Density:** " . $spacing_desc . ".\n";
		}

		// Individual heading/body font overrides (take priority over preset).
		$heading_font = ! empty( $page_settings['headingFont'] ) ? sanitize_text_field( $page_settings['headingFont'] ) : '';
		$body_font    = ! empty( $page_settings['bodyFont'] ) ? sanitize_text_field( $page_settings['bodyFont'] ) : '';

		if ( $heading_font || $body_font ) {
			if ( ! $has_settings ) {
				$prompt .= "## User Preferences\n";
				$has_settings = true;
			}
			$parts = array();
			if ( $heading_font ) {
				$parts[] = $heading_font . ' for headings';
			}
			if ( $body_font ) {
				$parts[] = $body_font . ' for body text';
			}
			$prompt .= "- **Typography:** Use " . implode( ', ', $parts ) . ". Declare these names in `font-family` followed by a system fallback (e.g. `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`). Do NOT @import remote fonts or add <link> tags to font CDNs.\n";
		} elseif ( ! empty( $page_settings['fontPreset'] ) ) {
			// Font pairing preset (fallback when no individual fonts set).
			$font_preset = sanitize_text_field( $page_settings['fontPreset'] );
			if ( ! $has_settings ) {
				$prompt .= "## User Preferences\n";
				$has_settings = true;
			}
			$font_map = array(
				'poppins-inter'     => 'Poppins for headings, Inter for body text',
				'playfair-lato'     => 'Playfair Display for headings, Lato for body text',
				'outfit-dm'         => 'Outfit for headings, DM Sans for body text',
				'space-mono'        => 'Space Mono for headings, Work Sans for body text',
				'raleway-opensans'  => 'Raleway for headings, Open Sans for body text',
			);
			$font_desc = isset( $font_map[ $font_preset ] ) ? $font_map[ $font_preset ] : $font_preset;
			$prompt .= "- **Typography:** Use " . $font_desc . ". Declare these names in `font-family` followed by a system fallback (e.g. `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`). Do NOT @import remote fonts or add <link> tags to font CDNs.\n";
		}

		if ( $has_settings ) {
			$prompt .= "\n";
		}
	}

	/**
	 * Minify content (HTML or CSS) by stripping whitespace and comments.
	 *
	 * @param string $content Raw content.
	 * @return string Minified content.
	 */
	private function minify_content( $content ) {
		if ( empty( $content ) ) {
			return '';
		}

		// Remove comments.
		$content = preg_replace( '/\/\*.*?\*\//s', '', $content );
		$content = preg_replace( '/<!--.*?-->/s', '', $content );

		// Compress whitespace.
		$content = preg_replace( '/\s+/S', ' ', $content );

		return trim( $content );
	}

	/**
	 * Minify and trim large prompt context blocks.
	 *
	 * @param string $content    Raw content.
	 * @param int    $max_length Maximum number of characters to keep.
	 * @return string
	 */
	private function prepare_context_snippet( $content, $max_length ) {
		$content = $this->minify_content( $content );

		if ( empty( $content ) || strlen( $content ) <= $max_length ) {
			return $content;
		}

		return substr( $content, 0, $max_length ) . ' ... [truncated for speed]';
	}

	/**
	 * Extract the section registry from a full HTML blob.
	 *
	 * Walks each top-level block element and collects `data-amb-section`
	 * ids along with the first class on each section (used for fuzzy
	 * matching against user prompts later). Returns an ordered list of
	 * `{ id, classes[], tag }` entries — ordered because later code uses
	 * position to pick the "first" / "last" section.
	 *
	 * @param  string $full_html Raw full-page HTML.
	 * @return array[] Ordered list of section descriptors.
	 */
	private function extract_section_registry( $full_html ) {
		if ( empty( $full_html ) ) {
			return array();
		}

		$out = array();

		// Fast regex pass for <section data-amb-section="...">, <header data-amb-section="...">,
		// and any top-level element carrying data-amb-section. Also grab the
		// class attribute when present. This is intentionally cheap — the
		// outline is small and we don't need DOM fidelity here.
		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 ) {
				$tag      = strtolower( $match[1] );
				$id       = trim( $match[2] );
				$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'      => $id,
					'tag'     => $tag,
					'classes' => $classes,
				);
			}
		}

		return $out;
	}

	/**
	 * Try to deterministically resolve which section the user is editing.
	 *
	 * Priority order:
	 *   1. Explicit canvas selection (the `selectedSection` payload on the
	 *      request) — highest confidence, we know exactly which section.
	 *   2. An id from the section registry appears as a word in the prompt
	 *      (e.g. user says "hero" → matches section id "hero").
	 *   3. A class from the section registry appears in the prompt
	 *      (e.g. user says "pricing cards" → matches class "pricing-cards").
	 *   4. A word in the prompt matches the stem of an id after stripping
	 *      the `-section`, `-block`, `-area` suffix (e.g. "testimonials"
	 *      resolves "testimonials-section").
	 *
	 * If no match OR multiple sections match with equal confidence, return
	 * null — the AI will have to pick via a `get_section` tool call.
	 *
	 * @param  string $prompt            User prompt (already trimmed).
	 * @param  string $selected_section  Selected section HTML, if any.
	 * @param  string $full_html         Full page HTML.
	 * @return string|null Section id, or null when unresolved.
	 */
	private function resolve_edit_target( $prompt, $selected_section, $full_html ) {
		// Case 1 — explicit selection wins.
		if ( ! empty( $selected_section ) ) {
			if ( preg_match( '/data-amb-section\s*=\s*"([^"]+)"/', $selected_section, $m ) ) {
				return trim( $m[1] );
			}
		}

		$registry = $this->extract_section_registry( $full_html );
		if ( empty( $registry ) ) {
			return null;
		}

		$needle = strtolower( $prompt );

		// Tokenize the prompt into word-chunks so we can do word-boundary
		// checks (avoids "herodynamic" matching "hero").
		$tokens = array_values( array_filter( preg_split( '/[^a-z0-9-]+/', $needle ) ) );
		if ( empty( $tokens ) ) {
			return null;
		}
		$token_set = array_flip( $tokens );

		$scores = array();

		foreach ( $registry as $section ) {
			$id      = strtolower( $section['id'] );
			$classes = array_map( 'strtolower', $section['classes'] );
			$score   = 0;

			// Exact id match.
			if ( isset( $token_set[ $id ] ) ) {
				$score += 10;
			}

			// Class-name match.
			foreach ( $classes as $cls ) {
				if ( isset( $token_set[ $cls ] ) ) {
					$score += 6;
					break;
				}
			}

			// Stem match (strip common suffixes).
			$stem = preg_replace( '/-?(section|block|area|band|strip)$/', '', $id );
			if ( $stem !== $id && isset( $token_set[ $stem ] ) ) {
				$score += 8;
			}

			// Hyphenated id present verbatim in the prompt as a phrase
			// ("pricing table" → "pricing-table").
			if ( false !== strpos( $id, '-' ) ) {
				$phrase = str_replace( '-', ' ', $id );
				if ( false !== strpos( $needle, $phrase ) ) {
					$score += 7;
				}
			}

			if ( $score > 0 ) {
				$scores[ $section['id'] ] = $score;
			}
		}

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

		arsort( $scores );
		$ids = array_keys( $scores );
		$top = $ids[0];

		// Ambiguous if the top two are tied.
		if ( count( $ids ) > 1 && $scores[ $ids[0] ] === $scores[ $ids[1] ] ) {
			return null;
		}

		return $top;
	}

	/**
	 * Extract a section plus its scoped CSS + JS from the full page state.
	 *
	 * Used by the tool-calling edit flow: the AI needs to see just the
	 * slice of the page it's about to modify, not the whole document.
	 *
	 * Returns `null` when the section id can't be found — the caller
	 * should surface this as a tool error so the AI can retry with a
	 * valid id from the registry.
	 *
	 * @param  string $section_id ID from `data-amb-section`.
	 * @param  string $full_html  Full-page HTML.
	 * @param  string $full_css   Full customCss blob.
	 * @param  string $full_js    Full customJs blob.
	 * @return array|null `{ html, classes, css, js, anchor }` or null.
	 */
	private function get_section_scoped( $section_id, $full_html, $full_css, $full_js ) {
		if ( empty( $section_id ) || empty( $full_html ) ) {
			return null;
		}

		// Parse to DOM to find the section safely.
		// libxml emits HTML5-unknown-tag warnings — silence them.
		$prev_error_state = libxml_use_internal_errors( true );

		$dom = new \DOMDocument();
		// Wrap in a body so relative-root parsing works for bare snippets.
		$load_ok = $dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_extract_root">' . $full_html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev_error_state );

		if ( ! $load_ok ) {
			return null;
		}

		$xpath  = new \DOMXPath( $dom );
		$target = null;

		// Look up by exact attribute value — DOMXPath handles the quoting.
		$nodes = $xpath->query(
			sprintf( '//*[@data-amb-section="%s"]', addslashes( $section_id ) )
		);
		if ( $nodes && $nodes->length > 0 ) {
			$target = $nodes->item( 0 );
		}

		if ( ! $target ) {
			return null;
		}

		// Serialize just this node.
		$section_html = $dom->saveHTML( $target );
		if ( ! is_string( $section_html ) ) {
			$section_html = '';
		}

		// Collect every class used inside this section (including on the
		// section element itself) so we can pull matching CSS rules.
		$class_set = array();
		if ( $target->hasAttribute( 'class' ) ) {
			foreach ( preg_split( '/\s+/', $target->getAttribute( 'class' ) ) as $c ) {
				$c = trim( $c );
				if ( '' !== $c ) {
					$class_set[ $c ] = true;
				}
			}
		}
		$descendant_nodes = $xpath->query( './/*[@class]', $target );
		if ( $descendant_nodes ) {
			foreach ( $descendant_nodes as $node ) {
				foreach ( preg_split( '/\s+/', $node->getAttribute( 'class' ) ) as $c ) {
					$c = trim( $c );
					if ( '' !== $c ) {
						$class_set[ $c ] = true;
					}
				}
			}
		}

		$classes = array_keys( $class_set );

		$scoped_css = $this->extract_scoped_css( $classes, $section_id, $full_css );
		$scoped_js  = $this->extract_scoped_js( $classes, $section_id, $full_js );

		return array(
			'html'    => trim( $section_html ),
			'classes' => $classes,
			'css'     => $scoped_css,
			'js'      => $scoped_js,
			'anchor'  => $section_id,
		);
	}

	/**
	 * Pull CSS rules from `$full_css` that target any of the given classes
	 * or the section id itself.
	 *
	 * The splitter is regex-based on purpose — PostCSS / Sabberworm would
	 * be heavier than we need, and the page CSS is produced by our own
	 * generator so the shape is predictable.
	 *
	 * Top-level rules are tested by their selector list; at-rules
	 * (@media / @supports / @container / @layer) recurse into their body
	 * and keep any nested rules that match. @keyframes / @font-face are
	 * always kept verbatim because section-scoped code often references
	 * them by name.
	 *
	 * @param  array  $classes    Class names to match against.
	 * @param  string $section_id Section id (for `[data-amb-section=X]` rules).
	 * @param  string $full_css   Full customCss blob.
	 * @return string Scoped CSS, minified.
	 */
	private function extract_scoped_css( $classes, $section_id, $full_css ) {
		if ( empty( $full_css ) ) {
			return '';
		}

		$css = $this->minify_content( $full_css );
		if ( '' === $css ) {
			return '';
		}

		$class_matches = array();
		foreach ( $classes as $c ) {
			$class_matches[ '.' . $c ] = true;
		}
		$section_attr = sprintf( '[data-amb-section="%s"]', $section_id );

		$selector_matches = function ( $selector ) use ( $class_matches, $section_attr, $section_id ) {
			$selector = trim( $selector );
			if ( '' === $selector ) {
				return false;
			}

			// Split selector list on commas (cheap — CSS selectors don't
			// use commas inside functional notations we care about here).
			foreach ( explode( ',', $selector ) as $sel ) {
				$sel = trim( $sel );
				if ( '' === $sel ) {
					continue;
				}

				// Direct attribute selector match on [data-amb-section="id"].
				if ( false !== strpos( $sel, $section_attr ) ) {
					return true;
				}

				// Tokenize selector to find class names (e.g. ".hero-cta",
				// ".hero .card", "section.hero > .cta" all contain classes).
				if ( preg_match_all( '/\.([A-Za-z_][A-Za-z0-9_-]*)/', $sel, $cm ) ) {
					foreach ( $cm[1] as $cls ) {
						if ( isset( $class_matches[ '.' . $cls ] ) ) {
							return true;
						}
					}
				}
			}

			return false;
		};

		$lines = array();
		$i     = 0;
		$len   = strlen( $css );

		$read_balanced_body = function () use ( $css, &$i, $len ) {
			if ( $i >= $len || '{' !== $css[ $i ] ) {
				return '';
			}
			$i++; // consume '{'
			$start = $i;
			$depth = 1;
			while ( $i < $len && $depth > 0 ) {
				$ch = $css[ $i ];
				if ( '{' === $ch ) {
					$depth++;
				} elseif ( '}' === $ch ) {
					$depth--;
				}
				if ( $depth === 0 ) {
					break;
				}
				$i++;
			}
			$body = substr( $css, $start, $i - $start );
			if ( $i < $len && '}' === $css[ $i ] ) {
				$i++; // consume '}'
			}
			return $body;
		};

		$read_selector_or_at = function () use ( $css, &$i, $len ) {
			$start = $i;
			while ( $i < $len ) {
				$ch = $css[ $i ];
				if ( '{' === $ch || ';' === $ch || '}' === $ch ) {
					break;
				}
				$i++;
			}
			return trim( substr( $css, $start, $i - $start ) );
		};

		while ( $i < $len ) {
			// Skip whitespace.
			while ( $i < $len && ctype_space( $css[ $i ] ) ) {
				$i++;
			}
			if ( $i >= $len ) {
				break;
			}
			if ( '}' === $css[ $i ] ) {
				$i++;
				continue;
			}

			$prefix = $read_selector_or_at();
			if ( '' === $prefix ) {
				if ( $i < $len && ';' === $css[ $i ] ) {
					$i++;
				}
				continue;
			}

			if ( '@' === $prefix[0] ) {
				$at_name = '';
				if ( preg_match( '/^@[\w-]+/', $prefix, $an ) ) {
					$at_name = strtolower( $an[0] );
				}

				// Statement at-rules end with ';'.
				if ( $i < $len && ';' === $css[ $i ] ) {
					$i++;
					// Skip @import/@charset/@namespace — they're global.
					continue;
				}

				if ( in_array( $at_name, array( '@media', '@supports', '@container', '@layer' ), true ) ) {
					// Recurse: keep matching inner rules; drop the wrapper if none match.
					$body      = $read_balanced_body();
					$inner     = $this->extract_scoped_css_from_body( $body, $class_matches, $section_attr );
					if ( '' !== $inner ) {
						$lines[] = $prefix . '{' . $inner . '}';
					}
					continue;
				}

				// @keyframes / @font-face — keep verbatim (section JS/CSS may reference).
				$body    = $read_balanced_body();
				$lines[] = $prefix . '{' . $body . '}';
				continue;
			}

			// Normal ruleset.
			$body = $read_balanced_body();
			if ( $selector_matches( $prefix ) ) {
				$lines[] = $prefix . '{' . $body . '}';
			}
		}

		return implode( '', $lines );
	}

	/**
	 * Recursive helper for `extract_scoped_css`: run the selector filter
	 * over the body of a nested at-rule.
	 *
	 * @param  string $body          Raw at-rule body (already minified).
	 * @param  array  $class_matches Map of `.class-name => true`.
	 * @param  string $section_attr  Section attribute selector to match.
	 * @return string Filtered body.
	 */
	private function extract_scoped_css_from_body( $body, $class_matches, $section_attr ) {
		if ( '' === $body ) {
			return '';
		}

		$lines = array();
		$i     = 0;
		$len   = strlen( $body );

		while ( $i < $len ) {
			while ( $i < $len && ctype_space( $body[ $i ] ) ) {
				$i++;
			}
			if ( $i >= $len ) {
				break;
			}

			// Read selector until '{'.
			$start = $i;
			while ( $i < $len && '{' !== $body[ $i ] && ';' !== $body[ $i ] && '}' !== $body[ $i ] ) {
				$i++;
			}
			$selector = trim( substr( $body, $start, $i - $start ) );
			if ( '' === $selector || $i >= $len || '{' !== $body[ $i ] ) {
				if ( $i < $len ) {
					$i++;
				}
				continue;
			}

			// Read balanced body.
			$i++; // consume '{'
			$body_start = $i;
			$depth      = 1;
			while ( $i < $len && $depth > 0 ) {
				$ch = $body[ $i ];
				if ( '{' === $ch ) {
					$depth++;
				} elseif ( '}' === $ch ) {
					$depth--;
				}
				if ( 0 === $depth ) {
					break;
				}
				$i++;
			}
			$rule_body = substr( $body, $body_start, $i - $body_start );
			if ( $i < $len && '}' === $body[ $i ] ) {
				$i++;
			}

			// Selector match.
			$match = false;
			if ( false !== strpos( $selector, $section_attr ) ) {
				$match = true;
			} elseif ( preg_match_all( '/\.([A-Za-z_][A-Za-z0-9_-]*)/', $selector, $cm ) ) {
				foreach ( $cm[1] as $cls ) {
					if ( isset( $class_matches[ '.' . $cls ] ) ) {
						$match = true;
						break;
					}
				}
			}

			if ( $match ) {
				$lines[] = $selector . '{' . $rule_body . '}';
			}
		}

		return implode( '', $lines );
	}

	/**
	 * Pull JS snippets from `$full_js` that appear to reference the given
	 * classes or section id.
	 *
	 * We split on top-level braces — each `document.addEventListener(...)`
	 * handler, each IIFE, each named function block — and keep only the
	 * blocks whose text contains one of our target class names or the
	 * section id. Intentionally approximate; an exact AST parse is
	 * overkill for the few-KB JS blobs we ship.
	 *
	 * @param  array  $classes    Class names from the section.
	 * @param  string $section_id Section id to match `[data-amb-section=X]`.
	 * @param  string $full_js    Full customJs blob.
	 * @return string Scoped JS.
	 */
	private function extract_scoped_js( $classes, $section_id, $full_js ) {
		if ( empty( $full_js ) || empty( $classes ) && empty( $section_id ) ) {
			return '';
		}

		$needles = array();
		foreach ( $classes as $c ) {
			if ( '' !== $c ) {
				$needles[] = $c;
			}
		}
		if ( ! empty( $section_id ) ) {
			$needles[] = $section_id;
		}
		if ( empty( $needles ) ) {
			return '';
		}

		// Find every balanced `{…}` block at depth 0 of the source.
		$out  = array();
		$i    = 0;
		$len  = strlen( $full_js );
		$pending_start = 0;

		while ( $i < $len ) {
			if ( '{' === $full_js[ $i ] ) {
				$block_start = $i;
				$depth       = 1;
				$i++;
				while ( $i < $len && $depth > 0 ) {
					$ch = $full_js[ $i ];
					if ( '{' === $ch ) {
						$depth++;
					} elseif ( '}' === $ch ) {
						$depth--;
					}
					$i++;
				}
				$block = substr( $full_js, $pending_start, $i - $pending_start );
				foreach ( $needles as $needle ) {
					if ( false !== strpos( $block, $needle ) ) {
						$out[] = $block;
						break;
					}
				}
				$pending_start = $i;
				continue;
			}
			$i++;
		}

		return trim( implode( "\n", $out ) );
	}

	/**
	 * Build the canonical tool registry used by the edit flow.
	 *
	 * Each tool is defined once in a provider-neutral shape; provider
	 * translators below serialize it into OpenAI or Gemini form. This
	 * keeps the "what tools exist" concern in one place — adding a new
	 * tool later means editing one function, not four.
	 *
	 * @param  bool $include_get_section Whether to expose `get_section`.
	 *                                   Omitted when the target is already
	 *                                   resolved, so the AI can't waste a
	 *                                   round-trip re-fetching it.
	 * @return array[]
	 */
	private function build_edit_tool_registry( $include_get_section = false, $include_js_files = false ) {
		$tools = array();

		$tools[] = array(
			'name'        => 'patch_css',
			'description' => 'CSS-ONLY refinement of an existing section. The section\'s full current HTML is already provided to you in the prompt — use it as context. Output ONLY CSS changes; the HTML and JS stay untouched. You may change ANY CSS aspect that the request requires (layout, grid/flex, spacing, sizing, typography, color, background, border, radius, shadow, transitions, hover/focus states, animations, filters, backdrop, transform, decorative pseudo-elements, responsive media queries) — not just color/typography. PICK THIS TOOL when the user asks to FIX, IMPROVE, POLISH, TWEAK, ADJUST, REFINE, RESTYLE, or generally make the section look better/cleaner without changing the underlying markup or content. Cheaper than replace_section; prefer when possible.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(
					'section_id' => array(
						'type'        => 'string',
						'description' => 'Optional. The data-amb-section id whose CSS you are patching. Required when you pass `css` (full block).',
					),
					'css'        => array(
						'type'        => 'string',
						'description' => 'Optional. A full CSS block containing one OR multiple rules scoped to this section. Use this when more than one selector needs to change (e.g. ".hero-section { … } .hero-title { … } .hero-cta:hover { … }"). When provided, completely REPLACES the section\'s existing CSS — include every rule the section needs, not just deltas.',
					),
					'selector'     => array(
						'type'        => 'string',
						'description' => 'Single-selector mode. CSS selector the rule targets (e.g. ".hero-title"). Use either { selector + declarations } OR { section_id + css } — not both.',
					),
					'declarations' => array(
						'type'        => 'string',
						'description' => 'Single-selector mode. CSS declarations without the surrounding braces (e.g. "font-size: 64px; line-height: 1.1;").',
					),
				),
				'required'   => array(),
			),
		);

		$tools[] = array(
			'name'        => 'patch_js',
			'description' => 'Add or replace JavaScript for a single section — JS-ONLY change, HTML and CSS stay untouched. PICK THIS TOOL for: adding interactivity (tabs, accordions, carousels, counters, scroll-reveal, modals, form validation, parallax), fixing a JS bug, animating elements, ANY behavior change. Do NOT use replace_section just to add JS when the HTML is not changing. Pass the COMPLETE JS for the section (not a diff). Do NOT include <script> tags or a DOMContentLoaded wrapper — the builder adds those. ALWAYS scope selectors to the section root.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(
					'section_id' => array(
						'type'        => 'string',
						'description' => 'data-amb-section id of the section to add/update JS for.',
					),
					'js'         => array(
						'type'        => 'string',
						'description' => 'Complete vanilla JS for this section. No <script> tags. No DOMContentLoaded wrapper. Scope ALL selectors: start with `const section = document.querySelector(\'[data-amb-section="SECTION_ID"]\'); if (!section) return;` then use `section.querySelector()` for all children. Wrap logic in try/catch.',
					),
					'summary'    => array(
						'type'        => 'string',
						'description' => 'One sentence describing what was added or fixed.',
					),
				),
				'required'   => array( 'section_id', 'js', 'summary' ),
			),
		);

		$tools[] = array(
			'name'        => 'patch_html',
			'description' => 'Surgically edit a section\'s MARKUP without rewriting it — you send a small list of selector-scoped ops, NOT the whole section. PICK THIS TOOL for: adding/removing classes (incl. animation classes like wow/fadeInUp), setting/removing attributes (data-*, aria-*, loading, id), changing a tag (h1→h2), inserting a small element (divider, badge, button, icon), wrapping/unwrapping elements, or removing an element. STRONGLY PREFER this over replace_section for any change that is NOT a full redesign — it cannot truncate or drop content because it never re-emits the section. For cross-cutting "every section" requests (e.g. animate all sections), call patch_html ONCE PER SECTION. Selectors are scoped INSIDE the section (use ":scope" for the section root). Use replace_section only when the section\'s structure/content is genuinely being redesigned.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(
					'section_id' => array(
						'type'        => 'string',
						'description' => 'data-amb-section id of the section to edit.',
					),
					'ops'        => array(
						'type'        => 'array',
						'description' => 'Ordered list of DOM operations. Each: { "op": <name>, "selector": <css scoped within the section, ":scope" = section root>, ...op-specific fields }. Ops: add_class {value:string|string[]}; remove_class {value}; set_attr {name,value, optional stagger:number → sets an incrementing seconds value per match, e.g. data-wow-delay}; remove_attr {name}; set_text {value}; change_tag {to}; insert {position:"beforebegin"|"afterbegin"|"beforeend"|"afterend", html}; wrap {with:"<div class=\'x\'>"}; unwrap {}; replace {html}; remove {}. Selector supports tag, .class, #id, [attr], [attr="v"], descendant (space) and child (>) combinators, and comma lists.',
						'items'       => array( 'type' => 'object' ),
					),
					'summary'    => array(
						'type'        => 'string',
						'description' => 'One sentence describing the markup change.',
					),
				),
				'required'   => array( 'section_id', 'ops' ),
			),
		);

		$tools[] = array(
			'name'        => 'fix_js',
			'description' => 'FIX a JavaScript BUG in a section. PICK THIS TOOL when the user reports something broken/not working OR when "Captured runtime JS errors" are present in the prompt — those are the REAL errors thrown in the live preview. Read the captured error message + line, locate the offending code in the section JS, and emit the COMPLETE corrected JS (not a diff). Common causes: null reference from an unguarded querySelector, class/selector mismatch between HTML and JS, listener attached before the element exists, undefined variable, double-initialization. Apply the JS SAFETY RULES (scope to the section root, guard every query, wrap in try/catch). Do NOT include <script> tags. Use patch_js (not fix_js) when ADDING new behavior rather than repairing a bug.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(
					'section_id' => array(
						'type'        => 'string',
						'description' => 'data-amb-section id of the section whose JS is broken.',
					),
					'js'         => array(
						'type'        => 'string',
						'description' => 'Complete corrected vanilla JS for this section. No <script> tags. Scope ALL selectors to the section root and guard every DOM query.',
					),
					'diagnosis'  => array(
						'type'        => 'string',
						'description' => 'One sentence: what the bug was (root cause) and how this fixes it. Reference the captured error if one was provided.',
					),
					'summary'    => array(
						'type'        => 'string',
						'description' => 'One short sentence describing the fix (shown to the user).',
					),
				),
				'required'   => array( 'section_id', 'js', 'summary' ),
			),
		);

		$tools[] = array(
			'name'        => 'replace_section',
			'description' => 'Replace the ENTIRE section — full HTML, full CSS, and optionally JS. PICK THIS TOOL when the user asks to CHANGE, REPLACE, REDESIGN, REWRITE, REBUILD, or RECREATE the section, when the markup itself must change (new layout structure, different elements, reordered children, new content blocks added inside), or when the user describes a new direction/concept. Emit dense production HTML. Always provide new_html and new_css together. Include new_js when the section needs interactivity (animations, toggles, scroll effects etc.) — this is the HTML+CSS+JS version; use patch_js when only JS changes. NEVER put a third-party library in new_html/new_js (no <script src>, <link rel="stylesheet">, @import, or pasted library source) — those are stripped and won\'t load; attach libraries with the attach_cdn tool first, then reference their globals here. For a small markup tweak (adding a class/attribute, changing a tag, inserting one element) do NOT use this tool — use patch_html, which won\'t risk truncating the section.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(
					'section_id' => array(
						'type'        => 'string',
						'description' => 'data-amb-section id of the section being replaced.',
					),
					'new_html'   => array(
						'type'        => 'string',
						'description' => 'The full new <section data-amb-section="…">…</section> element including the outer tag.',
					),
					'new_css'    => array(
						'type'        => 'string',
						'description' => 'Optional. Replacement CSS rules for this section only. Do NOT redefine :root variables or global utility classes. Omit if no CSS changes are needed.',
					),
					'new_js'     => array(
						'type'        => 'string',
						'description' => 'Optional. JS for this section. No <script> tags. Scope all selectors to the section root. Wrap logic in try/catch. Omit if no interactivity is needed.',
					),
				),
				'required'   => array( 'section_id', 'new_html' ),
			),
		);

		$tools[] = array(
			'name'        => 'append_section',
			'description' => 'Add a new section to the page. Place it after the given section id, or at the end of <main> if after_id is omitted.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(
					'after_id' => array(
						'type'        => 'string',
						'description' => 'Optional. data-amb-section id after which to insert. Omit to append at the end.',
					),
					'new_html' => array(
						'type'        => 'string',
						'description' => 'The new <section data-amb-section="…">…</section> element. Use a kebab-case id that is unique on the page.',
					),
					'new_css'  => array(
						'type'        => 'string',
						'description' => 'Optional. CSS rules for the new section.',
					),
					'new_js'   => array(
						'type'        => 'string',
						'description' => 'Optional. JS for the new section.',
					),
				),
				'required'   => array( 'new_html' ),
			),
		);

		$tools[] = array(
			'name'        => 'delete_section',
			'description' => 'Remove a section from the page by its data-amb-section id.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(
					'section_id' => array(
						'type'        => 'string',
						'description' => 'data-amb-section id to remove.',
					),
				),
				'required'   => array( 'section_id' ),
			),
		);

		if ( $include_get_section ) {
			$tools[] = array(
				'name'        => 'get_section',
				'description' => 'Fetch the current HTML/CSS/JS of a section by its data-amb-section id. Use this ONLY when you need to read existing code before deciding how to edit it. After the tool returns, call one of the edit tools (patch_css, replace_section, …).',
				'parameters'  => array(
					'type'       => 'object',
					'properties' => array(
						'section_id' => array(
							'type'        => 'string',
							'description' => 'data-amb-section id to inspect.',
						),
					),
					'required'   => array( 'section_id' ),
				),
			);

			$tools[] = array(
				'name'        => 'search_section',
				'description' => 'Find a section by name or keyword and return its full HTML, CSS, and JS code. Use this before replace_section when the user\'s prompt names a section (e.g. "improve the hero section") but no section is pre-selected. Prefer this over get_section when you only have a keyword, not an exact id.',
				'parameters'  => array(
					'type'       => 'object',
					'properties' => array(
						'keyword' => array(
							'type'        => 'string',
							'description' => 'Section name or keyword to search for (e.g. "hero", "pricing", "navbar"). Matched against data-amb-section ids.',
						),
					),
					'required'   => array( 'keyword' ),
				),
			);
		}

		if ( $include_js_files ) {
			$tools[] = array(
				'name'        => 'edit_js_file',
				'description' => 'Edit one of this imported page\'s own JavaScript files. The page\'s JS lives in separate files (listed in the conversation with their `id`s) — NOT in the section markup. PICK THIS TOOL for any JavaScript/behavior change (fix a bug, add interactivity, change a handler, etc.). Pass the file\'s `id` and the COMPLETE new contents of that file (not a diff). Do NOT use replace_section for JS-only changes, and do NOT re-add <script> tags in HTML. External library files (jQuery, etc.) are read-only and cannot be targeted.',
				'parameters'  => array(
					'type'       => 'object',
					'properties' => array(
						'file_id'     => array(
							'type'        => 'string',
							'description' => 'The `id` of the JS file to edit, exactly as listed in the EDITABLE JAVASCRIPT FILES message.',
						),
						'new_content' => array(
							'type'        => 'string',
							'description' => 'The full new JavaScript source for that file. Replaces the file\'s entire contents.',
						),
					),
					'required'   => array( 'file_id', 'new_content' ),
				),
			);
		}

		$tools[] = array(
			'name'        => 'attach_cdn',
			'description' => 'Attach an external CSS or JS library by CDN URL (e.g. Swiper, GSAP, AOS, Alpine, a font/icon stylesheet). PICK THIS TOOL whenever a request needs a third-party library. Inline <script src> / <link rel="stylesheet"> in HTML are STRIPPED and will NOT load — this is the ONLY way to add an external library. A JS library is added to the page\'s library list (loaded as a <script src>, alongside the page\'s own scripts); a CSS library is added as a <link rel="stylesheet">. After attaching, use patch_js / replace_section / edit_js_file to write the code that USES its globals. CSS loads in <head>; JS defaults to <head> so it is defined before body scripts. Pass the full https URL. Do not re-attach a URL already listed under ATTACHED LIBRARIES.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(
					'url'      => array(
						'type'        => 'string',
						'description' => 'Absolute https:// URL of the .css or .js resource.',
					),
					'type'     => array(
						'type'        => 'string',
						'enum'        => array( 'css', 'js' ),
						'description' => 'css = stylesheet link, js = script. If omitted, inferred from the URL extension.',
					),
					'position' => array(
						'type'        => 'string',
						'enum'        => array( 'head', 'body' ),
						'description' => 'JS only — where to load the script. Default head (loads before body JS).',
					),
				),
				'required'   => array( 'url' ),
			),
		);

		$tools[] = array(
			'name'        => 'set_global_style',
			'description' => 'Update global CSS custom properties (:root variables) that apply site-wide — brand colors, font families, border-radius scale, spacing scale. PICK THIS TOOL when the user asks to change the color scheme, swap all fonts, adjust all button radius, or modify any design token that spans multiple sections. Do NOT use patch_css for :root changes — patch_css is section-scoped and cannot update tokens that other sections depend on.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(
					'root_vars'    => array(
						'type'        => 'string',
						'description' => 'A :root { } block containing only the CSS custom properties to update. Merge into existing :root — do NOT emit a full stylesheet.',
					),
					'global_rules' => array(
						'type'        => 'string',
						'description' => 'Optional. Global selector rules that apply outside any section (e.g. body {}, a {}, .container {}). Appended after the :root block.',
					),
					'summary'      => array(
						'type'        => 'string',
						'description' => 'One sentence describing what changed (e.g. "Updated brand palette to deep blue").',
					),
				),
				'required'   => array( 'summary' ),
			),
		);

		$tools[] = array(
			'name'        => 'move_section',
			'description' => 'Reorder a section within the page by moving it before or after an anchor section. PICK THIS TOOL when the user says "move X up/down", "put testimonials before pricing", "swap X and Y", or anything that reorders existing sections without changing their content.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(
					'section_id' => array(
						'type'        => 'string',
						'description' => 'data-amb-section id of the section to move.',
					),
					'position'   => array(
						'type'        => 'string',
						'enum'        => array( 'before', 'after' ),
						'description' => 'Move the section before or after the anchor.',
					),
					'anchor_id'  => array(
						'type'        => 'string',
						'description' => 'data-amb-section id of the anchor section to move relative to.',
					),
				),
				'required'   => array( 'section_id', 'position', 'anchor_id' ),
			),
		);

		$tools[] = array(
			'name'        => 'get_page_code',
			'description' => 'Return the FULL page HTML, CSS, and JS. Use ONLY when you need to inspect or modify multiple sections at once, for global CSS refactors, or when no specific section is targeted. For single-section work, use get_section or search_section instead — they are faster and return less data.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(),
				'required'   => array(),
			),
		);

		$tools[] = array(
			'name'        => 'ask_clarification',
			'description' => 'Ask the user a single multiple-choice question INSTEAD of editing. Use this RARELY and ONLY when the request is genuinely ambiguous AND guessing wrong would DISCARD or REBUILD the existing design (e.g. "make it a slider" — how many cards per view? · "redesign this" — restyle vs rebuild from scratch?). DO NOT use for cosmetic tweaks, color/spacing/typography changes, or any request where a safe default preserves the existing markup — just do those. When you call this, you make NO edit; the user picks an option and re-sends, so ask only when the answer materially changes which tool you would call or whether existing content survives. Provide 2–4 concrete, mutually-exclusive options.',
			'parameters'  => array(
				'type'       => 'object',
				'properties' => array(
					'question' => array(
						'type'        => 'string',
						'description' => 'The single clarifying question to ask the user. Be specific about the trade-off (e.g. "How many review cards should show at once in the slider?").',
					),
					'options'  => array(
						'type'        => 'array',
						'description' => '2–4 mutually-exclusive choices the user can click.',
						'items'       => array(
							'type'       => 'object',
							'properties' => array(
								'label'       => array(
									'type'        => 'string',
									'description' => 'Short button text (1–5 words).',
								),
								'description' => array(
									'type'        => 'string',
									'description' => 'Optional one-line explanation of what this choice does.',
								),
							),
							'required'   => array( 'label' ),
						),
					),
				),
				'required'   => array( 'question', 'options' ),
			),
		);

		return $tools;
	}

	/**
	 * Serialize the canonical tool registry into OpenAI's `tools` shape.
	 *
	 * @param  array[] $tools Canonical tool list.
	 * @return array[]
	 */
	private function tools_to_openai_schema( $tools ) {
		$out = array();
		foreach ( $tools as $t ) {
			$out[] = array(
				'type'     => 'function',
				'function' => array(
					'name'        => $t['name'],
					'description' => $t['description'],
					'parameters'  => $t['parameters'],
				),
			);
		}
		return $out;
	}

	/**
	 * Serialize the canonical tool registry into Gemini's
	 * `functionDeclarations` shape.
	 *
	 * @param  array[] $tools Canonical tool list.
	 * @return array
	 */
	private function tools_to_gemini_schema( $tools ) {
		$decls = array();
		foreach ( $tools as $t ) {
			$decls[] = array(
				'name'        => $t['name'],
				'description' => $t['description'],
				'parameters'  => $t['parameters'],
			);
		}
		return array(
			array( 'functionDeclarations' => $decls ),
		);
	}

	/**
	 * Ask OpenAI for ONE tool call in edit mode.
	 *
	 * Non-streaming on purpose: tool-call args are tiny, we only need
	 * the final name + JSON, and the UI communicates progress via SSE
	 * status chips rather than token-level streaming. Uses `tool_choice:
	 * required` so the model MUST pick a tool — no free-text output,
	 * no ceremony, no summary message.
	 *
	 * @param  string  $api_key
	 * @param  string  $model
	 * @param  string  $system         System prompt.
	 * @param  array   $messages       Conversation history (compressed).
	 * @param  array[] $tools          Canonical tool registry.
	 * @param  string  $reasoning      'low'|'medium'|'high'|'xhigh' for reasoning models ('minimal' accepted as legacy alias for 'low').
	 * @param  int     $max_tokens
	 * @return array|WP_Error `{ name, args, finish_reason }` or error.
	 */
	private function call_openai_tool_edit( $api_key, $model, $system, $messages, $tools, $reasoning, $max_tokens, $base_url = 'https://api.openai.com/v1', $image_attachments = array() ) {
		$url = rtrim( (string) $base_url, '/' ) . '/chat/completions';

		$api_messages = array( array( 'role' => 'system', 'content' => $system ) );
		$last_user_index = -1;
		foreach ( $messages as $msg ) {
			if ( isset( $msg['role'], $msg['content'] ) ) {
				$api_messages[] = array(
					'role'    => $msg['role'],
					'content' => $msg['content'],
				);
				if ( 'user' === $msg['role'] ) {
					$last_user_index = count( $api_messages ) - 1;
				}
			}
		}

		$image_parts = $this->build_openai_image_parts( $image_attachments );
		if ( ! empty( $image_parts ) && $last_user_index >= 0 ) {
			$original_content = $api_messages[ $last_user_index ]['content'];
			$api_messages[ $last_user_index ]['content'] = array(
				array( 'type' => 'text', 'text' => $original_content ),
			);
			foreach ( $image_parts as $part ) {
				$api_messages[ $last_user_index ]['content'][] = $part;
			}
		}

		$token_key = AMBLD_Antimanual_Ai_Provider::uses_max_completion_tokens( $model )
			? 'max_completion_tokens'
			: 'max_tokens';

		$body = array(
			'model'       => $model,
			'messages'    => $api_messages,
			'tools'       => $this->tools_to_openai_schema( $tools ),
			'tool_choice' => 'required',
			$token_key    => max( 256, (int) $max_tokens ),
		);

		// 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 ) ) {
			$body['temperature'] = 0.4;
		}

		// Only attach reasoning_effort when non-empty — older chat models
		// (gpt-4o/4-turbo) reject the field.
		if ( ! empty( $reasoning ) ) {
			$body['reasoning_effort'] = AMBLD_Antimanual_Ai_Provider::normalize_reasoning_effort( $reasoning );
		}

		$response = wp_remote_post(
			$url,
			array(
				'headers' => array(
					'Content-Type'  => 'application/json',
					'Authorization' => 'Bearer ' . $api_key,
				),
				'body'    => wp_json_encode( $body ),
				'timeout' => 60,
			)
		);

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

		$code = wp_remote_retrieve_response_code( $response );
		$raw  = wp_remote_retrieve_body( $response );
		if ( $code < 200 || $code >= 300 ) {
			return new \WP_Error( 'openai_tool_call_http', 'OpenAI returned HTTP ' . $code . ': ' . substr( $raw, 0, 400 ) );
		}

		$parsed = json_decode( $raw, true );
		if ( ! is_array( $parsed ) ) {
			return new \WP_Error( 'openai_tool_call_decode', 'OpenAI returned non-JSON response.' );
		}

		$choice = isset( $parsed['choices'][0] ) ? $parsed['choices'][0] : null;
		if ( ! is_array( $choice ) ) {
			return new \WP_Error( 'openai_tool_call_no_choice', 'OpenAI returned no choices.' );
		}

		$message       = isset( $choice['message'] ) ? $choice['message'] : array();
		$finish_reason = isset( $choice['finish_reason'] ) ? (string) $choice['finish_reason'] : '';
		$tool_calls    = isset( $message['tool_calls'] ) && is_array( $message['tool_calls'] ) ? $message['tool_calls'] : array();

		if ( empty( $tool_calls ) ) {
			// Model returned free text instead of a tool call — caller decides
			// whether to fall back (probably yes) or retry.
			return array(
				'name'          => '',
				'args'          => array(),
				'text'          => isset( $message['content'] ) ? (string) $message['content'] : '',
				'finish_reason' => $finish_reason,
			);
		}

		$first = $tool_calls[0];
		$name  = isset( $first['function']['name'] ) ? (string) $first['function']['name'] : '';
		$args_raw = isset( $first['function']['arguments'] ) ? (string) $first['function']['arguments'] : '{}';
		$args  = json_decode( $args_raw, true );
		if ( ! is_array( $args ) ) {
			$args = array();
		}

		return array(
			'name'          => $name,
			'args'          => $args,
			'text'          => '',
			'finish_reason' => $finish_reason,
			'tool_call_id'  => isset( $first['id'] ) ? (string) $first['id'] : '',
		);
	}

	/**
	 * Ask Gemini for ONE tool call in edit mode.
	 *
	 * Mirror of call_openai_tool_edit for the Gemini API.
	 * Uses `toolConfig.functionCallingConfig.mode = "ANY"` to force a
	 * function call (Gemini's equivalent of tool_choice:required) and
	 * `thinkingConfig.thinkingBudget` to keep thinking-token cost low
	 * on what is essentially a routing decision.
	 *
	 * @param  string  $api_key
	 * @param  string  $model
	 * @param  string  $system
	 * @param  array   $messages
	 * @param  array[] $tools
	 * @param  int     $thinking_budget 0 to fully disable extended thinking.
	 * @param  int     $max_tokens
	 * @return array|WP_Error
	 */
	private function call_gemini_tool_edit( $api_key, $model, $system, $messages, $tools, $thinking_budget, $max_tokens, $image_attachments = array() ) {
		$url = 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode( $model ) . ':generateContent?key=' . rawurlencode( $api_key );

		$contents = array();
		$last_user_index = -1;
		$last_user_parts = array();
		foreach ( $messages as $msg ) {
			if ( ! isset( $msg['role'], $msg['content'] ) ) {
				continue;
			}
			$role = ( 'assistant' === $msg['role'] ) ? 'model' : 'user';
			$content_parts = array( array( 'text' => (string) $msg['content'] ) );
			if ( 'user' === $msg['role'] ) {
				$last_user_index = count( $contents );
				$last_user_parts = $content_parts;
			}
			$contents[] = array(
				'role'  => $role,
				'parts' => $content_parts,
			);
		}

		$image_parts = $this->build_gemini_image_parts( $image_attachments );
		if ( ! empty( $image_parts ) && $last_user_index >= 0 ) {
			$contents[ $last_user_index ]['parts'] = array_merge( $last_user_parts, $image_parts );
		}

		$body = array(
			'system_instruction' => array(
				'parts' => array( array( 'text' => $system ) ),
			),
			'contents'           => $contents,
			'tools'              => $this->tools_to_gemini_schema( $tools ),
			'tool_config'        => array(
				'function_calling_config' => array(
					'mode' => 'ANY',
				),
			),
			'generationConfig'   => array(
				'maxOutputTokens' => max( 256, (int) $max_tokens ),
				'temperature'     => 0.4,
			),
		);

		if ( $thinking_budget >= 0 ) {
			$body['generationConfig']['thinkingConfig'] = array(
				'thinkingBudget' => (int) $thinking_budget,
			);
		}

		$response = wp_remote_post(
			$url,
			array(
				'headers' => array( 'Content-Type' => 'application/json' ),
				'body'    => wp_json_encode( $body ),
				'timeout' => 60,
			)
		);

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

		$code = wp_remote_retrieve_response_code( $response );
		$raw  = wp_remote_retrieve_body( $response );
		if ( $code < 200 || $code >= 300 ) {
			return new \WP_Error( 'gemini_tool_call_http', 'Gemini returned HTTP ' . $code . ': ' . substr( $raw, 0, 400 ) );
		}

		$parsed = json_decode( $raw, true );
		if ( ! is_array( $parsed ) ) {
			return new \WP_Error( 'gemini_tool_call_decode', 'Gemini returned non-JSON response.' );
		}

		$candidates = isset( $parsed['candidates'] ) && is_array( $parsed['candidates'] ) ? $parsed['candidates'] : array();
		$cand       = isset( $candidates[0] ) ? $candidates[0] : null;
		if ( ! is_array( $cand ) ) {
			return new \WP_Error( 'gemini_tool_call_no_candidate', 'Gemini returned no candidates.' );
		}

		$finish_reason = isset( $cand['finishReason'] ) ? (string) $cand['finishReason'] : '';
		$parts         = isset( $cand['content']['parts'] ) && is_array( $cand['content']['parts'] ) ? $cand['content']['parts'] : array();

		$name = '';
		$args = array();
		$text = '';
		foreach ( $parts as $part ) {
			if ( isset( $part['functionCall']['name'] ) ) {
				$name = (string) $part['functionCall']['name'];
				if ( isset( $part['functionCall']['args'] ) && is_array( $part['functionCall']['args'] ) ) {
					$args = $part['functionCall']['args'];
				}
				break;
			}
			if ( isset( $part['text'] ) ) {
				$text .= (string) $part['text'];
			}
		}

		return array(
			'name'          => $name,
			'args'          => $args,
			'text'          => $text,
			'finish_reason' => $finish_reason,
		);
	}

	/**
	 * Execute a single tool call server-side and return the updated page
	 * state plus a short summary for the SSE timeline.
	 *
	 * Returns either:
	 *   - `array( 'status' => 'ok',    'state' => { html, css, js }, 'summary' => '…' )`
	 *     when the edit was applied — caller should emit `page_refined`.
	 *   - `array( 'status' => 'read',  'result' => { … } )`
	 *     for `get_section` — caller should feed this back as a tool message.
	 *   - `array( 'status' => 'error', 'error'  => '…', 'valid' => [ids] )`
	 *     when the args are invalid — caller can retry or fall back.
	 *
	 * @param  string $tool_name  Canonical tool name.
	 * @param  array  $args       Decoded JSON args.
	 * @param  array  $page_state `{ html, css, js }` current page state.
	 * @return array
	 */
	private function execute_edit_tool( $tool_name, $args, $page_state ) {
		$html    = isset( $page_state['html'] ) ? (string) $page_state['html'] : '';
		$css     = isset( $page_state['css'] )  ? (string) $page_state['css']  : '';
		$js      = isset( $page_state['js'] )   ? (string) $page_state['js']   : '';
		$scripts = isset( $page_state['scripts'] ) && is_array( $page_state['scripts'] ) ? $page_state['scripts'] : array();
		$cdn     = isset( $page_state['cdn'] ) && is_array( $page_state['cdn'] ) ? $page_state['cdn'] : array();

		$registry   = $this->extract_section_registry( $html );
		$valid_ids  = array();
		foreach ( $registry as $sec ) {
			$valid_ids[] = $sec['id'];
		}

		error_log( sprintf(
			'[AMB] execute_edit_tool: dispatching tool="%s" | valid_ids=[%s]',
			$tool_name,
			implode( ', ', $valid_ids )
		) );

		switch ( $tool_name ) {
			case 'patch_css':
				return $this->tool_patch_css( $args, $html, $css, $js );

			case 'replace_section':
				return $this->tool_replace_section( $args, $html, $css, $js, $valid_ids );

			case 'append_section':
				return $this->tool_append_section( $args, $html, $css, $js, $valid_ids );

			case 'delete_section':
				return $this->tool_delete_section( $args, $html, $css, $js, $valid_ids );

			case 'get_section':
				return $this->tool_get_section( $args, $html, $css, $js, $valid_ids );

			case 'search_section':
				return $this->tool_search_section( $args, $html, $css, $js );

			case 'edit_js_file':
				return $this->tool_edit_js_file( $args, $html, $css, $js, $scripts );

			case 'patch_js':
				return $this->tool_patch_js( $args, $html, $css, $js, $valid_ids );

			case 'attach_cdn':
				return $this->tool_attach_cdn( $args, $html, $css, $js, $scripts, $cdn );

			case 'patch_html':
				return $this->tool_patch_html( $args, $html, $css, $js, $valid_ids );

			case 'fix_js':
				return $this->tool_fix_js( $args, $html, $css, $js, $valid_ids );

			case 'ask_clarification':
				return $this->tool_ask_clarification( $args );

			case 'set_global_style':
				return $this->tool_set_global_style( $args, $html, $css, $js );

			case 'move_section':
				return $this->tool_move_section( $args, $html, $css, $js, $valid_ids );

			case 'get_page_code':
				return $this->tool_get_page_code( $html, $css, $js );

			default:
				return array(
					'status' => 'error',
					'error'  => 'Unknown tool: ' . $tool_name,
					'valid'  => array( 'patch_css', 'patch_js', 'patch_html', 'fix_js', 'attach_cdn', 'replace_section', 'append_section', 'delete_section', 'get_section', 'search_section', 'edit_js_file', 'set_global_style', 'move_section', 'get_page_code', 'ask_clarification' ),
				);
		}
	}

	/**
	 * Load the saved external-CDN list for a page (used as fallback when the
	 * request omits the live list).
	 *
	 * @param int $post_id Page id.
	 * @return array
	 */
	private function load_saved_cdn_resources( $post_id ) {
		if ( ! $post_id ) {
			return array();
		}
		$raw = get_post_meta( $post_id, '_ambld_cdn_resources', true );
		if ( empty( $raw ) ) {
			return array();
		}
		$decoded = json_decode( $raw, true );
		return is_array( $decoded ) ? $decoded : array();
	}

	/**
	 * Tool: attach an external library (CSS/JS) by URL.
	 *
	 * External JS libraries are unified into the per-script manifest as
	 * `remote` entries — the single home the Libraries tab and the JS file
	 * editor both read — and streamed to the editor via `scripts_updated`.
	 * External stylesheets go to the page's CDN-resource list and stream via
	 * `cdn_updated`. Both push immediately so the preview re-renders and the
	 * next autosave persists the change. Inline <script>/<link> are stripped,
	 * so this is the only way the AI can add a library.
	 */
	private function tool_attach_cdn( $args, $html, $css, $js, $scripts, $cdn ) {
		$url = isset( $args['url'] ) ? esc_url_raw( trim( (string) $args['url'] ), array( 'http', 'https' ) ) : '';
		if ( '' === $url ) {
			return array(
				'status' => 'error',
				'error'  => 'attach_cdn requires a valid http(s) url.',
			);
		}

		$type = isset( $args['type'] ) ? strtolower( (string) $args['type'] ) : '';
		if ( 'css' !== $type && 'js' !== $type ) {
			// Infer from the path extension; default to js.
			$path = wp_parse_url( $url, PHP_URL_PATH );
			$type = ( is_string( $path ) && preg_match( '/\.css(\?|$)/i', $path ) ) ? 'css' : 'js';
		}

		$position    = ( isset( $args['position'] ) && 'body' === $args['position'] ) ? 'body' : 'head';
		$manifest    = is_array( $scripts ) ? $scripts : array();
		$css_list    = is_array( $cdn ) ? $cdn : array();
		$state_base  = array( 'html' => $html, 'css' => $css, 'js' => $js );

		// JS → manifest `remote` entry (deduped by src).
		if ( 'js' === $type ) {
			foreach ( $manifest as $entry ) {
				if ( is_array( $entry )
					&& isset( $entry['kind'], $entry['src'] )
					&& 'remote' === $entry['kind']
					&& $entry['src'] === $url ) {
					return array(
						'status'  => 'ok',
						'state'   => array_merge( $state_base, array( 'scripts' => $manifest, 'cdn' => $css_list ) ),
						'summary' => sprintf( 'Library already attached (%s)', $url ),
					);
				}
			}

			$manifest[] = array(
				'id'       => 'lib-' . substr( md5( $url ), 0, 8 ),
				'kind'     => 'remote',
				'position' => $position,
				'src'      => $url,
			);

			// Push the updated manifest so the preview updates and the store
			// (and its autosave) carry the change.
			$this->send_sse_event( 'scripts_updated', array( 'scripts' => $manifest ) );

			return array(
				'status'  => 'ok',
				'state'   => array_merge( $state_base, array( 'scripts' => $manifest, 'cdn' => $css_list ) ),
				'summary' => sprintf( 'Attached JS library: %s', $url ),
			);
		}

		// CSS → external-stylesheet list (deduped by type+url).
		foreach ( $css_list as $existing ) {
			if ( is_array( $existing )
				&& isset( $existing['type'], $existing['url'] )
				&& 'css' === $existing['type']
				&& $existing['url'] === $url ) {
				return array(
					'status'  => 'ok',
					'state'   => array_merge( $state_base, array( 'scripts' => $manifest, 'cdn' => $css_list ) ),
					'summary' => sprintf( 'Stylesheet already attached (%s)', $url ),
				);
			}
		}

		$css_list[] = array(
			'id'   => 'cdn-' . substr( md5( 'css|' . $url ), 0, 8 ),
			'type' => 'css',
			'url'  => $url,
		);

		$this->send_sse_event( 'cdn_updated', array( 'cdn' => $css_list ) );

		return array(
			'status'  => 'ok',
			'state'   => array_merge( $state_base, array( 'scripts' => $manifest, 'cdn' => $css_list ) ),
			'summary' => sprintf( 'Attached CSS stylesheet: %s', $url ),
		);
	}

	/**
	 * Tool: apply a CSS patch.
	 *
	 * Upserts a single rule by matching the selector in customCss.
	 * If the selector already has a rule, the old declarations are
	 * replaced; otherwise, a new rule is appended.
	 */
	private function tool_patch_css( $args, $html, $css, $js ) {
		$selector   = isset( $args['selector'] ) ? trim( (string) $args['selector'] ) : '';
		$decls      = isset( $args['declarations'] ) ? trim( (string) $args['declarations'] ) : '';
		$full_css   = isset( $args['css'] ) ? trim( (string) $args['css'] ) : '';
		$section_id = isset( $args['section_id'] ) ? trim( (string) $args['section_id'] ) : '';

		// Multi-rule mode — AI passed a full CSS block scoped to a section.
		// Replace that section's CSS region in place; ignore selector/declarations.
		if ( '' !== $full_css ) {
			if ( '' === $section_id ) {
				return array(
					'status' => 'error',
					'error'  => 'patch_css with `css` requires `section_id` to scope the replacement.',
				);
			}
			$new_css = $this->replace_css_section( $css, $section_id, $full_css );
			return array(
				'status'  => 'ok',
				'state'   => array( 'html' => $html, 'css' => $new_css, 'js' => $js ),
				'summary' => sprintf( 'Patched CSS for section "%s"', $section_id ),
			);
		}

		if ( '' === $selector || '' === $decls ) {
			return array(
				'status' => 'error',
				'error'  => 'patch_css requires either { section_id + css } or { selector + declarations }.',
			);
		}

		// Strip stray braces the model might have included anyway.
		$decls = trim( $decls, "{} \t\n" );

		// Try to find an existing top-level rule with the same selector.
		// Match by selector list — regex-based; matches selector followed by
		// balanced { … }.
		$escaped_selector = preg_quote( $selector, '/' );
		$pattern = '/(^|\s|\})\s*' . $escaped_selector . '\s*\{[^{}]*\}/';
		$replacement = '$1' . $selector . ' { ' . $decls . ' }';

		if ( preg_match( $pattern, $css ) ) {
			$new_css = preg_replace( $pattern, $replacement, $css, 1 );
		} else {
			// Append as new rule.
			$new_css = rtrim( $css ) . "\n" . $selector . ' { ' . $decls . ' }';
		}

		$summary = sprintf( 'Patched CSS for %s', $selector );

		return array(
			'status'  => 'ok',
			'state'   => array( 'html' => $html, 'css' => $new_css, 'js' => $js ),
			'summary' => $summary,
		);
	}

	/**
	 * Tool: add or replace JavaScript for a single section (JS-only change).
	 *
	 * Analogous to tool_patch_css() but operates on the JS blob. HTML and
	 * CSS are passed through untouched. The new JS is merged into the
	 * section's `// [AMB:id]` marker block via replace_js_section().
	 */
	private function tool_patch_js( $args, $html, $css, $js, $valid_ids ) {
		$section_id = isset( $args['section_id'] ) ? trim( (string) $args['section_id'] ) : '';
		$new_js     = isset( $args['js'] )         ? trim( (string) $args['js'] )         : '';
		$summary    = isset( $args['summary'] )    ? trim( (string) $args['summary'] )    : 'Updated section JavaScript';

		if ( '' === $section_id ) {
			return array( 'status' => 'error', 'error' => 'patch_js requires a non-empty section_id.' );
		}
		if ( '' === $new_js ) {
			return array( 'status' => 'error', 'error' => 'patch_js requires non-empty js.' );
		}

		if ( ! in_array( $section_id, $valid_ids, true ) ) {
			$fuzzy = $this->match_section_keyword( strtolower( $section_id ), $valid_ids );
			if ( null !== $fuzzy ) {
				error_log( '[AMB] tool_patch_js: fuzzy-remapped "' . $section_id . '" → "' . $fuzzy . '"' );
				$section_id = $fuzzy;
			} else {
				return array( 'status' => 'error', 'error' => 'Unknown section_id: ' . $section_id, 'valid' => $valid_ids );
			}
		}

		// Strip any <script> tags the AI may have accidentally included.
		$new_js = preg_replace( '/<\/?script\b[^>]*>/i', '', $new_js );

		$new_js_blob = $this->replace_js_section( $js, $section_id, $new_js );
		// Drop any orphaned, unmarked copy of this section's JS (e.g. the
		// original handler from page generation) so the new marker block is
		// the ONLY handler — otherwise both run and the fix never sticks,
		// which sends the model into a re-fix loop.
		$new_js_blob = $this->scrub_unmarked_section_js( $new_js_blob, $section_id, $html );

		return array(
			'status'  => 'ok',
			'state'   => array( 'html' => $html, 'css' => $css, 'js' => $new_js_blob ),
			'summary' => $summary,
		);
	}

	/**
	 * Tool: fix a JavaScript bug in a section.
	 *
	 * Shares the exact JS-merge mechanics as tool_patch_js() (script-tag
	 * stripping + replace_js_section into the section marker block). The
	 * separate tool name routes bug-fix intent — and the AI is told to use
	 * the "Captured runtime JS errors" block in the prompt — while the
	 * optional `diagnosis` arg is logged for transparency.
	 */
	private function tool_fix_js( $args, $html, $css, $js, $valid_ids ) {
		$diagnosis = isset( $args['diagnosis'] ) ? trim( (string) $args['diagnosis'] ) : '';
		if ( '' !== $diagnosis ) {
			error_log( '[AMB] tool_fix_js: diagnosis — ' . $diagnosis );
		}
		// Default the summary to the diagnosis when the AI omitted one.
		if ( empty( $args['summary'] ) && '' !== $diagnosis ) {
			$args['summary'] = $diagnosis;
		}
		return $this->tool_patch_js( $args, $html, $css, $js, $valid_ids );
	}

	/**
	 * Tool: surgical HTML edit of a single section via selector-scoped DOM ops.
	 *
	 * The output is proportional to the CHANGE, not the section size — the
	 * model emits a small op list (selectors + intent + tiny fragments), never
	 * the whole section. This is the HTML analogue of patch_css / patch_js and
	 * the correct tool for class/attribute/structural tweaks (animations,
	 * dividers, wrappers, tag swaps) and for cross-cutting "every section" work
	 * (one patch_html call per section). Use replace_section only for a genuine
	 * structural redesign.
	 *
	 * Supported ops (each scoped within the section; selector ":scope" = the
	 * section root): add_class, remove_class, set_attr (+ optional `stagger`
	 * seconds for incrementing delays), remove_attr, set_text, change_tag,
	 * insert (position beforebegin|afterbegin|beforeend|afterend), wrap,
	 * unwrap, replace, remove.
	 */
	private function tool_patch_html( $args, $html, $css, $js, $valid_ids ) {
		$section_id = isset( $args['section_id'] ) ? trim( (string) $args['section_id'] ) : '';
		$ops        = isset( $args['ops'] ) && is_array( $args['ops'] ) ? $args['ops'] : array();
		$summary    = isset( $args['summary'] ) ? trim( (string) $args['summary'] ) : '';

		if ( '' === $section_id ) {
			return array( 'status' => 'error', 'error' => 'patch_html requires a non-empty section_id.', 'valid' => $valid_ids );
		}
		if ( empty( $ops ) ) {
			return array( 'status' => 'error', 'error' => 'patch_html requires a non-empty `ops` array.' );
		}

		if ( ! in_array( $section_id, $valid_ids, true ) ) {
			$fuzzy = $this->match_section_keyword( strtolower( $section_id ), $valid_ids );
			if ( null !== $fuzzy ) {
				error_log( '[AMB] tool_patch_html: fuzzy-remapped "' . $section_id . '" → "' . $fuzzy . '"' );
				$section_id = $fuzzy;
			} else {
				return array( 'status' => 'error', 'error' => 'Unknown section_id: ' . $section_id, 'valid' => $valid_ids );
			}
		}

		libxml_use_internal_errors( true );
		$dom    = new \DOMDocument();
		$ok     = $dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_html_root">' . $html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( false );
		if ( ! $ok ) {
			return array( 'status' => 'error', 'error' => 'Could not parse page HTML.' );
		}

		$xpath         = new \DOMXPath( $dom );
		$section_nodes = $xpath->query( sprintf( '//*[@data-amb-section="%s"]', addslashes( $section_id ) ) );
		if ( ! $section_nodes || 0 === $section_nodes->length ) {
			return array( 'status' => 'error', 'error' => 'Could not locate section node in the page HTML.', 'valid' => $valid_ids );
		}
		$section_node = $section_nodes->item( 0 );

		$applied = 0;
		$details = array();

		foreach ( $ops as $op_def ) {
			if ( ! is_array( $op_def ) ) {
				continue;
			}
			$op       = isset( $op_def['op'] ) ? strtolower( trim( (string) $op_def['op'] ) ) : '';
			$selector = isset( $op_def['selector'] ) && '' !== trim( (string) $op_def['selector'] )
				? trim( (string) $op_def['selector'] )
				: ':scope';
			if ( '' === $op ) {
				continue;
			}

			$rel_xpath = $this->css_selector_to_xpath( $selector );
			if ( null === $rel_xpath ) {
				$details[] = 'skipped bad selector "' . $selector . '"';
				continue;
			}

			$matched_list = $xpath->query( $rel_xpath, $section_node );
			$matched      = $matched_list ? iterator_to_array( $matched_list ) : array();
			$matched      = array_values( array_filter(
				$matched,
				static function ( $n ) {
					return $n instanceof \DOMElement;
				}
			) );

			if ( empty( $matched ) ) {
				$details[] = sprintf( '%s: 0 matched "%s"', $op, $selector );
				continue;
			}

			$count = 0;
			switch ( $op ) {
				case 'add_class':
					$classes = $this->normalize_class_value( isset( $op_def['value'] ) ? $op_def['value'] : '' );
					foreach ( $matched as $n ) {
						$this->dom_add_classes( $n, $classes );
						$count++;
					}
					break;

				case 'remove_class':
					$classes = $this->normalize_class_value( isset( $op_def['value'] ) ? $op_def['value'] : '' );
					foreach ( $matched as $n ) {
						$this->dom_remove_classes( $n, $classes );
						$count++;
					}
					break;

				case 'set_attr':
					$name = isset( $op_def['name'] ) ? trim( (string) $op_def['name'] ) : '';
					if ( '' === $name ) {
						break;
					}
					$stagger = isset( $op_def['stagger'] ) && is_numeric( $op_def['stagger'] ) ? (float) $op_def['stagger'] : null;
					$value   = isset( $op_def['value'] ) ? (string) $op_def['value'] : '';
					foreach ( $matched as $i => $n ) {
						if ( null !== $stagger ) {
							$delay = rtrim( rtrim( sprintf( '%.3f', $i * $stagger ), '0' ), '.' );
							$n->setAttribute( $name, ( '' === $delay ? '0' : $delay ) . 's' );
						} else {
							$n->setAttribute( $name, $value );
						}
						$count++;
					}
					break;

				case 'remove_attr':
					$name = isset( $op_def['name'] ) ? trim( (string) $op_def['name'] ) : '';
					if ( '' === $name ) {
						break;
					}
					foreach ( $matched as $n ) {
						if ( $n->hasAttribute( $name ) ) {
							$n->removeAttribute( $name );
						}
						$count++;
					}
					break;

				case 'set_text':
					$value = isset( $op_def['value'] ) ? (string) $op_def['value'] : '';
					foreach ( $matched as $n ) {
						$n->textContent = $value;
						$count++;
					}
					break;

				case 'change_tag':
					$to = isset( $op_def['to'] ) ? strtolower( trim( (string) $op_def['to'] ) ) : '';
					if ( '' === $to || ! preg_match( '/^[a-z][a-z0-9]*$/', $to ) ) {
						break;
					}
					foreach ( $matched as $n ) {
						if ( ! $n->parentNode ) {
							continue;
						}
						$new = $dom->createElement( $to );
						foreach ( iterator_to_array( $n->attributes ) as $attr ) {
							$new->setAttribute( $attr->name, $attr->value );
						}
						while ( $n->firstChild ) {
							$new->appendChild( $n->firstChild );
						}
						$n->parentNode->replaceChild( $new, $n );
						$count++;
					}
					break;

				case 'insert':
					$position  = isset( $op_def['position'] ) ? (string) $op_def['position'] : 'beforeend';
					$frag_html = isset( $op_def['html'] ) ? (string) $op_def['html'] : '';
					if ( '' === $frag_html ) {
						break;
					}
					foreach ( $matched as $n ) {
						$imported = $this->dom_import_fragment( $dom, $frag_html );
						$this->dom_insert_nodes( $n, $imported, $position );
						$count++;
					}
					break;

				case 'wrap':
					$wrap_html = isset( $op_def['with'] ) ? (string) $op_def['with'] : '';
					if ( '' === $wrap_html ) {
						break;
					}
					foreach ( $matched as $n ) {
						if ( ! $n->parentNode ) {
							continue;
						}
						$wrappers = $this->dom_import_fragment( $dom, $wrap_html );
						$wrapper  = null;
						foreach ( $wrappers as $w ) {
							if ( $w instanceof \DOMElement ) {
								$wrapper = $w;
								break;
							}
						}
						if ( null === $wrapper ) {
							continue;
						}
						$n->parentNode->replaceChild( $wrapper, $n );
						$wrapper->appendChild( $n );
						$count++;
					}
					break;

				case 'unwrap':
					foreach ( $matched as $n ) {
						$parent = $n->parentNode;
						if ( ! $parent ) {
							continue;
						}
						while ( $n->firstChild ) {
							$parent->insertBefore( $n->firstChild, $n );
						}
						$parent->removeChild( $n );
						$count++;
					}
					break;

				case 'replace':
					$frag_html = isset( $op_def['html'] ) ? (string) $op_def['html'] : '';
					if ( '' === $frag_html ) {
						break;
					}
					foreach ( $matched as $n ) {
						if ( ! $n->parentNode ) {
							continue;
						}
						$imported = $this->dom_import_fragment( $dom, $frag_html );
						foreach ( $imported as $imp ) {
							$n->parentNode->insertBefore( $imp, $n );
						}
						$n->parentNode->removeChild( $n );
						$count++;
					}
					break;

				case 'remove':
					foreach ( $matched as $n ) {
						if ( $n->parentNode ) {
							$n->parentNode->removeChild( $n );
						}
						$count++;
					}
					break;

				default:
					$details[] = 'unknown op "' . $op . '"';
					continue 2;
			}

			$applied  += $count;
			$details[] = sprintf( '%s ×%d', $op, $count );
		}

		if ( 0 === $applied ) {
			return array(
				'status' => 'error',
				'error'  => 'No ops applied — selectors matched nothing. Call get_section to inspect the markup, then retry with valid selectors scoped within the section. Detail: ' . implode( '; ', $details ),
				'valid'  => $valid_ids,
			);
		}

		$root = $dom->getElementById( '__ambld_html_root' );
		if ( ! $root ) {
			// Fallback: locate the wrapper via XPath if getElementById fails.
			$wrap = $xpath->query( '//*[@id="__ambld_html_root"]' );
			$root = ( $wrap && $wrap->length ) ? $wrap->item( 0 ) : null;
		}
		if ( ! $root ) {
			return array( 'status' => 'error', 'error' => 'Failed to serialize edited HTML.' );
		}
		$out = '';
		foreach ( $root->childNodes as $child ) {
			$out .= $dom->saveHTML( $child );
		}

		$out = $this->inject_html_markers_if_missing( $out, $section_id );

		if ( '' === $summary ) {
			$summary = sprintf( 'Edited "%s" markup (%s)', $section_id, implode( ', ', $details ) );
		}

		return array(
			'status'  => 'ok',
			'state'   => array( 'html' => $out, 'css' => $css, 'js' => $js ),
			'summary' => $summary,
		);
	}

	/**
	 * Normalize a class op value (string or array) to a flat array of tokens.
	 */
	private function normalize_class_value( $value ) {
		$tokens = array();
		$parts  = is_array( $value ) ? $value : array( $value );
		foreach ( $parts as $part ) {
			foreach ( preg_split( '/\s+/', trim( (string) $part ) ) as $tok ) {
				if ( '' !== $tok ) {
					$tokens[] = $tok;
				}
			}
		}
		return $tokens;
	}

	/** Add classes to a DOM element (deduped). */
	private function dom_add_classes( $node, $classes ) {
		$set = preg_split( '/\s+/', trim( $node->getAttribute( 'class' ) ), -1, PREG_SPLIT_NO_EMPTY );
		foreach ( $classes as $c ) {
			if ( ! in_array( $c, $set, true ) ) {
				$set[] = $c;
			}
		}
		$node->setAttribute( 'class', implode( ' ', $set ) );
	}

	/** Remove classes from a DOM element. */
	private function dom_remove_classes( $node, $classes ) {
		$set = preg_split( '/\s+/', trim( $node->getAttribute( 'class' ) ), -1, PREG_SPLIT_NO_EMPTY );
		$set = array_values( array_diff( $set, $classes ) );
		if ( empty( $set ) ) {
			$node->removeAttribute( 'class' );
		} else {
			$node->setAttribute( 'class', implode( ' ', $set ) );
		}
	}

	/** Parse an HTML fragment and import its top-level nodes into $dom. */
	private function dom_import_fragment( $dom, $html ) {
		$frag_dom = new \DOMDocument();
		libxml_use_internal_errors( true );
		$frag_dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_op_frag">' . $html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( false );

		$root  = $frag_dom->getElementById( '__ambld_op_frag' );
		$nodes = array();
		if ( $root ) {
			foreach ( iterator_to_array( $root->childNodes ) as $child ) {
				$nodes[] = $dom->importNode( $child, true );
			}
		}
		return $nodes;
	}

	/** Insert imported nodes relative to a reference element. */
	private function dom_insert_nodes( $ref, $nodes, $position ) {
		$parent = $ref->parentNode;
		switch ( $position ) {
			case 'beforebegin':
				foreach ( $nodes as $n ) {
					$parent->insertBefore( $n, $ref );
				}
				break;
			case 'afterend':
				$next = $ref->nextSibling;
				foreach ( $nodes as $n ) {
					if ( $next ) {
						$parent->insertBefore( $n, $next );
					} else {
						$parent->appendChild( $n );
					}
				}
				break;
			case 'afterbegin':
				$first = $ref->firstChild;
				foreach ( $nodes as $n ) {
					if ( $first ) {
						$ref->insertBefore( $n, $first );
					} else {
						$ref->appendChild( $n );
					}
				}
				break;
			case 'beforeend':
			default:
				foreach ( $nodes as $n ) {
					$ref->appendChild( $n );
				}
				break;
		}
	}

	/**
	 * Convert a (subset of) CSS selector to an XPath expression relative to a
	 * context node. Supports: tag, .class, #id, [attr], [attr="val"],
	 * descendant (space) and child (>) combinators, comma lists, and the
	 * special tokens :scope / :root / & (= the context node itself).
	 * Returns null on an unsupported selector.
	 */
	private function css_selector_to_xpath( $selector ) {
		$selector = trim( $selector );
		if ( '' === $selector ) {
			return null;
		}

		// Comma-separated list → union.
		if ( false !== strpos( $selector, ',' ) ) {
			$parts = array();
			foreach ( explode( ',', $selector ) as $sub ) {
				$xp = $this->css_selector_to_xpath( trim( $sub ) );
				if ( null === $xp ) {
					return null;
				}
				$parts[] = $xp;
			}
			return implode( ' | ', $parts );
		}

		if ( in_array( $selector, array( ':scope', ':root', '&', 'self' ), true ) ) {
			return '.';
		}

		$normalized = preg_replace( '/\s*>\s*/', ' > ', $selector );
		$tokens     = preg_split( '/\s+/', trim( $normalized ) );

		$xpath        = '.';
		$expect_child = false;
		foreach ( $tokens as $tok ) {
			if ( '>' === $tok ) {
				$expect_child = true;
				continue;
			}
			$compiled = $this->compile_simple_selector( $tok );
			if ( null === $compiled ) {
				return null;
			}
			$xpath       .= ( $expect_child ? '/' : '//' ) . $compiled;
			$expect_child = false;
		}
		return $xpath;
	}

	/** Compile one simple selector (tag + .class/#id/[attr] parts) to an XPath step. */
	private function compile_simple_selector( $tok ) {
		if ( ! preg_match( '/^([a-zA-Z][\w-]*|\*)?((?:[.#][\w-]+|\[[^\]]+\])*)$/', $tok, $m ) ) {
			return null;
		}
		$tag  = ( isset( $m[1] ) && '' !== $m[1] && '*' !== $m[1] ) ? strtolower( $m[1] ) : '*';
		$rest = isset( $m[2] ) ? $m[2] : '';
		$preds = array();

		if ( '' !== $rest && preg_match_all( '/[.#][\w-]+|\[[^\]]+\]/', $rest, $mm ) ) {
			foreach ( $mm[0] as $part ) {
				$sym = $part[0];
				if ( '.' === $sym ) {
					$name    = substr( $part, 1 );
					$preds[] = 'contains(concat(" ", normalize-space(@class), " "), " ' . $name . ' ")';
				} elseif ( '#' === $sym ) {
					$preds[] = '@id="' . substr( $part, 1 ) . '"';
				} elseif ( '[' === $sym ) {
					$inner = substr( $part, 1, -1 );
					if ( preg_match( '/^([\w:-]+)\s*=\s*"?([^"]*)"?$/', $inner, $am ) ) {
						$preds[] = '@' . $am[1] . '="' . $am[2] . '"';
					} elseif ( preg_match( '/^([\w:-]+)$/', $inner, $am ) ) {
						$preds[] = '@' . $am[1];
					} else {
						return null;
					}
				}
			}
		}

		$step = $tag;
		foreach ( $preds as $p ) {
			$step .= '[' . $p . ']';
		}
		return $step;
	}

	/**
	 * Tool: ask the user a multiple-choice clarifying question.
	 *
	 * Returns a non-edit `clarify` result that handle_tool_edit_flow()
	 * surfaces as a `clarification_needed` SSE event and then ends the turn.
	 * No page state changes.
	 */
	private function tool_ask_clarification( $args ) {
		$question = isset( $args['question'] ) ? trim( (string) $args['question'] ) : '';
		if ( '' === $question ) {
			return array( 'status' => 'error', 'error' => 'ask_clarification requires a non-empty question.' );
		}

		$options = array();
		if ( isset( $args['options'] ) && is_array( $args['options'] ) ) {
			foreach ( $args['options'] as $opt ) {
				if ( is_array( $opt ) ) {
					$label = isset( $opt['label'] ) ? trim( (string) $opt['label'] ) : '';
					$desc  = isset( $opt['description'] ) ? trim( (string) $opt['description'] ) : '';
				} else {
					$label = trim( (string) $opt );
					$desc  = '';
				}
				if ( '' === $label ) {
					continue;
				}
				$options[] = array( 'label' => $label, 'description' => $desc );
			}
		}

		if ( count( $options ) < 2 ) {
			return array( 'status' => 'error', 'error' => 'ask_clarification requires at least 2 options.' );
		}

		return array(
			'status'   => 'clarify',
			'question' => $question,
			'options'  => array_slice( $options, 0, 4 ),
		);
	}

	/**
	 * Tool: replace a section entirely.
	 *
	 * DOM-parses the full HTML, finds the section node by id, swaps in
	 * the new HTML. CSS and JS, if provided, are merged into the
	 * customCss / customJs blobs (append; no cleanup of orphan rules
	 * — that's a v2 nicety).
	 */
	private function tool_replace_section( $args, $html, $css, $js, $valid_ids ) {
		$section_id = isset( $args['section_id'] ) ? trim( (string) $args['section_id'] ) : '';
		$new_html   = isset( $args['new_html'] )   ? trim( (string) $args['new_html'] )   : '';
		$new_css    = isset( $args['new_css'] )    ? trim( (string) $args['new_css'] )    : '';
		$new_js     = isset( $args['new_js'] )     ? trim( (string) $args['new_js'] )     : '';

		if ( '' === $section_id || '' === $new_html ) {
			return array(
				'status' => 'error',
				'error'  => 'replace_section requires non-empty `section_id` and `new_html`.',
				'valid'  => $valid_ids,
			);
		}

		if ( ! in_array( $section_id, $valid_ids, true ) ) {
			// Try fuzzy match before rejecting — AI often drops the "-section"
			// suffix (calls "hero" instead of "hero-section").
			$fuzzy = $this->match_section_keyword( strtolower( $section_id ), $valid_ids );
			if ( null !== $fuzzy ) {
				error_log( '[AMB] tool_replace_section: fuzzy-remapped section_id "' . $section_id . '" → "' . $fuzzy . '"' );
				$section_id = $fuzzy;
			} else {
				return array(
					'status' => 'error',
					'error'  => 'Unknown section_id: ' . $section_id,
					'valid'  => $valid_ids,
				);
			}
		}

		// Strip DOCTYPE/html/head/body wrappers the AI sometimes emits.
		$new_html = $this->strip_html_doc_wrapper( $new_html );

		// Strip any markers the AI may have embedded in new_html.
		$new_html = $this->strip_html_markers( $new_html, $section_id );

		$merged_html = $this->splice_section_in_html( $html, $section_id, $new_html );
		if ( null === $merged_html ) {
			return array(
				'status' => 'error',
				'error'  => 'Could not locate section node in the page HTML.',
				'valid'  => $valid_ids,
			);
		}

		// DOMDocument preserves existing comment markers through the splice.
		// If no markers existed yet, inject them now.
		$merged_html = $this->inject_html_markers_if_missing( $merged_html, $section_id );

		$merged_css = ( '' !== $new_css ) ? $this->replace_css_section( $css, $section_id, $new_css ) : $css;
		$merged_js  = ( '' !== $new_js )  ? $this->replace_js_section( $js,  $section_id, $new_js )  : $js;
		// Same orphan-JS cleanup as patch_js: when this section's JS is
		// rewritten, drop any unmarked duplicate left over from generation so
		// only the marker block runs. Uses the post-splice HTML for class
		// attribution (the section keeps its id; classes may have changed).
		if ( '' !== $new_js ) {
			$merged_js = $this->scrub_unmarked_section_js( $merged_js, $section_id, $merged_html );
		}

		return array(
			'status'  => 'ok',
			'state'   => array( 'html' => $merged_html, 'css' => $merged_css, 'js' => $merged_js ),
			'summary' => sprintf( 'Replaced section "%s"', $section_id ),
		);
	}

	/**
	 * Tool: append a new section.
	 */
	private function tool_append_section( $args, $html, $css, $js, $valid_ids ) {
		$after_id = isset( $args['after_id'] ) ? trim( (string) $args['after_id'] ) : '';
		$new_html = isset( $args['new_html'] ) ? trim( (string) $args['new_html'] ) : '';
		$new_css  = isset( $args['new_css'] )  ? trim( (string) $args['new_css'] )  : '';
		$new_js   = isset( $args['new_js'] )   ? trim( (string) $args['new_js'] )   : '';

		if ( '' === $new_html ) {
			return array(
				'status' => 'error',
				'error'  => 'append_section requires non-empty `new_html`.',
			);
		}

		// Strip DOCTYPE/html/head/body wrappers the AI sometimes emits,
		// then dedup against existing section IDs.
		$new_html = $this->strip_html_doc_wrapper( $new_html );
		if ( '' !== $html ) {
			$existing_ids = array();
			if ( preg_match_all( '/data-amb-section\s*=\s*"([^"]+)"/i', $html, $_id_m ) ) {
				$existing_ids = array_values( array_unique( $_id_m[1] ) );
			}
			$new_html = $this->normalize_section_append_html( $new_html, $existing_ids );
		}

		if ( '' !== $after_id && ! in_array( $after_id, $valid_ids, true ) ) {
			return array(
				'status' => 'error',
				'error'  => 'Unknown after_id: ' . $after_id,
				'valid'  => $valid_ids,
			);
		}

		// Extract the new section's id so we can wrap it in markers.
		$new_section_id = '';
		if ( preg_match( '/data-amb-section="([^"]+)"/', $new_html, $ns_match ) ) {
			$new_section_id = $ns_match[1];
		}

		if ( '' !== $after_id ) {
			$merged_html = $this->insert_after_section_in_html( $html, $after_id, $new_html );
			if ( null === $merged_html ) {
				return array(
					'status' => 'error',
					'error'  => 'Could not locate after_id node in the page HTML.',
					'valid'  => $valid_ids,
				);
			}
		} else {
			// Append before the closing </main>, fallback to </body>, fallback to end.
			$lower       = strtolower( $html );
			$main_close  = strrpos( $lower, '</main>' );
			$body_close  = strrpos( $lower, '</body>' );
			if ( false !== $main_close ) {
				$merged_html = substr( $html, 0, $main_close ) . "\n" . $new_html . "\n" . substr( $html, $main_close );
			} elseif ( false !== $body_close ) {
				$merged_html = substr( $html, 0, $body_close ) . "\n" . $new_html . "\n" . substr( $html, $body_close );
			} else {
				$merged_html = rtrim( $html ) . "\n" . $new_html;
			}
		}

		// Add markers around the new section if it has a section id.
		if ( '' !== $new_section_id ) {
			$merged_html = $this->inject_html_markers_if_missing( $merged_html, $new_section_id );
		}

		$merged_css = ( '' !== $new_css && '' !== $new_section_id )
			? $this->replace_css_section( $css, $new_section_id, $new_css )
			: ( ( '' !== $new_css ) ? rtrim( $css ) . "\n" . $new_css : $css );
		$merged_js  = ( '' !== $new_js && '' !== $new_section_id )
			? $this->replace_js_section( $js, $new_section_id, $new_js )
			: ( ( '' !== $new_js ) ? rtrim( $js ) . "\n" . $new_js : $js );

		$summary = ( '' !== $after_id )
			? sprintf( 'Added a new section after "%s"', $after_id )
			: 'Added a new section at the end';

		return array(
			'status'  => 'ok',
			'state'   => array( 'html' => $merged_html, 'css' => $merged_css, 'js' => $merged_js ),
			'summary' => $summary,
		);
	}

	/**
	 * Tool: remove a section.
	 */
	private function tool_delete_section( $args, $html, $css, $js, $valid_ids ) {
		$section_id = isset( $args['section_id'] ) ? trim( (string) $args['section_id'] ) : '';
		if ( '' === $section_id ) {
			return array(
				'status' => 'error',
				'error'  => 'delete_section requires non-empty `section_id`.',
				'valid'  => $valid_ids,
			);
		}
		if ( ! in_array( $section_id, $valid_ids, true ) ) {
			return array(
				'status' => 'error',
				'error'  => 'Unknown section_id: ' . $section_id,
				'valid'  => $valid_ids,
			);
		}

		$merged_html = $this->splice_section_in_html( $html, $section_id, '' );
		if ( null === $merged_html ) {
			return array(
				'status' => 'error',
				'error'  => 'Could not locate section node in the page HTML.',
				'valid'  => $valid_ids,
			);
		}

		return array(
			'status'  => 'ok',
			'state'   => array( 'html' => $merged_html, 'css' => $css, 'js' => $js ),
			'summary' => sprintf( 'Removed section "%s"', $section_id ),
		);
	}

	/**
	 * Tool: fetch scoped section code (read-only).
	 */
	private function extract_root_vars( $css ) {
		$vars = array();
		if ( preg_match_all( '/:root\s*\{([^}]+)\}/', (string) $css, $m ) ) {
			foreach ( $m[1] as $body ) {
				if ( preg_match_all( '/(--[\w-]+)\s*:\s*([^;]+);/', $body, $vm, PREG_SET_ORDER ) ) {
					foreach ( $vm as $v ) {
						$vars[ trim( $v[1] ) ] = trim( $v[2] );
					}
				}
			}
		}
		if ( empty( $vars ) ) { return ''; }
		$lines = array( ':root {' );
		foreach ( $vars as $n => $v ) { $lines[] = "  {$n}: {$v};"; }
		$lines[] = '}';
		return implode( "\n", $lines );
	}

	private function tool_get_page_code( $html, $css, $js ) {
		return array(
			'status' => 'read',
			'result' => array(
				'html'      => $html,
				'css'       => $css,
				'js'        => $js,
				'root_vars' => $this->extract_root_vars( $css ),
			),
		);
	}

	private function tool_get_section( $args, $html, $css, $js, $valid_ids ) {
		$section_id = isset( $args['section_id'] ) ? trim( (string) $args['section_id'] ) : '';
		if ( '' === $section_id || ! in_array( $section_id, $valid_ids, true ) ) {
			return array(
				'status' => 'error',
				'error'  => 'Unknown section_id: ' . $section_id,
				'valid'  => $valid_ids,
			);
		}

		$scoped = $this->get_section_scoped( $section_id, $html, $css, $js );
		if ( null === $scoped ) {
			return array(
				'status' => 'error',
				'error'  => 'Failed to extract section.',
				'valid'  => $valid_ids,
			);
		}

		$scoped['root_vars'] = $this->extract_root_vars( $css );

		return array(
			'status' => 'read',
			'result' => $scoped,
		);
	}

	/**
	 * Tool: find a section by keyword and return its full HTML/CSS/JS chunks.
	 *
	 * Searches data-amb-section ids for the closest match to `keyword`, then
	 * returns the code between the AMB comment markers (or falls back to
	 * get_section_scoped for pages that don't have markers yet).
	 */
	private function tool_search_section( $args, $html, $css, $js ) {
		$keyword = isset( $args['keyword'] ) ? trim( strtolower( (string) $args['keyword'] ) ) : '';
		if ( '' === $keyword ) {
			error_log( '[AMB] search_section: ERROR — empty keyword' );
			return array( 'status' => 'error', 'error' => 'search_section requires non-empty `keyword`.' );
		}

		// Use the full registry instead of a flat id list. The registry preserves
		// the FIRST class on each section, which we expose to the keyword matcher
		// as a soft alias so the AI can search by class when the data-amb-section
		// id is unknown to it.
		$registry  = $this->extract_section_registry( $html );
		$known_ids = array_values( array_unique( array_filter( array_map(
			static function ( $entry ) {
				return isset( $entry['id'] ) ? (string) $entry['id'] : '';
			},
			$registry
		) ) ) );

		error_log( sprintf(
			'[AMB] search_section: keyword="%s" | known_ids=[%s]',
			$keyword,
			implode( ', ', $known_ids )
		) );

		if ( empty( $known_ids ) ) {
			error_log( '[AMB] search_section: ERROR — no sections on page' );
			return array( 'status' => 'error', 'error' => 'No sections found on this page.' );
		}

		$matched_id = $this->match_section_keyword( $keyword, $known_ids, $registry );
		if ( null === $matched_id ) {
			error_log( sprintf( '[AMB] search_section: ERROR — no match for "%s"', $keyword ) );
			return array(
				'status'    => 'error',
				'error'     => 'No section matched keyword "' . $keyword . '".',
				'available' => array_values( $known_ids ),
			);
		}

		error_log( sprintf( '[AMB] search_section: matched_id="%s"', $matched_id ) );

		// Extract via markers first (fast path).
		$section_html = $this->extract_html_section( $html, $matched_id );
		$section_css  = $this->extract_css_section( $css, $matched_id );
		$section_js   = $this->extract_js_section( $js, $matched_id );

		$used_markers = ( null !== $section_html );

		// Fallback: DOM-based extraction for pages without markers yet.
		if ( null === $section_html || null === $section_css || null === $section_js ) {
			error_log( '[AMB] search_section: markers not found — falling back to DOM extraction' );
			$scoped = $this->get_section_scoped( $matched_id, $html, $css, $js );
			if ( null === $section_html ) {
				$section_html = $scoped ? $scoped['html'] : '';
			}
			if ( null === $section_css ) {
				$section_css = $scoped ? $scoped['css'] : '';
			}
			if ( null === $section_js ) {
				$section_js = $scoped ? $scoped['js'] : '';
			}
		}

		error_log( sprintf(
			'[AMB] search_section result: section_id="%s" | extraction=%s | html_chars=%d | css_chars=%d | js_chars=%d',
			$matched_id,
			$used_markers ? 'markers' : 'DOM-fallback',
			strlen( (string) $section_html ),
			strlen( (string) $section_css ),
			strlen( (string) $section_js )
		) );

		return array(
			'status' => 'read',
			'result' => array(
				'section_id' => $matched_id,
				'html'       => (string) $section_html,
				'css'        => (string) $section_css,
				'js'         => (string) $section_js,
				'root_vars'  => $this->extract_root_vars( $css ),
				'found'      => true,
			),
		);
	}

	/**
	 * Tool: edit one file in the imported-script manifest.
	 *
	 * Matches the target by `file_id` (the entry's id, falling back to its
	 * array index), replaces its `content`, and returns the updated manifest in
	 * the page state. HTML/CSS/JS blobs pass through unchanged. Remote/library
	 * entries are read-only.
	 *
	 * @param  array  $args    Tool args: { file_id, new_content }.
	 * @param  string $html    Current page HTML (unchanged).
	 * @param  string $css     Current page CSS (unchanged).
	 * @param  string $js      Current customJs blob (unchanged).
	 * @param  array  $scripts Current imported-script manifest.
	 * @return array
	 */
	private function tool_edit_js_file( $args, $html, $css, $js, $scripts ) {
		$file_id     = isset( $args['file_id'] ) ? trim( (string) $args['file_id'] ) : '';
		$new_content = isset( $args['new_content'] ) ? (string) $args['new_content'] : '';

		if ( '' === $file_id ) {
			return array( 'status' => 'error', 'error' => 'edit_js_file requires a `file_id`.' );
		}
		if ( ! is_array( $scripts ) || empty( $scripts ) ) {
			return array( 'status' => 'error', 'error' => 'This page has no editable JavaScript files.' );
		}

		$valid_ids = array();
		$target    = -1;
		foreach ( $scripts as $i => $entry ) {
			$sid         = isset( $entry['id'] ) ? (string) $entry['id'] : (string) $i;
			$valid_ids[] = $sid;
			if ( $sid === $file_id ) {
				$target = $i;
			}
		}

		if ( -1 === $target ) {
			return array(
				'status'    => 'error',
				'error'     => 'No JavaScript file with id "' . $file_id . '".',
				'available' => $valid_ids,
			);
		}

		$kind = isset( $scripts[ $target ]['kind'] ) ? (string) $scripts[ $target ]['kind'] : '';
		if ( 'remote' === $kind ) {
			return array(
				'status' => 'error',
				'error'  => 'File "' . $file_id . '" is an external library reference and cannot be edited.',
			);
		}

		$label                          = isset( $scripts[ $target ]['label'] ) && '' !== $scripts[ $target ]['label']
			? (string) $scripts[ $target ]['label']
			: $file_id;
		$scripts[ $target ]['content'] = $new_content;

		return array(
			'status'  => 'ok',
			'state'   => array(
				'html'    => $html,
				'css'     => $css,
				'js'      => $js,
				'scripts' => $scripts,
			),
			'summary' => sprintf( 'Updated JavaScript file "%s"', $label ),
		);
	}

	/**
	 * Tool: update global :root CSS custom properties site-wide.
	 *
	 * Merges the incoming :root block into the existing CSS blob by updating
	 * matching custom property lines in the first :root { } rule and
	 * appending any new ones. Appends optional global_rules after :root.
	 */
	private function tool_set_global_style( $args, $html, $css, $js ) {
		$root_vars    = isset( $args['root_vars'] )    ? trim( (string) $args['root_vars'] )    : '';
		$global_rules = isset( $args['global_rules'] ) ? trim( (string) $args['global_rules'] ) : '';
		$summary      = isset( $args['summary'] )      ? trim( (string) $args['summary'] )      : 'Updated global styles';

		if ( '' === $root_vars && '' === $global_rules ) {
			return array( 'status' => 'error', 'error' => 'set_global_style requires at least root_vars or global_rules.' );
		}

		$new_css = $css;

		if ( '' !== $root_vars ) {
			// Extract individual --property: value; lines from the incoming block.
			$incoming_block = $root_vars;
			// Strip the :root { ... } wrapper if present.
			$inner_match = array();
			if ( preg_match( '/:root\s*\{([\s\S]*?)\}/i', $incoming_block, $inner_match ) ) {
				$incoming_lines_raw = $inner_match[1];
			} else {
				$incoming_lines_raw = $incoming_block;
			}

			// Parse incoming custom properties into an associative map.
			$incoming_props = array();
			preg_match_all( '/(--[\w-]+)\s*:\s*([^;]+);/i', $incoming_lines_raw, $prop_matches, PREG_SET_ORDER );
			foreach ( $prop_matches as $m ) {
				$incoming_props[ trim( $m[1] ) ] = trim( $m[2] );
			}

			if ( ! empty( $incoming_props ) ) {
				// Find existing :root block in the CSS blob.
				if ( preg_match( '/:root\s*\{([\s\S]*?)\}/i', $new_css, $root_block_match ) ) {
					$existing_root_body = $root_block_match[1];
					$updated_root_body  = $existing_root_body;

					foreach ( $incoming_props as $prop => $value ) {
						$esc = preg_quote( $prop, '/' );
						if ( preg_match( '/' . $esc . '\s*:/', $updated_root_body ) ) {
							// Replace existing property line.
							$updated_root_body = preg_replace(
								'/' . $esc . '\s*:[^;]+;/',
								$prop . ': ' . $value . ';',
								$updated_root_body
							);
						} else {
							// Append new property before closing brace.
							$updated_root_body = rtrim( $updated_root_body ) . "\n\t" . $prop . ': ' . $value . ';';
						}
					}

					$new_css = preg_replace(
						'/:root\s*\{[\s\S]*?\}/i',
						':root {' . $updated_root_body . "\n}",
						$new_css,
						1
					);
				} else {
					// No :root block exists — prepend one.
					$lines = array();
					foreach ( $incoming_props as $prop => $value ) {
						$lines[] = "\t" . $prop . ': ' . $value . ';';
					}
					$new_css = ":root {\n" . implode( "\n", $lines ) . "\n}\n" . $new_css;
				}
			}
		}

		if ( '' !== $global_rules ) {
			$new_css = rtrim( $new_css ) . "\n" . $global_rules;
		}

		return array(
			'status'  => 'ok',
			'state'   => array( 'html' => $html, 'css' => $new_css, 'js' => $js ),
			'summary' => $summary,
		);
	}

	/**
	 * Tool: reorder a section within the page.
	 *
	 * Removes the section from its current position and re-inserts it
	 * before or after an anchor section. CSS and JS are unchanged because
	 * AMB CSS is scoped by section marker, not DOM position.
	 */
	private function tool_move_section( $args, $html, $css, $js, $valid_ids ) {
		$section_id = isset( $args['section_id'] ) ? trim( (string) $args['section_id'] ) : '';
		$position   = isset( $args['position'] )   ? trim( (string) $args['position'] )   : '';
		$anchor_id  = isset( $args['anchor_id'] )  ? trim( (string) $args['anchor_id'] )  : '';

		if ( '' === $section_id || '' === $anchor_id ) {
			return array( 'status' => 'error', 'error' => 'move_section requires section_id, position, and anchor_id.' );
		}
		if ( ! in_array( $position, array( 'before', 'after' ), true ) ) {
			return array( 'status' => 'error', 'error' => 'move_section position must be "before" or "after".' );
		}
		if ( $section_id === $anchor_id ) {
			return array( 'status' => 'error', 'error' => 'section_id and anchor_id must be different.' );
		}

		// Fuzzy-resolve section_id and anchor_id if they don't exactly match.
		if ( ! in_array( $section_id, $valid_ids, true ) ) {
			$fuzzy = $this->match_section_keyword( strtolower( $section_id ), $valid_ids );
			if ( null !== $fuzzy ) {
				$section_id = $fuzzy;
			} else {
				return array( 'status' => 'error', 'error' => 'Unknown section_id: ' . $section_id, 'valid' => $valid_ids );
			}
		}
		if ( ! in_array( $anchor_id, $valid_ids, true ) ) {
			$fuzzy = $this->match_section_keyword( strtolower( $anchor_id ), $valid_ids );
			if ( null !== $fuzzy ) {
				$anchor_id = $fuzzy;
			} else {
				return array( 'status' => 'error', 'error' => 'Unknown anchor_id: ' . $anchor_id, 'valid' => $valid_ids );
			}
		}

		$prev_error_state = libxml_use_internal_errors( true );
		$dom = new \DOMDocument();
		$load_ok = $dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_move_root">' . $html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev_error_state );

		if ( ! $load_ok ) {
			return array( 'status' => 'error', 'error' => 'Could not parse page HTML.' );
		}

		$xpath       = new \DOMXPath( $dom );
		$sec_nodes   = $xpath->query( sprintf( '//*[@data-amb-section="%s"]', addslashes( $section_id ) ) );
		$anchor_nodes = $xpath->query( sprintf( '//*[@data-amb-section="%s"]', addslashes( $anchor_id ) ) );

		if ( ! $sec_nodes || 0 === $sec_nodes->length ) {
			return array( 'status' => 'error', 'error' => 'Could not locate section "' . $section_id . '" in page HTML.' );
		}
		if ( ! $anchor_nodes || 0 === $anchor_nodes->length ) {
			return array( 'status' => 'error', 'error' => 'Could not locate anchor "' . $anchor_id . '" in page HTML.' );
		}

		$section_node = $sec_nodes->item( 0 );
		$anchor_node  = $anchor_nodes->item( 0 );
		$parent       = $anchor_node->parentNode;

		// Detach section from its current position.
		$section_node->parentNode->removeChild( $section_node );

		if ( 'before' === $position ) {
			$parent->insertBefore( $section_node, $anchor_node );
		} else {
			// Insert after anchor: insertBefore its nextSibling, or appendChild.
			$next_sibling = $anchor_node->nextSibling;
			if ( $next_sibling ) {
				$parent->insertBefore( $section_node, $next_sibling );
			} else {
				$parent->appendChild( $section_node );
			}
		}

		// Reconstruct HTML from the wrapper div.
		$root = $dom->getElementById( '__ambld_move_root' );
		if ( ! $root ) {
			return array( 'status' => 'error', 'error' => 'DOM reconstruction failed.' );
		}
		$out = '';
		foreach ( $root->childNodes as $child ) {
			$out .= $dom->saveHTML( $child );
		}

		return array(
			'status'  => 'ok',
			'state'   => array( 'html' => $out, 'css' => $css, 'js' => $js ),
			'summary' => sprintf( 'Moved section "%s" %s "%s"', $section_id, $position, $anchor_id ),
		);
	}

	/**
	 * Match a keyword against known section ids.
	 *
	 * Priority: 1) exact match, 2) substr (id contains keyword or vice versa),
	 * 3) Levenshtein distance ≤ 3.
	 *
	 * @param  string   $keyword   Lowercased search term.
	 * @param  string[] $ids       Known section ids.
	 * @return string|null         Best matching id, or null if no match.
	 */
	private function match_section_keyword( $keyword, $ids, $registry = array() ) {
		// 1. Exact id match.
		foreach ( $ids as $id ) {
			if ( strtolower( $id ) === $keyword ) {
				return $id;
			}
		}

		// 2. First-class alias match. Multi-class sections like
		//    `<section data-amb-section="services" class="services-section animate-fade">`
		//    used to fail when the model passed `services-section` as the keyword —
		//    the matcher only checked ids and fell through to Levenshtein on garbage.
		//    The first class is now a hard alias for the section id.
		if ( is_array( $registry ) && ! empty( $registry ) ) {
			foreach ( $registry as $entry ) {
				if ( empty( $entry['id'] ) || empty( $entry['classes'] ) || ! is_array( $entry['classes'] ) ) {
					continue;
				}
				$first_class = strtolower( (string) $entry['classes'][0] );
				if ( '' !== $first_class && $first_class === $keyword ) {
					return $entry['id'];
				}
			}

			// 3. Any-class match — softer fallback. Catches the case where the
			//    model picks the second/third class or strips a `-section` suffix.
			foreach ( $registry as $entry ) {
				if ( empty( $entry['id'] ) || empty( $entry['classes'] ) || ! is_array( $entry['classes'] ) ) {
					continue;
				}
				foreach ( $entry['classes'] as $cls ) {
					if ( strtolower( (string) $cls ) === $keyword ) {
						return $entry['id'];
					}
				}
			}
		}

		// 4. Substr match against ids.
		foreach ( $ids as $id ) {
			$lower = strtolower( $id );
			if ( false !== strpos( $lower, $keyword ) || false !== strpos( $keyword, $lower ) ) {
				return $id;
			}
		}

		// 5. Levenshtein (allow up to 3 edits).
		$best_id   = null;
		$best_dist = PHP_INT_MAX;
		foreach ( $ids as $id ) {
			$dist = levenshtein( $keyword, strtolower( $id ) );
			if ( $dist < $best_dist ) {
				$best_dist = $dist;
				$best_id   = $id;
			}
		}
		return ( $best_dist <= 3 ) ? $best_id : null;
	}

	// -------------------------------------------------------------------------
	// Section comment-marker helpers
	// -------------------------------------------------------------------------
	// Every section's code (HTML / CSS / JS) is wrapped in named start/end
	// markers so the search_section tool can extract and replace code chunks
	// precisely during refinement prompts.
	//
	// HTML:  <!-- [AMB:id] --> ... <!-- [/AMB:id] -->
	// CSS:   /* [AMB:id] */  ... /* [/AMB:id] */
	// JS:    // [AMB:id]     ... // [/AMB:id]
	// -------------------------------------------------------------------------

	private function wrap_html_with_marker( $html, $id ) {
		return "<!-- [AMB:{$id}] -->\n{$html}\n<!-- [/AMB:{$id}] -->";
	}

	private function strip_html_markers( $html, $id ) {
		$esc  = preg_quote( $id, '/' );
		$html = preg_replace( '/<!--\s*\[AMB:' . $esc . '\]\s*-->/i', '', $html );
		$html = preg_replace( '/<!--\s*\[\/AMB:' . $esc . '\]\s*-->/i', '', $html );
		return trim( $html );
	}

	private function extract_html_section( $html, $id ) {
		$esc = preg_quote( $id, '/' );
		if ( preg_match( '/<!--\s*\[AMB:' . $esc . '\]\s*-->([\s\S]*?)<!--\s*\[\/AMB:' . $esc . '\]\s*-->/i', $html, $m ) ) {
			return trim( $m[1] );
		}
		return null;
	}

	/**
	 * Inject <!-- [AMB:id] --> / <!-- [/AMB:id] --> around the section element
	 * in $html if they are not already present. Uses DOMDocument so the comment
	 * nodes are placed precisely as siblings of the <section> element.
	 */
	private function inject_html_markers_if_missing( $html, $id ) {
		$esc = preg_quote( $id, '/' );
		if ( preg_match( '/<!--\s*\[AMB:' . $esc . '\]\s*-->/i', $html ) ) {
			return $html; // Already present — DOM round-trip preserved them.
		}

		$prev = libxml_use_internal_errors( true );
		$dom  = new \DOMDocument();
		$dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_mark_root">' . $html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev );

		$xpath  = new \DOMXPath( $dom );
		$nodes  = $xpath->query( sprintf( '//*[@data-amb-section="%s"]', addslashes( $id ) ) );
		if ( ! $nodes || 0 === $nodes->length ) {
			return $html;
		}
		$target = $nodes->item( 0 );
		$parent = $target->parentNode;

		$open  = $dom->createComment( ' [AMB:' . $id . '] ' );
		$close = $dom->createComment( ' [/AMB:' . $id . '] ' );

		$parent->insertBefore( $open, $target );
		$next = $target->nextSibling;
		if ( $next ) {
			$parent->insertBefore( $close, $next );
		} else {
			$parent->appendChild( $close );
		}

		$root = $dom->getElementById( '__ambld_mark_root' );
		if ( ! $root ) {
			return $html;
		}
		$out = '';
		foreach ( $root->childNodes as $child ) {
			$out .= $dom->saveHTML( $child );
		}
		return $out;
	}

	private function wrap_css_with_marker( $css, $id ) {
		if ( '' === trim( (string) $css ) ) {
			return '';
		}
		return "/* [AMB:{$id}] */\n{$css}\n/* [/AMB:{$id}] */";
	}

	private function extract_css_section( $css, $id ) {
		$esc = preg_quote( $id, '/' );
		if ( preg_match( '/\/\*\s*\[AMB:' . $esc . '\]\s*\*\/([\s\S]*?)\/\*\s*\[\/AMB:' . $esc . '\]\s*\*\//i', $css, $m ) ) {
			return trim( $m[1] );
		}
		return null;
	}

	private function replace_css_section( $css, $id, $new_css ) {
		$esc     = preg_quote( $id, '/' );
		$pattern = '/\/\*\s*\[AMB:' . $esc . '\]\s*\*\/[\s\S]*?\/\*\s*\[\/AMB:' . $esc . '\]\s*\*\//i';
		if ( preg_match( $pattern, $css ) ) {
			if ( '' === trim( (string) $new_css ) ) {
				return trim( preg_replace( $pattern, '', $css ) );
			}
			return preg_replace( $pattern, $this->wrap_css_with_marker( $new_css, $id ), $css );
		}
		// No block yet — append as a new marked block.
		if ( '' === trim( (string) $new_css ) ) {
			return $css;
		}
		return rtrim( $css ) . "\n" . $this->wrap_css_with_marker( $new_css, $id );
	}

	private function wrap_js_with_marker( $js, $id ) {
		$js = trim( (string) $js );
		if ( '' === $js ) {
			return '';
		}
		// Strip any accidental <script> tags the AI may have included.
		$js = preg_replace( '/<\/?script\b[^>]*>/i', '', $js );
		$js = trim( $js );
		if ( '' === $js ) {
			return '';
		}
		return "// [AMB:{$id}]\n{$js}\n// [/AMB:{$id}]";
	}

	private function extract_js_section( $js, $id ) {
		$esc = preg_quote( $id, '/' );
		if ( preg_match( '/\/\/\s*\[AMB:' . $esc . '\]([\s\S]*?)\/\/\s*\[\/AMB:' . $esc . '\]/i', $js, $m ) ) {
			return trim( $m[1] );
		}
		return null;
	}

	private function replace_js_section( $js, $id, $new_js ) {
		$esc     = preg_quote( $id, '/' );
		$pattern = '/\/\/\s*\[AMB:' . $esc . '\][\s\S]*?\/\/\s*\[\/AMB:' . $esc . '\]/i';
		if ( preg_match( $pattern, $js ) ) {
			if ( '' === trim( (string) $new_js ) ) {
				return trim( preg_replace( $pattern, '', $js ) );
			}
			return preg_replace( $pattern, $this->wrap_js_with_marker( $new_js, $id ), $js );
		}
		if ( '' === trim( (string) $new_js ) ) {
			return $js;
		}
		return rtrim( $js ) . "\n" . $this->wrap_js_with_marker( $new_js, $id );
	}

	/**
	 * Remove orphaned, UNMARKED copies of a section's JS from the page blob.
	 *
	 * Page generation stores all JS as one unmarked blob; `wrap_js_with_marker`
	 * only runs on edit (via replace_js_section). So the FIRST time the AI
	 * patches a section's JS, a `// [AMB:id]` marker block is appended while the
	 * original handler is left in place — two handlers now run, the fix never
	 * takes visible effect, and the model loops re-fixing it. This scrubs that
	 * orphan: it drops top-level statements that live OUTSIDE any AMB marker
	 * block and are attributable SOLELY to this section.
	 *
	 * Safety: marker blocks (this section's and every other section's) are
	 * preserved verbatim. An unmarked statement is removed only when it (a)
	 * references this section's `data-amb-section` attribute or a class that
	 * exists ONLY in this section, AND (b) references NOTHING that belongs to
	 * another section (its id or an exclusive class). A multi-section shared
	 * handler therefore always survives.
	 *
	 * @param  string $js         Page customJs blob (after replace_js_section).
	 * @param  string $section_id Section being edited.
	 * @param  string $html       Full page HTML (post-edit) for class attribution.
	 * @return string Scrubbed JS blob.
	 */
	private function scrub_unmarked_section_js( $js, $section_id, $html ) {
		$js         = (string) $js;
		$section_id = (string) $section_id;
		if ( '' === trim( $js ) || '' === $section_id || false === strpos( $js, '{' ) ) {
			return $js;
		}

		list( $own_classes, $other_classes ) = $this->partition_section_classes( $html, $section_id );

		$own_lookup   = array_flip( $own_classes );
		$other_lookup = array_flip( $other_classes );

		// Classes that appear ONLY in this section — safe attribution signals.
		$own_unique = array();
		foreach ( $own_classes as $c ) {
			if ( ! isset( $other_lookup[ $c ] ) ) {
				$own_unique[] = $c;
			}
		}
		// Classes that appear ONLY outside this section — keep-signals.
		$others_exclusive = array();
		foreach ( $other_classes as $c ) {
			if ( ! isset( $own_lookup[ $c ] ) ) {
				$others_exclusive[] = $c;
			}
		}

		// Other section ids: a statement that mentions one is cross-section.
		$other_ids = array();
		if ( preg_match_all( '/data-amb-section\s*=\s*"([^"]+)"/i', (string) $html, $m ) ) {
			foreach ( $m[1] as $sid ) {
				$sid = trim( $sid );
				if ( '' !== $sid && $sid !== $section_id ) {
					$other_ids[ $sid ] = true;
				}
			}
		}
		$other_ids = array_keys( $other_ids );

		// Nothing uniquely identifies this section → too risky to attribute.
		if ( empty( $own_unique ) ) {
			$attr_only = ( false !== strpos( $js, 'data-amb-section="' . $section_id . '"' ) )
				|| ( false !== strpos( $js, "data-amb-section='" . $section_id . "'" ) );
			if ( ! $attr_only ) {
				return $js;
			}
		}

		$matches_class = static function ( $chunk, $class ) {
			// Token match so `.pricing-tab` doesn't hit inside `.pricing-table`.
			return (bool) preg_match( '/(?<![\w-])' . preg_quote( $class, '/' ) . '(?![\w-])/', $chunk );
		};

		$belongs = function ( $chunk ) use ( $section_id, $own_unique, $others_exclusive, $other_ids, $matches_class ) {
			$refs_own = ( false !== strpos( $chunk, 'data-amb-section="' . $section_id . '"' ) )
				|| ( false !== strpos( $chunk, "data-amb-section='" . $section_id . "'" ) );
			if ( ! $refs_own ) {
				foreach ( $own_unique as $c ) {
					if ( $matches_class( $chunk, $c ) ) {
						$refs_own = true;
						break;
					}
				}
			}
			if ( ! $refs_own ) {
				return false;
			}
			// Any cross-section reference → shared handler, keep it.
			foreach ( $other_ids as $oid ) {
				if ( false !== strpos( $chunk, 'data-amb-section="' . $oid . '"' )
					|| false !== strpos( $chunk, "data-amb-section='" . $oid . "'" ) ) {
					return false;
				}
			}
			foreach ( $others_exclusive as $c ) {
				if ( $matches_class( $chunk, $c ) ) {
					return false;
				}
			}
			return true;
		};

		// Split into [unmarked, marker, unmarked, marker, …] — scrub only the
		// unmarked (even-index) segments; marker blocks survive untouched.
		$marker_pattern = '/(\/\/\s*\[AMB:[^\]]+\][\s\S]*?\/\/\s*\[\/AMB:[^\]]+\])/';
		$segments       = preg_split( $marker_pattern, $js, -1, PREG_SPLIT_DELIM_CAPTURE );
		if ( ! is_array( $segments ) ) {
			return $js;
		}

		$removed_any = false;
		foreach ( $segments as $idx => $seg ) {
			if ( 1 === ( $idx % 2 ) || '' === trim( (string) $seg ) ) {
				continue; // marker block or empty gap
			}
			$units = $this->split_top_level_js_statements( (string) $seg );
			$kept  = array();
			foreach ( $units as $unit ) {
				if ( '' !== trim( $unit ) && $belongs( $unit ) ) {
					$removed_any = true;
					continue;
				}
				$kept[] = $unit;
			}
			$segments[ $idx ] = implode( '', $kept );
		}

		if ( ! $removed_any ) {
			return $js;
		}

		$result = implode( '', $segments );
		$result = preg_replace( "/\n{3,}/", "\n\n", $result );
		return trim( (string) $result );
	}

	/**
	 * Partition every CSS class on the page into "belongs to this section" vs
	 * "appears elsewhere", for safely attributing unmarked JS statements.
	 *
	 * @param  string $html       Full page HTML.
	 * @param  string $section_id Target section id.
	 * @return array{0:string[],1:string[]} [ own_classes, other_classes ].
	 */
	private function partition_section_classes( $html, $section_id ) {
		if ( '' === trim( (string) $html ) ) {
			return array( array(), array() );
		}

		$prev = libxml_use_internal_errors( true );
		$dom  = new \DOMDocument();
		$ok   = $dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_partition_root">' . $html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev );
		if ( ! $ok ) {
			return array( array(), array() );
		}

		$xpath  = new \DOMXPath( $dom );
		$nodes  = $xpath->query( sprintf( '//*[@data-amb-section="%s"]', addslashes( $section_id ) ) );
		$target = ( $nodes && $nodes->length > 0 ) ? $nodes->item( 0 ) : null;

		$own_set   = array();
		$other_set = array();
		$all       = $xpath->query( '//*[@class]' );
		if ( $all ) {
			foreach ( $all as $node ) {
				$is_own = false;
				if ( $target ) {
					if ( $node->isSameNode( $target ) ) {
						$is_own = true;
					} else {
						for ( $p = $node->parentNode; $p instanceof \DOMNode; $p = $p->parentNode ) {
							if ( $p->isSameNode( $target ) ) {
								$is_own = true;
								break;
							}
						}
					}
				}
				foreach ( preg_split( '/\s+/', $node->getAttribute( 'class' ) ) as $c ) {
					$c = trim( $c );
					if ( '' === $c ) {
						continue;
					}
					if ( $is_own ) {
						$own_set[ $c ] = true;
					} else {
						$other_set[ $c ] = true;
					}
				}
			}
		}

		return array( array_keys( $own_set ), array_keys( $other_set ) );
	}

	/**
	 * Split a JS source into top-level statement units such that concatenating
	 * the units reproduces the input exactly. String-, template-, and
	 * comment-aware, and tracks (), [], {} depth so a complete statement —
	 * including a trailing `})();` or `);` — is one unit and never severed mid
	 * way (which would leave orphaned syntax behind when a unit is dropped).
	 *
	 * Intentionally approximate (no full JS parse): regex literals are rare in
	 * our generated handlers, and a mis-split only ever causes a unit to be
	 * KEPT, never corrupted.
	 *
	 * @param  string $code JS source.
	 * @return string[] Ordered statement units (with their leading trivia).
	 */
	private function split_top_level_js_statements( $code ) {
		$len = strlen( $code );
		if ( 0 === $len ) {
			return array();
		}

		$units      = array();
		$i          = 0;
		$unit_start = 0;
		$paren      = 0;
		$brace      = 0;
		$bracket    = 0;
		$in_str     = false;
		$str_ch     = '';
		$in_tmpl    = false;
		$in_line    = false;
		$in_block   = false;

		while ( $i < $len ) {
			$ch   = $code[ $i ];
			$next = ( $i + 1 < $len ) ? $code[ $i + 1 ] : '';

			if ( $in_line ) {
				if ( "\n" === $ch ) {
					$in_line = false;
				}
				$i++;
				continue;
			}
			if ( $in_block ) {
				if ( '*' === $ch && '/' === $next ) {
					$in_block = false;
					$i       += 2;
					continue;
				}
				$i++;
				continue;
			}
			if ( $in_str ) {
				if ( '\\' === $ch ) {
					$i += 2;
					continue;
				}
				if ( $ch === $str_ch ) {
					$in_str = false;
				}
				$i++;
				continue;
			}
			if ( $in_tmpl ) {
				if ( '\\' === $ch ) {
					$i += 2;
					continue;
				}
				if ( '`' === $ch ) {
					$in_tmpl = false;
				}
				$i++;
				continue;
			}

			if ( '/' === $ch && '/' === $next ) {
				$in_line = true;
				$i      += 2;
				continue;
			}
			if ( '/' === $ch && '*' === $next ) {
				$in_block = true;
				$i       += 2;
				continue;
			}
			if ( '"' === $ch || "'" === $ch ) {
				$in_str = true;
				$str_ch = $ch;
				$i++;
				continue;
			}
			if ( '`' === $ch ) {
				$in_tmpl = true;
				$i++;
				continue;
			}
			if ( '(' === $ch ) {
				$paren++;
				$i++;
				continue;
			}
			if ( ')' === $ch ) {
				if ( $paren > 0 ) {
					$paren--;
				}
				$i++;
				continue;
			}
			if ( '[' === $ch ) {
				$bracket++;
				$i++;
				continue;
			}
			if ( ']' === $ch ) {
				if ( $bracket > 0 ) {
					$bracket--;
				}
				$i++;
				continue;
			}
			if ( '{' === $ch ) {
				$brace++;
				$i++;
				continue;
			}
			if ( '}' === $ch ) {
				if ( $brace > 0 ) {
					$brace--;
				}
				$i++;
				// A top-level block just closed (e.g. function/if/for body that
				// is the whole statement). If everything is balanced, consume an
				// optional trailing `)…;` tail + one newline and close the unit.
				if ( 0 === $brace && 0 === $paren && 0 === $bracket ) {
					$i = $this->consume_statement_tail( $code, $i, $len );
					$units[]    = substr( $code, $unit_start, $i - $unit_start );
					$unit_start = $i;
				}
				continue;
			}
			if ( ';' === $ch && 0 === $paren && 0 === $brace && 0 === $bracket ) {
				$i++;
				$i          = $this->consume_inline_ws_to_newline( $code, $i, $len );
				$units[]    = substr( $code, $unit_start, $i - $unit_start );
				$unit_start = $i;
				continue;
			}
			$i++;
		}

		if ( $unit_start < $len ) {
			$units[] = substr( $code, $unit_start );
		}

		return $units;
	}

	/**
	 * From a closing `}` at depth 0, consume a trailing `)`/`;`/inline-ws tail
	 * (so `})();` and `});` close with their statement) plus one newline.
	 */
	private function consume_statement_tail( $code, $i, $len ) {
		while ( $i < $len ) {
			$c = $code[ $i ];
			if ( ')' === $c || ';' === $c || ' ' === $c || "\t" === $c || "\r" === $c ) {
				$i++;
				continue;
			}
			break;
		}
		return $this->consume_inline_ws_to_newline( $code, $i, $len );
	}

	/** Consume inline whitespace and a single trailing newline. */
	private function consume_inline_ws_to_newline( $code, $i, $len ) {
		while ( $i < $len ) {
			$c = $code[ $i ];
			if ( ' ' === $c || "\t" === $c || "\r" === $c ) {
				$i++;
				continue;
			}
			if ( "\n" === $c ) {
				$i++;
				break;
			}
			break;
		}
		return $i;
	}

	/**
	 * Swap a <section data-amb-section="ID">…</section> node in a full-page
	 * HTML blob with `$new_html`. Pass empty `$new_html` to delete the node.
	 *
	 * Returns the merged HTML, or `null` when the target can't be found.
	 *
	 * @param  string $full_html
	 * @param  string $section_id
	 * @param  string $new_html
	 * @return string|null
	 */
	private function splice_section_in_html( $full_html, $section_id, $new_html ) {
		$prev_error_state = libxml_use_internal_errors( true );
		$dom = new \DOMDocument();
		$load_ok = $dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_splice_root">' . $full_html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev_error_state );
		if ( ! $load_ok ) {
			return null;
		}

		$xpath = new \DOMXPath( $dom );
		$nodes = $xpath->query( sprintf( '//*[@data-amb-section="%s"]', addslashes( $section_id ) ) );
		if ( ! $nodes || 0 === $nodes->length ) {
			return null;
		}
		$target = $nodes->item( 0 );

		if ( '' === $new_html ) {
			$target->parentNode->removeChild( $target );
		} else {
			// Parse `$new_html` in an isolated fragment and import the nodes.
			$frag_dom = new \DOMDocument();
			libxml_use_internal_errors( true );
			$frag_dom->loadHTML(
				'<?xml encoding="utf-8"?><div id="__ambld_frag">' . $new_html . '</div>',
				LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
			);
			libxml_clear_errors();
			libxml_use_internal_errors( false );

			$frag_root = $frag_dom->getElementById( '__ambld_frag' );
			if ( ! $frag_root ) {
				return null;
			}

			$parent = $target->parentNode;
			// Insert new children before the target, then remove target.
			while ( $frag_root->firstChild ) {
				$imported = $dom->importNode( $frag_root->firstChild, true );
				$parent->insertBefore( $imported, $target );
				$frag_root->removeChild( $frag_root->firstChild );
			}
			$parent->removeChild( $target );
		}

		$root = $dom->getElementById( '__ambld_splice_root' );
		if ( ! $root ) {
			return null;
		}
		$out = '';
		foreach ( $root->childNodes as $child ) {
			$out .= $dom->saveHTML( $child );
		}
		return $out;
	}

	/**
	 * Insert `$new_html` as a sibling AFTER the section identified by
	 * `$after_id` in `$full_html`. Returns merged HTML or null on failure.
	 */
	private function insert_after_section_in_html( $full_html, $after_id, $new_html ) {
		$prev_error_state = libxml_use_internal_errors( true );
		$dom = new \DOMDocument();
		$load_ok = $dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_insert_root">' . $full_html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev_error_state );
		if ( ! $load_ok ) {
			return null;
		}

		$xpath = new \DOMXPath( $dom );
		$nodes = $xpath->query( sprintf( '//*[@data-amb-section="%s"]', addslashes( $after_id ) ) );
		if ( ! $nodes || 0 === $nodes->length ) {
			return null;
		}
		$anchor = $nodes->item( 0 );
		$parent = $anchor->parentNode;

		$frag_dom = new \DOMDocument();
		libxml_use_internal_errors( true );
		$frag_dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_frag">' . $new_html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( false );
		$frag_root = $frag_dom->getElementById( '__ambld_frag' );
		if ( ! $frag_root ) {
			return null;
		}

		$insert_point = $anchor->nextSibling;
		while ( $frag_root->firstChild ) {
			$imported = $dom->importNode( $frag_root->firstChild, true );
			if ( $insert_point ) {
				$parent->insertBefore( $imported, $insert_point );
			} else {
				$parent->appendChild( $imported );
			}
			$frag_root->removeChild( $frag_root->firstChild );
		}

		$root = $dom->getElementById( '__ambld_insert_root' );
		if ( ! $root ) {
			return null;
		}
		$out = '';
		foreach ( $root->childNodes as $child ) {
			$out .= $dom->saveHTML( $child );
		}
		return $out;
	}

	/**
	 * Strip DOCTYPE/html/head/body wrappers from a section HTML snippet.
	 *
	 * When the AI emits a full document (`<!DOCTYPE html><html>…</html>`) for
	 * a tool argument that should contain only a `<section>`, this method
	 * extracts the inner section content and discards the outer structure.
	 * If the HTML is already a clean snippet (no doc wrappers), it is returned
	 * unchanged.
	 */
	private function strip_html_doc_wrapper( $html ) {
		$html = trim( (string) $html );
		if ( '' === $html ) {
			return '';
		}
		$lc = strtolower( $html );
		$has_wrapper = (
			false !== strpos( $lc, '<!doctype' )
			|| false !== strpos( $lc, '<html' )
			|| false !== strpos( $lc, '<body' )
		);
		if ( ! $has_wrapper ) {
			return $html;
		}

		$prev = libxml_use_internal_errors( true );
		$dom  = new \DOMDocument();
		$dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_docstrip">' . $html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev );

		$xpath = new \DOMXPath( $dom );

		// Prefer data-amb-section elements; fall back to any structural block.
		$blocks = $xpath->query( '//*[@data-amb-section]' );
		if ( ! $blocks || 0 === $blocks->length ) {
			$blocks = $xpath->query(
				'(//main | //body)//*[self::section or self::header or self::footer or self::nav or self::article or self::aside][not(ancestor::section)]'
			);
		}
		if ( ! $blocks || 0 === $blocks->length ) {
			// No structural blocks found — return original to avoid data loss.
			return $html;
		}

		$out = '';
		foreach ( $blocks as $node ) {
			$out .= $dom->saveHTML( $node ) . "\n";
		}
		return trim( $out );
	}

	/**
	 * Normalize the AI's section-append output into a flat snippet.
	 *
	 * On a section-append pass the model is supposed to emit ONLY a
	 * `<section …>` (optionally preceded by `<style>` and/or followed by
	 * `<script>`). Models drift — sometimes they emit a full HTML
	 * document or wrap the section in `<body>`/`<main>`. If we forward
	 * that wrapped output to the frontend, `appendSectionToMain` splices
	 * the WHOLE document inside the existing `<main>`, producing the
	 * nested / "vanished prior sections" failure mode the user reports.
	 *
	 * This method DOM-parses the output, finds all top-level structural
	 * blocks (section/header/footer/nav/article/aside) anywhere in the
	 * tree, plus head-level `<style>` and bottom-level inline `<script>`,
	 * and concatenates them as a flat snippet — discarding doctype,
	 * html, head, body, main wrappers entirely.
	 *
	 * Returns the raw input unchanged when no structural blocks exist
	 * (e.g. global / style-only / script-only tasks).
	 *
	 * @param  string $html Raw HTML the AI emitted.
	 * @return string Section-only snippet ready for splicing into <main>.
	 */
	/**
	 * Rewrite known third-party placeholder image hosts to the project's
	 * sanctioned host (`placehold.co`). The system prompt asks the model to
	 * use `placehold.co` exclusively, but models routinely fall back to
	 * `via.placeholder.com`, `dummyimage.com`, `picsum.photos`, etc. from
	 * training-data muscle memory. Rather than wrestle with prompt-only
	 * compliance, we transparently rewrite those hosts on every AI output
	 * before it touches the canvas / DB.
	 *
	 * Patterns rewritten:
	 *   via.placeholder.com/300x200/f0f0f0/cccccc?text=Foo
	 *     → placehold.co/300x200/f0f0f0/cccccc?text=Foo
	 *   dummyimage.com/300x200/eee/333.png&text=Foo
	 *     → placehold.co/300x200/eee/333?text=Foo
	 *   picsum.photos/seed/N/300/200  → placehold.co/300x200
	 *   picsum.photos/300/200         → placehold.co/300x200
	 *   loremflickr.com/300/200/cat   → placehold.co/300x200
	 *   placekitten.com/300/200       → placehold.co/300x200
	 *   placeimg.com/300/200/any      → placehold.co/300x200
	 *
	 * Operates on the FULL response string (HTML + inline CSS + inline JS),
	 * so URLs in `<img src>`, `background-image:`, `srcset`, and JS string
	 * literals all get rewritten in one pass.
	 *
	 * @param  string $html Raw HTML/CSS/JS blob.
	 * @return string Rewritten blob.
	 */
	private function rewrite_placeholder_image_urls( $html ) {
		$html = (string) $html;
		if ( '' === $html ) {
			return $html;
		}

		$before_length = strlen( $html );
		$swap_count    = 0;

		// 1. via.placeholder.com — same path/query shape as placehold.co.
		//    Match the host (with or without "www.") and replace verbatim;
		//    path arguments (size, bg/fg, ?text=) pass through unchanged.
		$html = preg_replace_callback(
			'#https?://(?:www\.)?via\.placeholder\.com/([^\s"\'<>]+)#i',
			function ( $m ) use ( &$swap_count ) {
				$swap_count++;
				return 'https://placehold.co/' . $m[1];
			},
			$html
		);

		// 2. dummyimage.com — strip a trailing `.png|.jpg|.gif|.webp` and
		//    convert `&text=` separator to `?text=` since placehold.co uses
		//    `?text=`. Sizes are interchangeable.
		$html = preg_replace_callback(
			'#https?://(?:www\.)?dummyimage\.com/([0-9]+x[0-9]+)(?:/([a-f0-9]{3,8}))?(?:/([a-f0-9]{3,8}))?(?:\.(?:png|jpg|jpeg|gif|webp))?(?:&text=([^"\'\s<>]+))?#i',
			function ( $m ) use ( &$swap_count ) {
				$swap_count++;
				$size  = $m[1];
				$bg    = isset( $m[2] ) && '' !== $m[2] ? $m[2] : '';
				$fg    = isset( $m[3] ) && '' !== $m[3] ? $m[3] : '';
				$text  = isset( $m[4] ) && '' !== $m[4] ? $m[4] : '';
				$path  = $size;
				if ( '' !== $bg ) {
					$path .= '/' . $bg;
					if ( '' !== $fg ) {
						$path .= '/' . $fg;
					}
				}
				$qs = '';
				if ( '' !== $text ) {
					$qs = '?text=' . $text;
				}
				return 'https://placehold.co/' . $path . $qs;
			},
			$html
		);

		// 3. picsum.photos — placeholder service. We can't preserve the
		//    photo content (placehold.co is a solid-color generator), but
		//    we keep the size. Two URL shapes: /seed/SEED/W/H and /W/H.
		$html = preg_replace_callback(
			'#https?://(?:www\.)?picsum\.photos/(?:seed/[^/]+/)?([0-9]+)/([0-9]+)(?:[^\s"\'<>]*)#i',
			function ( $m ) use ( &$swap_count ) {
				$swap_count++;
				return 'https://placehold.co/' . $m[1] . 'x' . $m[2];
			},
			$html
		);

		// 4. Other common offenders. Match `host/W/H[/anything]` shape.
		$html = preg_replace_callback(
			'#https?://(?:www\.)?(loremflickr\.com|placekitten\.com|placeimg\.com|fakeimg\.pl|placehold\.it|placehold\.jp)/([0-9]+)/([0-9]+)(?:[^\s"\'<>]*)#i',
			function ( $m ) use ( &$swap_count ) {
				$swap_count++;
				return 'https://placehold.co/' . $m[2] . 'x' . $m[3];
			},
			$html
		);

		// 5. unsplash.com `source.unsplash.com/{w}x{h}` and the random
		//    endpoint — same treatment, drop the photo content.
		$html = preg_replace_callback(
			'#https?://(?:source\.)?unsplash\.com/(?:random/)?([0-9]+)x([0-9]+)(?:[^\s"\'<>]*)#i',
			function ( $m ) use ( &$swap_count ) {
				$swap_count++;
				return 'https://placehold.co/' . $m[1] . 'x' . $m[2];
			},
			$html
		);

		if ( $swap_count > 0 ) {
			error_log( sprintf(
				'[AMB-IMG] rewrite_placeholder_image_urls: swapped %d URL(s) to placehold.co | input_chars=%d',
				$swap_count,
				$before_length
			) );
			// Surface to the editor's commentary so the user sees the AI
			// drift was auto-corrected. Useful signal for "is the prompt
			// rule being followed?" — frequent swaps mean we should
			// tighten the prompt; zero swaps means compliance is good.
			$this->send_sse_event( 'commentary', array(
				'phase'   => 'images_normalized',
				'message' => sprintf(
					/* translators: %d: number of placeholder image URLs rewritten */
					'Normalized %d placeholder image URL%s to placehold.co (model used a third-party host).',
					$swap_count,
					1 === $swap_count ? '' : 's'
				),
			) );
		}

		return $html;
	}

	/**
	 * Extract HTML from an AI's free-form text response.
	 *
	 * Tries multiple strategies in decreasing strictness. The first one
	 * that succeeds wins. Each strategy is logged via `[AMB-PLAN] extract`
	 * so a misbehaving response is greppable in `wp-content/debug.log`.
	 *
	 * Strategies:
	 *   1. Strict ```html fence with `<` immediately after.
	 *   2. Any fence ``` … ``` whose first non-whitespace char is `<`.
	 *   3. Any fence ``` … ``` whose body contains `<section …>` somewhere.
	 *   4. Section-pass-only: extract `<section data-amb-section="…">…</section>`
	 *      runs anywhere in the response, even without fences.
	 *   5. Any opener-led pattern: <!DOCTYPE, <html>, <section>, <div>.
	 *
	 * @param  string $content AI response text.
	 * @param  string $pass_mode 'foundation' | 'section' | 'tool_edit' | ''.
	 * @return string Extracted HTML, or '' if nothing recognizable was found.
	 */
	private function extract_html_from_ai_content( $content, $pass_mode = '' ) {
		$content = (string) $content;
		if ( '' === trim( $content ) ) {
			return '';
		}

		// Strategy 1: strict ```html fence, < required after the fence opener.
		if ( preg_match( '/```(?:html)?\s*(<[\s\S]*?)\s*```/is', $content, $m ) ) {
			error_log( '[AMB-PLAN] extract: strategy=1-strict-html-fence MATCHED' );
			return trim( $m[1] );
		}

		// Strategy 2: any fenced block whose body STARTS with `<` (some
		// models drop the `html` language tag).
		if ( preg_match( '/```\s*\n\s*(<[\s\S]*?)```/is', $content, $m ) ) {
			error_log( '[AMB-PLAN] extract: strategy=2-bare-fence-starts-with-tag MATCHED' );
			return trim( $m[1] );
		}

		// Strategy 3: any fence containing a `<section …>` somewhere inside.
		// Catches "```html\n<!-- task notes -->\n<section …>" where the fence
		// body doesn't start with `<` (some thinking-model variants).
		if ( preg_match( '/```(?:html)?\s*([\s\S]*?<(?:section|main|article|header|footer|nav)\b[\s\S]*?)```/is', $content, $m ) ) {
			$body = trim( $m[1] );
			// Trim leading prose/notes up to the first `<tag>`.
			if ( preg_match( '/<(?:!DOCTYPE|html|section|main|article|header|footer|nav|div|style|script)\b/i', $body, $opener_m, PREG_OFFSET_CAPTURE ) ) {
				$body = substr( $body, (int) $opener_m[0][1] );
			}
			error_log( '[AMB-PLAN] extract: strategy=3-fence-with-tag-inside MATCHED' );
			return trim( $body );
		}

		// Strategy 4 (section-pass only): grab every `<section data-amb-section="…">…</section>`
		// run from anywhere in the response. Catches the common drift where
		// the model emits sections inline as plain text with no fences.
		// We also keep adjacent `<style>` / `<script>` blocks so CSS/JS that
		// belongs to the new section travels with it.
		if ( 'section' === $pass_mode ) {
			$found = '';
			if ( preg_match_all( '/<style\b[^>]*>[\s\S]*?<\/style>/i', $content, $sm ) ) {
				$found .= implode( "\n", $sm[0] ) . "\n";
			}
			if ( preg_match_all( '/<section\b[^>]*data-amb-section=[^>]*>[\s\S]*?<\/section>/i', $content, $secm ) ) {
				$found .= implode( "\n", $secm[0] ) . "\n";
			}
			if ( preg_match_all( '/<script\b(?![^>]*\bsrc=)[^>]*>[\s\S]*?<\/script>/i', $content, $sm2 ) ) {
				$found .= implode( "\n", $sm2[0] ) . "\n";
			}
			if ( '' !== trim( $found ) ) {
				error_log( '[AMB-PLAN] extract: strategy=4-section-pass-tag-scan MATCHED (no fence)' );
				return trim( $found );
			}
		}

		// Strategy 5: any opener-led pattern. Greedy `[\s\S]+` is intentional
		// so we capture nested children up to the last matching close tag.
		if ( preg_match( '/(<!DOCTYPE[\s\S]+|<html[\s\S]+<\/html>|<section[\s\S]+<\/section>|<div[\s\S]+<\/div>)/i', $content, $m ) ) {
			error_log( '[AMB-PLAN] extract: strategy=5-opener-led MATCHED' );
			return trim( $m[1] );
		}

		error_log( '[AMB-PLAN] extract: ALL STRATEGIES FAILED — content has no recognizable HTML.' );
		return '';
	}

	private function normalize_section_append_html( $html, $existing_ids = array() ) {
		$html = trim( (string) $html );
		if ( '' === $html ) {
			return '';
		}

		// Build a fast lookup for IDs already on the page. Anything in
		// the AI's response with one of these IDs is by definition a
		// duplicate of a prior task's section — the model was supposed
		// to skip it, but if it emitted it anyway, we filter it out
		// here so it doesn't get spliced a second time (the root cause
		// of the "sections nested 5 deep" bug on later tasks).
		$existing_lookup = array();
		if ( is_array( $existing_ids ) ) {
			foreach ( $existing_ids as $id ) {
				$id = trim( (string) $id );
				if ( '' !== $id ) {
					$existing_lookup[ $id ] = true;
				}
			}
		}

		// If there's no doctype/html/body/main wrapper AND the snippet
		// has no duplicate-section concerns, the AI obeyed the rules —
		// no work to do.
		$lc = strtolower( $html );
		$is_wrapped = (
			false !== strpos( $lc, '<!doctype' )
			|| false !== strpos( $lc, '<html' )
			|| false !== strpos( $lc, '<body' )
			|| false !== strpos( $lc, '<main' )
		);

		// Even on un-wrapped output, run the dedup pass when we have
		// known existing IDs — the AI may emit a flat list of sections
		// that includes prior ones.
		if ( ! $is_wrapped && empty( $existing_lookup ) ) {
			return $html;
		}

		$prev = libxml_use_internal_errors( true );
		$dom  = new \DOMDocument();
		$load_ok = $dom->loadHTML(
			'<?xml encoding="utf-8"?><div id="__ambld_norm_root">' . $html . '</div>',
			LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
		);
		libxml_clear_errors();
		libxml_use_internal_errors( $prev );

		if ( ! $load_ok ) {
			return $html; // give up; let the frontend deal with it
		}

		$xpath = new \DOMXPath( $dom );

		// Top-level structural blocks (anywhere in the tree). We dedupe
		// by node identity to avoid duplicating a <section> that lives
		// inside another captured ancestor — but at this point the only
		// nesting we ever see is body > main > section, so a simple
		// "first match wins" XPath works.
		$blocks = $xpath->query(
			'//section[@data-amb-section] | //header[@data-amb-section]'
			. ' | //footer[@data-amb-section] | //nav[@data-amb-section]'
			. ' | //article[@data-amb-section] | //aside[@data-amb-section]'
		);

		// If the AI forgot the data-amb-section attribute, fall back to
		// any top-level structural element under main/body.
		if ( ! $blocks || 0 === $blocks->length ) {
			$blocks = $xpath->query(
				'(//main | //body)//*[self::section or self::header or self::footer'
				. ' or self::nav or self::article or self::aside][not(ancestor::section)]'
			);
		}

		// Head-level <style> blocks that the AI moved into <head> — we
		// want them inlined with the section snippet so the frontend's
		// CSS extractor can hoist them into customCss.
		$head_styles = $xpath->query( '//head/style | //*[@id="__ambld_norm_root"]/style' );

		// Bottom-level <script> blocks (no src — those are external loads
		// we never want to forward).
		$body_scripts = $xpath->query( '//body/script[not(@src)] | //main/script[not(@src)]' );

		$out = '';

		if ( $head_styles ) {
			foreach ( $head_styles as $node ) {
				$out .= $dom->saveHTML( $node ) . "\n";
			}
		}

		// Filter step: drop any block whose data-amb-section id already
		// exists on the page. This is the dedup that prevents the
		// "5 nested sections by task 5" failure mode — when the model
		// re-emits prior sections in addition to the new one (despite
		// the FORBIDDEN list in the system prompt), we keep ONLY the
		// new ones.
		$kept_count = 0;
		if ( $blocks && $blocks->length > 0 ) {
			foreach ( $blocks as $node ) {
				$node_id = method_exists( $node, 'getAttribute' )
					? trim( (string) $node->getAttribute( 'data-amb-section' ) )
					: '';
				if ( '' !== $node_id && isset( $existing_lookup[ $node_id ] ) ) {
					// Already on the page — skip.
					continue;
				}
				$out .= $dom->saveHTML( $node ) . "\n";
				$kept_count++;
			}
		}

		// Safety: if filtering removed every block (e.g. AI re-emitted
		// only existing sections without adding a new one), fall back
		// to returning whatever the AI sent so the user sees an error
		// rather than a silently swallowed response.
		if ( 0 === $kept_count && $blocks && $blocks->length > 0 ) {
			// Re-emit everything the AI returned; the frontend will
			// still apply this, but at least the user has visibility
			// into what came back. They can then re-prompt.
			$fallback = '';
			foreach ( $blocks as $node ) {
				$fallback .= $dom->saveHTML( $node ) . "\n";
			}
			// Prepend head styles + append scripts so the snippet is whole.
			$head_str = '';
			if ( $head_styles ) {
				foreach ( $head_styles as $node ) {
					$head_str .= $dom->saveHTML( $node ) . "\n";
				}
			}
			$script_str = '';
			if ( $body_scripts ) {
				foreach ( $body_scripts as $node ) {
					$script_str .= $dom->saveHTML( $node ) . "\n";
				}
			}
			return trim( $head_str . $fallback . $script_str );
		}

		if ( $body_scripts ) {
			foreach ( $body_scripts as $node ) {
				$out .= $dom->saveHTML( $node ) . "\n";
			}
		}

		$out = trim( $out );
		if ( '' === $out ) {
			return $html; // last-resort passthrough
		}

		return $out;
	}

	/**
	 * Detect whether the user's prompt is an edit request (imperative)
	 * rather than an informational/conversational one.
	 *
	 * The tool-edit flow forces the model to pick an edit tool — that's
	 * the wrong behavior for "what does this section do?". For those
	 * prompts we fall through to the conversational path instead.
	 *
	 * Returns false when the prompt is obviously informational; true for
	 * any other phrasing (default-allow so we don't miss edits with
	 * creative phrasing like "the hero needs more punch").
	 *
	 * @param  string $prompt
	 * @return bool
	 */
	private function detect_edit_intent( $prompt ) {
		$p = strtolower( trim( (string) $prompt ) );
		if ( '' === $p ) {
			return false;
		}

		// Ends with a question mark AND starts with a wh-word / auxiliary.
		if ( '?' === substr( $p, -1 ) ) {
			if ( preg_match( '/^\s*(what|which|why|when|where|who|how|can|could|does|do|is|are|should|tell me|show me|explain|list|describe)\b/', $p ) ) {
				return false;
			}
		}

		// Statements that are clearly informational (no edit verb).
		$informational = array(
			'/^(what|which|how|why|when|where|tell me|show me|explain|list|describe)\b/',
			'/^what (is|are|does|did|should|would|could|can)\b/',
			'/what (sections|does|is|are)\b/',
		);
		foreach ( $informational as $pattern ) {
			if ( preg_match( $pattern, $p ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Detect whether a prompt is asking to REFINE an existing thing
	 * (improve / redesign / enhance / etc.) vs. create something new.
	 *
	 * Used to gate pre-injection of existing section code and to tighten
	 * the system prompt instruction about modifying vs. generating.
	 *
	 * @param  string $prompt
	 * @return bool
	 */
	private function detect_refinement_intent( $prompt ) {
		$p = strtolower( trim( (string) $prompt ) );
		$keywords = array(
			'improve', 'refine', 'redesign', 'enhance', 'restyle', 'redo',
			'rework', 'update', 'modernize', 'revamp', 'upgrade', 'tweak',
			'adjust', 'change the', 'make it', 'make the', 'better', 'nicer',
			'fix the', 'clean up', 'polish', 'freshen', 'spice up', 'redo the',
		);
		foreach ( $keywords as $kw ) {
			if ( false !== strpos( $p, $kw ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Build a compact system prompt for the tool-edit flow.
	 *
	 * Deliberately minimal — no step markers, no ceremony prose, no
	 * output-format instructions (the tool schema is the contract).
	 * Keeps completion tokens tiny: the model's only job is to pick
	 * the right tool and supply its args.
	 *
	 * @param  string     $page_title
	 * @param  array      $registry          Section registry from extract_section_registry.
	 * @param  string     $css_registry      Compact :root vars + class registry (minified).
	 * @param  array|null $target_code       `{html,css,js}` if resolved, else null.
	 * @param  string     $kb_context
	 * @param  string     $attachment_context
	 * @param  bool       $is_refinement     Whether the prompt is a refinement intent.
	 * @param  array|null $pass_directive    Planned task directive from planner mode.
	 * @return string
	 */
	private function build_tool_edit_system_prompt( $page_title, $registry, $css_registry, $target_code, $kb_context = '', $attachment_context = '', $is_refinement = false, $pass_directive = null, $page_settings = array(), $js_errors = array() ) {
		$has_target = is_array( $target_code );

		$prompt  = "You are the AI edit assistant for Antimanual Builder. The user wants to modify an existing page.\n\n";
		$prompt .= "Your ONLY output is exactly one function call (tool call). DO NOT write prose, step markers, summaries, or 'Done!' messages — those are forbidden and would be rejected.\n\n";

		// Real runtime JS errors captured from the live preview iframe. These
		// are the ACTUAL failures the page threw — far more reliable than
		// guessing from a static read. Surfaced near the top so the AI treats
		// them as priority signal and routes to fix_js.
		if ( is_array( $js_errors ) && ! empty( $js_errors ) ) {
			$prompt .= "## Captured runtime JS errors (from the live preview — these are REAL, not hypothetical)\n";
			$prompt .= "The page's JavaScript threw the following error(s) on the user's screen. If the request is about something broken/not working, FIX THE ROOT CAUSE of these with `fix_js` (read the message + line, find the offending selector/statement in the section JS, emit corrected JS):\n";
			$max_err = 10;
			$shown   = 0;
			foreach ( $js_errors as $err ) {
				if ( $shown >= $max_err || ! is_array( $err ) ) {
					continue;
				}
				$msg  = isset( $err['message'] ) ? sanitize_text_field( (string) $err['message'] ) : '';
				if ( '' === $msg ) {
					continue;
				}
				$line  = isset( $err['lineno'] ) ? (int) $err['lineno'] : 0;
				$stack = isset( $err['stack'] ) ? trim( (string) $err['stack'] ) : '';
				$stack = '' !== $stack ? substr( preg_replace( '/\s+/', ' ', $stack ), 0, 240 ) : '';
				$prompt .= '- ' . $msg . ( $line > 0 ? " (line {$line})" : '' );
				if ( '' !== $stack ) {
					$prompt .= ' | stack: ' . $stack;
				}
				$prompt .= "\n";
				$shown++;
			}
			$prompt .= "\n";
		}

		// Same image-URL rule as build_system_prompt — repeated here because
		// tool-edit prompts are a separate code path that the model receives
		// in isolation, and placeholder-URL drift happens just as often in
		// `replace_section` / `append_section` calls as in legacy generation.
		$prompt .= "## MANDATORY: PLACEHOLDER IMAGE URLS (zero exceptions)\n";
		$prompt .= "Any `<img src>`, `srcset`, CSS `background-image: url(...)`, or `data-src` you place in your tool arguments MUST use https://placehold.co — and ONLY https://placehold.co.\n";
		$prompt .= "Pattern: `https://placehold.co/{W}x{H}` plain, or `https://placehold.co/{W}x{H}/{bgHex}/{fgHex}?text=Label` styled (hex WITHOUT #, spaces as `+`).\n";
		$prompt .= "FORBIDDEN: via.placeholder.com, dummyimage.com, picsum.photos, source.unsplash.com, unsplash.com, loremflickr.com, placekitten.com, placeimg.com, fakeimg.pl, placehold.it. The renderer will silently rewrite these to placehold.co, so emitting them is wasted output.\n\n";

		// Prepend planned task directive when in planner mode (tool_edit pass).
		if ( is_array( $pass_directive ) && ! empty( $pass_directive['index'] ) && ! empty( $pass_directive['label'] ) ) {
			$task_index  = absint( $pass_directive['index'] );
			$task_total  = isset( $pass_directive['total'] ) ? absint( $pass_directive['total'] ) : 0;
			$task_label  = sanitize_text_field( $pass_directive['label'] );
			$task_directive = isset( $pass_directive['directive'] ) ? wp_strip_all_tags( (string) $pass_directive['directive'] ) : '';

			$prompt .= "## Current Plan Task ({$task_index}" . ( $task_total ? " of {$task_total}" : '' ) . "): {$task_label}\n";
			if ( ! empty( $task_directive ) ) {
				$prompt .= $task_directive . "\n";
			}
			$prompt .= "\nThe user already approved this plan. Execute ONLY this task using the available tools. Do NOT do work from other tasks — they have their own passes.\n\n";
		}

		$prompt .= "## Tool-picking rules — verb-driven\n";
		$prompt .= "Pick the tool by reading the user's VERB. The verb signals intent; the visible scope of changes signals the tool.\n\n";
		$prompt .= "VERB → TOOL MAPPING (authoritative — apply this first, before any other heuristic):\n";
		$prompt .= "- \"fix\", \"polish\", \"tweak\", \"refine\", \"improve\", \"adjust\", \"clean up\", \"restyle\", \"make it nicer/cleaner/better looking\", \"make it more <adj>\", \"add hover/transition/animation\", \"add shadow/border/radius\", \"space it better\", \"align\", \"center\", \"resize\", \"recolor\", \"retype\" → ALWAYS `patch_css`. The HTML stays. Only CSS changes — but CSS can change ANYTHING (layout, grid, flex, spacing, sizing, typography, color, background, border, radius, shadow, transitions, hover/focus, animations, filters, transforms, decorative pseudo-elements, media queries). Do NOT call replace_section just because multiple CSS rules need to change. (Whole-PAGE palette/font/radius changes go to `set_global_style`, not patch_css — see COLOR/FONT SCOPE below.)\n";
		$prompt .= "- MARKUP TWEAK that is NOT a redesign — add/remove a class (INCLUDING animation classes like wow/aos/fadeInUp/animate__), set/remove an attribute (data-*, aria-*, id, loading), change a tag (h1→h2), insert ONE small element (divider, badge, icon, button), wrap/unwrap an element, or reorder/remove a single element → ALWAYS `patch_html`. It applies a small list of selector-scoped ops and NEVER re-emits the section, so it cannot truncate or drop content. STRONGLY prefer it over replace_section for anything short of a full redesign. For an \"every section\" markup change (e.g. add scroll-animation classes to all sections), call `patch_html` ONCE PER SECTION.\n";
		$prompt .= "- \"change\", \"replace\", \"redesign\", \"rewrite\", \"rebuild\", \"recreate\", \"swap out\", \"convert this section into…\", \"turn this into a <different layout>\", \"new copy/headline/items\", \"add several new elements / a whole new content block\", \"different layout\" → `replace_section`. Use ONLY for a genuine structural redesign or substantial new content; emit complete new_html (+ new_css and new_js as needed). For a single class/attribute/tag/one-element tweak use `patch_html` instead — it can't truncate the section.\n";
		$prompt .= "- Request needs a THIRD-PARTY LIBRARY (Swiper, GSAP, AOS, Chart.js, Splide, an icon or font stylesheet, etc.) → call `attach_cdn` FIRST (inline <script src> / <link rel=\"stylesheet\"> / @import are STRIPPED and will NOT load — attach_cdn is the only way), THEN patch_js / replace_section to write the code that uses its globals.\n";
		$prompt .= "- \"add a new section\", \"insert a section\", \"another section\", \"one more section after this\" → `append_section`. ONLY when a NEW SIBLING SECTION is requested.\n";
		$prompt .= "- \"remove\", \"delete\", \"drop\", \"take out\", \"hide this section\" → `delete_section`.\n\n";
		$prompt .= "TIE-BREAK RULES (when verbs are ambiguous):\n";
		$prompt .= "- If the request can be satisfied by CSS alone (no new/removed/reordered/retyped elements, no copy changes), ALWAYS use `patch_css`. Multiple CSS rules across multiple selectors is fine — pass them all in the `css` param scoped to `section_id`.\n";
		$prompt .= "- If the request needs a SMALL markup change (toggle a class/attribute, change a tag, insert/wrap/reorder/remove ONE element) → `patch_html`. If it needs a NEW content block, several new elements, new copy text, or a structural redesign → `replace_section`.\n";
		$prompt .= "- COLOR/FONT SCOPE: a single section's colors/fonts → `patch_css`; the WHOLE page's palette, color scheme, all fonts, or all button radii (any token spanning multiple sections) → `set_global_style` (it edits the :root custom properties every section reads). The word \"recolor\" in the verb list above means a single section — page-wide palette changes are set_global_style.\n";
		$prompt .= "- A subjective \"make it look better\" with no markup-changing detail → `patch_css` (broad CSS overhaul of the section is allowed).\n\n";
		$prompt .= "PATCH_CSS USAGE (multi-rule mode, preferred for any non-trivial styling pass):\n";
		$prompt .= "- Call shape: { \"section_id\": \"<resolved id>\", \"css\": \"<full CSS block — every rule the section needs, scoped to section selectors>\" }\n";
		$prompt .= "- The `css` value REPLACES the section's CSS region wholesale — include every rule the section needs (even unchanged ones), not just deltas.\n";
		$prompt .= "- Use the section's existing root class to scope rules (e.g. .hero-section, .hero-section h1, .hero-section .cta:hover) so styles don't leak.\n";
		$prompt .= "- For a single-selector change, the legacy { selector, declarations } shape is still accepted.\n\n";

		$prompt .= "## JavaScript & Interactivity — MAXIMUM EFFORT\n";
		$prompt .= "The AI MUST add JavaScript when the user asks for interaction, animation, or behavior. Do NOT default to CSS-only when JS would deliver a significantly better result.\n\n";
		$prompt .= "TOOL ROUTING for JS changes:\n";
		$prompt .= "- ADD new interactivity / behavior / animation → `patch_js`. Do NOT use replace_section just to add JS.\n";
		$prompt .= "- FIX a JS bug (something broken / not working / a captured runtime error above) → `fix_js`.\n";
		$prompt .= "- HTML + JS change (genuinely new layout that needs behavior) → `replace_section` with new_js param.\n";
		$prompt .= "- CSS :hover / transitions / @keyframes → `patch_css` only.\n\n";
		$prompt .= "WHEN TO USE JS (not just CSS):\n";
		$prompt .= "\"make it interactive\" / \"add interaction\" → patch_js with the appropriate behavior.\n";
		$prompt .= "\"add scroll animation\" / \"fade in on scroll\" / \"scroll reveal\" → patch_js with IntersectionObserver.\n";
		$prompt .= "\"add tabs\" / \"make it a tabbed layout\" → patch_js with full tab logic.\n";
		$prompt .= "\"add accordion\" / \"make FAQ collapsible\" → patch_js.\n";
		$prompt .= "\"animate the numbers\" / \"counter animation\" → patch_js with requestAnimationFrame counter.\n";
		$prompt .= "\"add a modal\" / \"add a popup\" → patch_js.\n";
		$prompt .= "\"sticky nav\" / \"shrink on scroll\" → patch_js.\n";
		$prompt .= "\"animate\" / \"add movement\" / \"needs more life\" → patch_js for real animation, pair with CSS for transitions.\n\n";

		$prompt .= "## TRANSFORM an existing layout — PRESERVE the existing design (critical)\n";
		$prompt .= "When the user asks to turn existing content into a slider/carousel/tabs/accordion/grid/masonry (\"make the reviews a slider\", \"make these cards a carousel\", \"turn the features into tabs\"), the existing child elements are ALREADY DESIGNED. Your job is to make them BEHAVE differently, NOT to redesign them.\n";
		$prompt .= "RULES:\n";
		$prompt .= "- PRESERVE every existing child's markup, classes, and content VERBATIM. Do NOT regenerate the cards/items or change their styling.\n";
		$prompt .= "- Implement with `patch_js` (operate on the EXISTING DOM: wrap the current children in a track/wrapper at runtime, add prev/next + drag/swipe + keyboard, scroll-snap) PLUS `patch_css` (track overflow, flex, scroll-snap, transitions). You may call patch_js then patch_css in the same turn.\n";
		$prompt .= "- Do NOT use `replace_section` for these transforms — it rebuilds and destroys the existing design. Only use replace_section if the user EXPLICITLY says to redesign/rebuild/change the content.\n";
		$prompt .= "SLIDER/CAROUSEL DEFAULT: show MULTIPLE items per view (responsive: e.g. 3 on desktop, 2 on tablet, 1 on mobile) using flex-basis / scroll-snap — NEVER 1-per-view unless the user explicitly asked for one-at-a-time.\n";
		$prompt .= "If — and only if — the transform truly cannot proceed without choosing between materially different layouts that would discard the existing design (and a sensible default does not exist), call `ask_clarification` FIRST with concrete options (e.g. items-per-view). Otherwise apply the default above and proceed; do NOT ask for cosmetic or low-stakes choices.\n\n";
		$prompt .= "## Bug Fixing & Code Audit\n";
		$prompt .= "When the user says a section is broken, not working, or behaving unexpectedly:\n";
		$prompt .= "MANDATORY FLOW: 1) If a \"Captured runtime JS errors\" block is present above, START there — it names the real failure. Otherwise call search_section to read the current HTML, CSS, AND JS. 2) Diagnose: is the bug in the HTML structure, CSS conflict, or JS logic? 3) Fix with the minimal-change tool: `fix_js` for JS bugs (preferred for any JS failure), patch_css for CSS bugs, replace_section only if the HTML itself is wrong.\n";
		$prompt .= "4) DID IT TAKE EFFECT? If you fix a section's JS and the bug PERSISTS — or a tool reports the page did not change — do NOT re-run the same fix on the same section (that just loops). A page-level handler often lives OUTSIDE a single section: call `get_page_code` to read the FULL JS, find the DUPLICATE/conflicting handler (e.g. an old copy with a wrong selector running alongside yours), and fix it at the source. For \"fix the JS of the WHOLE page\" requests, prefer get_page_code up front so you see every handler at once.\n\n";
		$prompt .= "COMMON JS BUG PATTERNS to check:\n";
		$prompt .= "- Selector not found (querySelector returned null → unguarded null reference) → fix: add null check.\n";
		$prompt .= "- Event listener attached before DOM ready → fix: ensure scoped to section + DOM-ready.\n";
		$prompt .= "- Class name mismatch between HTML and JS → fix: align selectors.\n";
		$prompt .= "- Multiple initializations (init runs twice on re-render) → fix: guard with data attribute flag.\n";
		$prompt .= "- JS referencing a global variable that doesn't exist → fix: define or remove the reference.\n\n";
		$prompt .= "JS SAFETY RULES (mandatory for ALL section JS):\n";
		$prompt .= "1. Scope ALL selectors to the section root:\n";
		$prompt .= "   const section = document.querySelector('[data-amb-section=\"SECTION_ID\"]');\n";
		$prompt .= "   if (!section) return;\n";
		$prompt .= "   Then use section.querySelector() / section.querySelectorAll() for all children.\n";
		$prompt .= "2. Wrap all logic in try/catch: try { ... } catch(e) { console.warn('[AMB:SECTION_ID]', e); }\n";
		$prompt .= "3. Guard every DOM query: const el = section.querySelector('.class'); if (!el) return;\n";
		$prompt .= "4. Use const/let only — never var.\n";
		$prompt .= "5. No eval(), no document.write().\n";
		$prompt .= "6. Vanilla JS only — no jQuery, no external libraries.\n";
		$prompt .= "7. Do NOT include <script> tags in patch_js args — the builder adds them.\n\n";
		$prompt .= "JS TEMPLATE (use this pattern every time):\n";
		$prompt .= "(function() {\n";
		$prompt .= "  const section = document.querySelector('[data-amb-section=\"SECTION_ID\"]');\n";
		$prompt .= "  if (!section) return;\n";
		$prompt .= "  try {\n";
		$prompt .= "    // your code here\n";
		$prompt .= "    const btn = section.querySelector('.my-button');\n";
		$prompt .= "    if (!btn) return;\n";
		$prompt .= "    btn.addEventListener('click', function() { ... });\n";
		$prompt .= "  } catch(e) { console.warn('[AMB:SECTION_ID]', e); }\n";
		$prompt .= "})();\n\n";

		$prompt .= "## Non-Technical Phrase Translation (read user prompts through this lens FIRST)\n";
		$prompt .= "Translate EMOTIONAL or AESTHETIC language to the correct tool before applying the verb table:\n\n";
		$prompt .= "\"make it pop\" / \"needs more punch\" → patch_css: increase contrast, bold key typography, add box-shadow or border to CTAs, strengthen accent colors.\n";
		$prompt .= "\"looks too busy\" / \"overwhelming\" / \"too much going on\" → patch_css: increase padding/whitespace, reduce to 2-3 colors, simplify decorations.\n";
		$prompt .= "\"looks cheap\" / \"amateurish\" / \"unprofessional\" → patch_css: improve type scale, add depth via box-shadow, increase whitespace, smooth border-radius.\n";
		$prompt .= "\"looks boring\" / \"too plain\" / \"needs more life\" → patch_css: add gradient backgrounds, increase color saturation, add hover transitions.\n";
		$prompt .= "\"more professional\" / \"looks corporate\" / \"too corporate\" → patch_css: cool the palette (navy/grey/white), tighten spacing, conservative radius (6-8px).\n";
		$prompt .= "\"looks outdated\" / \"old-fashioned\" → patch_css if cosmetic; replace_section if the layout is genuinely archaic.\n";
		$prompt .= "\"change the color scheme\" / \"make it [color]\" / \"different palette\" → ALWAYS call set_global_style (update :root custom property vars). NEVER use patch_css for whole-page palette changes.\n";
		$prompt .= "\"change all fonts\" / \"different typeface everywhere\" → set_global_style (update --font-heading / --font-body root vars).\n";
		$prompt .= "\"make buttons rounder\" / \"sharper corners\" → set_global_style (update --border-radius) OR patch_css targeting button selectors.\n";
		$prompt .= "\"make it like Apple\" → patch_css: white-dominant background, massive whitespace, restrained sans typography, flat shadows, centered layouts.\n";
		$prompt .= "\"make it like Airbnb\" → patch_css: warm tones, coral accent, card grids, photography-forward, trust badges.\n";
		$prompt .= "\"make it like Stripe\" → patch_css: deep navy/purple gradient, technical density, Inter-feel typography, dark-section contrast.\n";
		$prompt .= "\"make it like Linear\" → patch_css: dark mode, neon purple accents, monospace elements, developer-forward.\n";
		$prompt .= "\"make it like Notion\" → patch_css: off-white background, ultra-minimal, clean sans, soft grey hierarchy.\n";
		$prompt .= "\"needs more breathing room\" / \"too cramped\" → patch_css: increase section padding (4rem → 6-8rem), increase gap between child elements.\n";
		$prompt .= "\"buttons don't stand out\" / \"CTA is hard to find\" → patch_css: target button selectors, increase contrast, add hover transform/shadow.\n";
		$prompt .= "\"needs more movement\" / \"feels static\" → patch_css: add CSS transitions, hover states, @keyframes fade-in on scroll.\n";
		$prompt .= "\"make it interactive\" / \"add interaction\" / \"add interactivity\" → patch_js: choose the right behavior (tabs, accordion, carousel, modal, counter, scroll reveal).\n";
		$prompt .= "\"something is broken\" / \"not working\" / \"this doesn't work\" / \"the X isn't working\" / \"broken\" / \"fix it\" / \"fix the bug\"\n";
		$prompt .= "  → BUG AUDIT FLOW: check the \"Captured runtime JS errors\" block first (if present); otherwise call search_section to read HTML + CSS + JS. Diagnose the root cause. Fix with minimal tool: `fix_js` for JS bugs, patch_css for CSS bugs, replace_section only if the HTML structure is broken.\n";
		$prompt .= "\"debug\" / \"why isn't this working\" / \"audit the code\" → same BUG AUDIT FLOW: captured errors / search_section → diagnose → fix_js / patch_css. If the fix doesn't stick, escalate to get_page_code to find a duplicate/conflicting handler before retrying.\n\n";

		if ( $has_target ) {
			$prompt .= "A TARGET SECTION IS PRE-RESOLVED for this request (see ## Target section code below). The user has either selected this section in the canvas or named it explicitly — they want it MODIFIED, not duplicated.\n";
			$prompt .= "- Apply the verb table above. The full HTML of the target section is in this prompt — read it as context for any patch_css call.\n";
			$prompt .= "- Pass the resolved section_id verbatim, NEVER invent a new id.\n";
			$prompt .= "- USE `delete_section` ONLY if the user explicitly asks to remove the section.\n";
			$prompt .= "- DO NOT use `append_section` unless the user EXPLICITLY asks for a NEW SIBLING SECTION. Adding a card/button/item INSIDE the existing section is `replace_section`.\n";
			$prompt .= "- DO NOT pick a different section than the resolved target — the user is editing THIS section.\n";
			if ( $is_refinement ) {
				$prompt .= "CRITICAL — REFINEMENT MODE: The user wants to IMPROVE/REFINE this section. Strongly prefer `patch_css` (multi-rule). If you must use `replace_section`, your `new_html` MUST be a direct improvement of the existing code shown — do NOT generate a new design from scratch. Preserve ALL existing class names, CSS custom properties, and layout structure unless the user explicitly asked to change them.\n";
			}
		} else {
			$prompt .= "No target pre-resolved.\n";
			$prompt .= "- YOU MUST call `search_section` FIRST before any edit tool. It is FORBIDDEN to call replace_section or patch_css without first reading the existing code via search_section.\n";
			$prompt .= "- After search_section returns, apply the verb table above to choose between patch_css / replace_section / append_section / delete_section.\n";
		}
		$prompt .= "\n";

		// --- Template constraints (header/footer attached) ---
		if ( ! empty( $page_settings ) ) {
			$tpl_state = \AMBLD_Render_Template_Resolver::resolve_attachment_state( $page_settings );
			$tpl_constraints = \AMBLD_Render_Template_Resolver::build_constraints_prompt( $tpl_state );
			if ( ! empty( $tpl_constraints ) ) {
				$prompt .= $tpl_constraints . "\n";
				$prompt .= "Tool-edit specific: NEVER call `append_section` or `replace_section` to create a header / nav / masthead / footer / copyright strip while the template is attached. If the user asks for one, refuse via a short `commentary`-style intent and DO NOT emit any tool call that would build it.\n\n";
			}
		}

		$prompt .= "## Output contract for HTML/CSS you emit inside tool args\n";
		$prompt .= "- Production-dense: no blank lines, minimal indentation.\n";
		$prompt .= "- Reuse existing classes and :root variables — never redefine them.\n";
		$prompt .= "- Every element needs a semantic class (hero-title, feature-card, etc.).\n";
		$prompt .= "- Use `var(--name)` to reference existing design tokens.\n";
		$prompt .= "- Do NOT include `<!-- [AMB:id] -->` or `/* [AMB:id] */` markers in your tool arguments — the server adds them automatically.\n\n";

		// --- Placeholder section rename guidance ---
		$prompt .= "## Placeholder section rename rule\n";
		$prompt .= "If the target section has class `ambld-new-section` (an auto-generated placeholder with no real content), treat it as a EMPTY SECTION. When the user asks to edit/refine it (e.g., \"make this a hero\", \"add content to this section\"), you MUST:\n";
		$prompt .= "1. Use `replace_section` (not `patch_css`)\n";
		$prompt .= "2. Replace the ENTIRE section HTML with production content for the requested type (hero, features, CTA, etc.)\n";
		$prompt .= "3. Do NOT preserve `ambld-new-section` class, `min-height`, or the `<!-- New section — describe it in the AI chat -->` comment — these are placeholders, not design intent.\n";
		$prompt .= "In short: a placeholder section is a blank canvas. Treat it like a new section you're building from scratch, just with the existing section id.\n\n";

		// --- Page outline ---
		$prompt .= "## Page sections (id — tag — classes)\n";
		if ( empty( $registry ) ) {
			$prompt .= "(no sections on the page yet)\n";
		} else {
			foreach ( $registry as $sec ) {
				$classes = empty( $sec['classes'] ) ? '' : ' .' . implode( ' .', $sec['classes'] );
				$prompt .= '- ' . $sec['id'] . ' — ' . $sec['tag'] . $classes . "\n";
			}
		}
		$prompt .= "\n";

		// --- CSS registry (class + var vocabulary) ---
		if ( ! empty( $css_registry ) ) {
			$prompt .= "## CSS registry (selectors + :root vars that ALREADY exist — reuse, do NOT redefine)\n";
			$prompt .= $css_registry . "\n\n";
		}

		// --- Target section ---
		if ( is_array( $target_code ) && ! empty( $target_code['anchor'] ) ) {
			$prompt .= "## Target section\n";
			$prompt .= "Pre-resolved: `" . $target_code['anchor'] . "`.\n";
			$prompt .= "YOUR FIRST CALL MUST be `get_section` with section_id=\"" . $target_code['anchor'] . "\" to read the current HTML/CSS/JS. Do NOT edit without reading first — never generate new code from scratch when existing code can be modified.\n\n";
		} else {
			$prompt .= "## Target section\n";
			$prompt .= "Not pre-resolved. YOUR FIRST CALL MUST be `search_section` — pass the section name or keyword from the user's prompt. It is FORBIDDEN to call replace_section or patch_css without first reading the existing code via search_section.\n";
			$prompt .= "**Keyword selection rule:** prefer the section's `data-amb-section` id. If you only know the section by class, use the FIRST class on the `<section>` element (the canonical section class — e.g. `services-section` from `class=\"services-section animate-fade-in\"`). NEVER pass animation/utility/decorator classes (`.animate-fade-in`, `.is-visible`, `.scroll-reveal`) as the keyword — those are not section identifiers.\n\n";
		}

		if ( ! empty( $page_title ) ) {
			$prompt .= "Page title: " . $page_title . "\n\n";
		}

		if ( ! empty( $kb_context ) ) {
			$prompt .= "## Knowledge base\n" . $kb_context . "\n\n";
		}

		if ( ! empty( $attachment_context ) ) {
			$prompt .= "## Attached files\n" . $attachment_context . "\n\n";
		}

		$prompt .= "## Examples (study these before responding)\n\n";
		$prompt .= "User: \"make the hero pop more\"\n";
		$prompt .= "→ patch_css { section_id: \"hero\", css: \".hero-section { background: #0f172a; } .hero-title { font-size: 72px; font-weight: 800; } .hero-cta:hover { transform: scale(1.05); box-shadow: 0 8px 24px rgba(0,0,0,0.3); }\" }\n";
		$prompt .= "WRONG: replace_section (unnecessary — CSS achieves this fully)\n\n";
		$prompt .= "User: \"the testimonials look boring, redesign them as cards\"\n";
		$prompt .= "→ replace_section { section_id: \"testimonials\", new_html: \"...\", new_css: \"...\" }\n";
		$prompt .= "REASON: \"redesign as cards\" means a layout change — replace_section is correct\n\n";
		$prompt .= "User: \"change the color scheme to deep blue\"\n";
		$prompt .= "→ set_global_style { root_vars: \":root { --brand-primary: #1e3a5f; --brand-accent: #3b82f6; --brand-bg: #f0f6ff; }\", summary: \"Updated palette to deep blue\" }\n";
		$prompt .= "WRONG: patch_css on individual sections (misses sections not yet targeted, and doesn't update :root tokens)\n\n";
		$prompt .= "User: \"add a testimonials section after the hero\"\n";
		$prompt .= "→ append_section { after_id: \"hero\", new_html: \"...\", new_css: \"...\" }\n\n";
		$prompt .= "User: \"move the CTA section before the footer\"\n";
		$prompt .= "→ move_section { section_id: \"cta\", position: \"before\", anchor_id: \"footer\" }\n\n";

		return $prompt;
	}

	/**
	 * Read the external/third-party library URLs already enqueued on a page.
	 *
	 * These are remote/CDN scripts (and ES modules) captured at import time and
	 * stored in `_ambld_imported_js_urls`. The page's own inline/local JS lives in
	 * `customJs` and reaches the AI as `currentJs`; this list is supplied
	 * separately so the AI knows which globals are available without being
	 * handed (or asked to edit) third-party source.
	 *
	 * @param  int $post_id Post ID.
	 * @return string[] List of script URLs (may be empty).
	 */
	private function get_loaded_library_urls( $post_id ) {
		if ( $post_id <= 0 ) {
			return array();
		}

		$raw = get_post_meta( $post_id, '_ambld_imported_js_urls', true );
		if ( empty( $raw ) ) {
			return array();
		}

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

		return array_values( array_filter( $decoded, 'is_string' ) );
	}

	/**
	 * Orchestrate the full tool-edit flow — pre-resolve, loop up to 3
	 * tool calls, execute, emit SSE events.
	 *
	 * On success: streams `tool_executed` + `page_refined` SSE events,
	 *   then `operation_summary` + `done`. Returns true.
	 * On unrecoverable tool error: returns false so the caller can fall
	 *   back to the legacy refinement path.
	 *
	 * @param  array  $ctx Context bag.
	 * @return bool        True when handled end-to-end, false for fallback.
	 */
	private function handle_tool_edit_flow( $ctx ) {
		$provider           = $ctx['provider'];
		$api_key            = $ctx['api_key'];
		$settings           = $ctx['settings'];
		$messages           = $ctx['messages'];
		$current_html       = $ctx['current_html'];
		$current_css        = $ctx['current_css'];
		$current_js         = isset( $ctx['current_js'] ) ? $ctx['current_js'] : '';
		$js_errors          = isset( $ctx['js_errors'] ) && is_array( $ctx['js_errors'] ) ? $ctx['js_errors'] : array();
		$loaded_libraries   = isset( $ctx['loaded_libraries'] ) && is_array( $ctx['loaded_libraries'] ) ? $ctx['loaded_libraries'] : array();
		$imported_scripts   = isset( $ctx['imported_scripts'] ) && is_array( $ctx['imported_scripts'] ) ? $ctx['imported_scripts'] : array();
		$cdn_resources      = isset( $ctx['cdn_resources'] ) && is_array( $ctx['cdn_resources'] ) ? $ctx['cdn_resources'] : array();
		$selected_section   = $ctx['selected_section'];
		$page_title         = $ctx['page_title'];
		$page_settings     = isset( $ctx['page_settings'] ) ? $ctx['page_settings'] : array();
		$kb_context         = $ctx['kb_context'];
		$attachment_context = $ctx['attachment_context'];
		$image_attachments = isset( $ctx['image_attachments'] ) ? $ctx['image_attachments'] : array();
		$user_prompt        = $ctx['user_prompt'];
		$start_time         = $ctx['start_time'];
		$max_output_tokens  = isset( $ctx['max_output_tokens'] ) ? (int) $ctx['max_output_tokens'] : 3000;
		$thinking_budget    = isset( $ctx['thinking_budget'] ) ? (int) $ctx['thinking_budget'] : 0;
		$reasoning_effort   = AMBLD_Antimanual_Ai_Provider::normalize_reasoning_effort(
			isset( $ctx['reasoning_effort'] ) ? (string) $ctx['reasoning_effort'] : 'low'
		);
		$pass_directive     = isset( $ctx['pass_directive'] ) ? $ctx['pass_directive'] : null;

		// Pre-resolve target.
		$resolved = $this->resolve_edit_target( $user_prompt, $selected_section, $current_html );

		$registry = $this->extract_section_registry( $current_html );

		// A planner per-section task carries its exact target id in the
		// directive (scope "section:<id>"). Honor it over keyword resolution —
		// the directive also names sibling sections (completed/upcoming lists),
		// which otherwise make keyword matching tie and fall back to a guess.
		if ( is_array( $pass_directive ) && ! empty( $pass_directive['sectionId'] ) ) {
			$hinted_id = sanitize_text_field( (string) $pass_directive['sectionId'] );
			$reg_ids   = array_column( $registry, 'id' );
			if ( in_array( $hinted_id, $reg_ids, true ) ) {
				$resolved = $hinted_id;
			} else {
				$fuzzy = $this->match_section_keyword( strtolower( $hinted_id ), $reg_ids );
				if ( null !== $fuzzy ) {
					$resolved = $fuzzy;
				}
			}
		}

		error_log( sprintf(
			'[AMB] handle_tool_edit_flow: prompt="%s" | resolved=%s | known_sections=[%s] | html_chars=%d | css_chars=%d',
			substr( $user_prompt, 0, 80 ),
			$resolved ? '"' . $resolved . '"' : 'null (AI will choose)',
			implode( ', ', array_column( $registry, 'id' ) ),
			strlen( $current_html ),
			strlen( $current_css )
		) );

		// Build CSS registry for the system prompt. Lightweight inline
		// implementation mirroring buildCssRegistry on the frontend —
		// only keeps :root custom properties + top-level selector names.
		$css_registry = $this->build_css_registry_for_prompt( $current_css );

		// Detect refinement intent (improve / refine / redesign / etc.).
		$is_refinement = $this->detect_refinement_intent( $user_prompt );

		$this->send_sse_event( 'commentary', array(
			'phase'   => 'analyzing',
			'message' => 'Analyzing: "' . substr( $user_prompt, 0, 60 ) . ( strlen( $user_prompt ) > 60 ? '…' : '' ) . '"',
		) );

		// Lazy-load architecture: no page code is pushed upfront.
		// The AI calls get_section / search_section / get_page_code tools
		// to fetch whatever it actually needs.
		if ( $resolved ) {
			// Pass only the resolved ID — full code is fetched on-demand via tool.
			$target_code = array( 'anchor' => $resolved );
			$this->send_sse_event(
				'progress_update',
				array(
					'stepId' => 'locate',
					'status' => 'done',
					'label'  => 'Located "' . $resolved . '"',
				)
			);
		} else {
			$target_code = null;
			$this->send_sse_event(
				'progress_update',
				array(
					'stepId' => 'locate',
					'status' => 'active',
					'label'  => 'Choosing target section',
				)
			);
		}

		$system_prompt = $this->build_tool_edit_system_prompt(
			$page_title,
			$registry,
			$css_registry,
			$target_code,
			$kb_context,
			$attachment_context,
			$is_refinement,
			$pass_directive,
			$page_settings,
			$js_errors
		);

		// All read tools (get_section, search_section, get_page_code) are always
		// exposed — the AI decides which one to call based on what it needs.
		$tools = $this->build_edit_tool_registry( true, ! empty( $imported_scripts ) );

		// Chat messages fed to the provider. We only keep the last few
		// turns so the tool-call decision isn't polluted by old HTML.
		$recent = $this->get_recent_messages( $messages, 4 );

		// Tell the AI which external libraries are already loaded on the page so
		// it can call into their globals (jQuery, Swiper, GSAP, …) and does NOT
		// re-add <script> tags for them — script tags in edited HTML are stripped
		// on save, and these references load before the page's own JS.
		if ( ! empty( $loaded_libraries ) ) {
			$recent[] = array(
				'role'    => 'user',
				'content' => "CONTEXT — these external scripts are already loaded on this page (do NOT re-add <script> tags for them; you may rely on the globals they expose):\n"
					. implode( "\n", array_map( static function( $url ) { return '- ' . $url; }, $loaded_libraries ) ),
			);
		}

		// External-library policy. Inline <script src> / <link rel="stylesheet">
		// in edited HTML are stripped on save and never load — the AI must use
		// the attach_cdn tool. Also list what's already attached so it doesn't
		// duplicate.
		$cdn_lines = array();
		foreach ( $cdn_resources as $res ) {
			if ( is_array( $res ) && ! empty( $res['url'] ) ) {
				$cdn_lines[] = '- ' . strtoupper( isset( $res['type'] ) ? $res['type'] : 'js' ) . ': ' . $res['url'];
			}
		}
		$cdn_policy = "⚠️ EXTERNAL LIBRARIES — HARD RULE (no exceptions):\n"
			. "If ANY third-party library is needed (Swiper, GSAP, AOS, Alpine, Chart.js, Lottie, icon sets, Google/remote fonts, a CSS framework, etc.) you MUST attach it with the `attach_cdn` tool, passing its CDN URL.\n"
			. "You are FORBIDDEN from putting a library reference in markup: do NOT emit `<script src=\"…\">`, `<link rel=\"stylesheet\" href=\"…\">`, `@import`, or paste a library's minified source into any HTML/JS you return via replace_section / append_section / patch_js / edit_js_file. Every such tag is STRIPPED on save and on the live page — it will simply not load, and the feature will silently break.\n"
			. "Correct sequence: (1) call attach_cdn for each library URL, then (2) write the code that USES it with patch_js / replace_section. The library's global (e.g. `Swiper`, `gsap`) is available because attach_cdn loads it before your code.\n"
			. "If you don't know an exact, current CDN URL, prefer a vanilla-JS/CSS implementation instead of guessing — never inline a library.";
		if ( ! empty( $cdn_lines ) ) {
			$cdn_policy .= "\nAlready attached (do NOT re-attach these):\n" . implode( "\n", $cdn_lines );
		}
		$recent[] = array(
			'role'    => 'user',
			'content' => $cdn_policy,
		);

		// Tool-routing guidance. The model decides which tool fits the prompt —
		// this just teaches the cost model so it stops re-emitting whole
		// sections for small markup edits (the cause of truncation on large
		// pages) and handles cross-cutting "every section" requests safely.
		$recent[] = array(
			'role'    => 'user',
			'content' => "TOOL ROUTING — pick the smallest tool that does the job:\n"
				. "• Class / attribute / tag / small-element / wrap / remove change to markup → patch_html (selector-scoped ops). It NEVER re-emits the section, so it can't truncate or drop content. PREFER it over replace_section for anything that isn't a real redesign.\n"
				. "• CSS-only change → patch_css. JS-only/behavior → patch_js. New library → attach_cdn.\n"
				. "• replace_section ONLY when the section's structure/content is genuinely being redesigned.\n"
				. "CROSS-CUTTING 'every / all sections' requests — choose by what actually changes:\n"
				. "  1) If it's expressible as ONE shared rule, do a single edit: patch_css with a shared selector like `[data-amb-section]{…}`, or patch_js with one listener over `document.querySelectorAll('[data-amb-section]')`. Do NOT patch each section separately when one rule covers them.\n"
				. "  2) If it's per-element MARKUP (animation classes, a divider in each, attributes), call patch_html ONCE PER SECTION — small op list each, never replace_section. (You will be invoked per-section by the planner; handle the section(s) named in this turn.)\n"
				. "  3) Only fall back to per-section replace_section when each section must be structurally rebuilt — one section per call.\n"
				. "Never try to load or rewrite all sections at once — that overflows the output and breaks the page.",
		);

		// Tell the AI which JavaScript files it can edit (the imported-script
		// manifest). The AI targets one by `id` via the edit_js_file tool —
		// JS changes go to these files, not the section markup.
		if ( ! empty( $imported_scripts ) ) {
			$file_blocks = array();
			foreach ( $imported_scripts as $i => $entry ) {
				if ( ! is_array( $entry ) || ( isset( $entry['kind'] ) && 'remote' === $entry['kind'] ) ) {
					continue;
				}
				$sid     = isset( $entry['id'] ) ? (string) $entry['id'] : (string) $i;
				$label   = isset( $entry['label'] ) && '' !== $entry['label']
					? (string) $entry['label']
					: ( ( isset( $entry['kind'] ) ? $entry['kind'] : 'script' ) . ' #' . ( $i + 1 ) );
				$content = isset( $entry['content'] ) ? (string) $entry['content'] : '';
				$file_blocks[] = "### file id: \"{$sid}\"  (label: {$label})\n" . $content;
			}
			if ( ! empty( $file_blocks ) ) {
				$recent[] = array(
					'role'    => 'user',
					'content' => "EDITABLE JAVASCRIPT FILES — this page's own JavaScript lives in these separate files. To change behavior, call the `edit_js_file` tool with the file's `id` and the full new contents. Do NOT use replace_section for JS-only changes.\n\n"
						. implode( "\n\n", $file_blocks ),
				);
			}
		}

		// Tell the AI which external libraries are already loaded, so it
		// references their globals instead of re-attaching (and knows what's
		// available). JS libraries live in the manifest as `remote` entries;
		// stylesheets in the CDN-resource list.
		$attached_libs = array();
		foreach ( $imported_scripts as $entry ) {
			if ( is_array( $entry ) && isset( $entry['kind'], $entry['src'] ) && 'remote' === $entry['kind'] && '' !== (string) $entry['src'] ) {
				$attached_libs[] = '- JS: ' . (string) $entry['src'];
			}
		}
		foreach ( $cdn_resources as $entry ) {
			if ( is_array( $entry ) && isset( $entry['type'], $entry['url'] ) && 'css' === $entry['type'] && '' !== (string) $entry['url'] ) {
				$attached_libs[] = '- CSS: ' . (string) $entry['url'];
			}
		}
		if ( ! empty( $attached_libs ) ) {
			$recent[] = array(
				'role'    => 'user',
				'content' => "ATTACHED LIBRARIES — these external resources are already loaded on this page. Reference their globals directly; do NOT re-attach them.\n" . implode( "\n", $attached_libs ),
			);
		}

		// Current page state we'll mutate as tool calls execute.
		$page_state = array(
			'html'    => $current_html,
			'css'     => $current_css,
			'js'      => $current_js,
			'scripts' => $imported_scripts,
			'cdn'     => $cdn_resources,
		);

		$tool_call_budget = 6; // Allow up to 5 actual AI calls (search + multi-tool fix).
		$invalid_in_a_row = 0;
		$pending_ok_state    = null;  // Accumulated state from successful edit calls.
		$pending_ok_summary  = '';    // Summary of all successful edits so far.
		$pending_ok_tools    = array(); // Tool names used in successful edits.
		$edited_sections     = array(); // Distinct section ids touched by surgical edits.
		$noop_streak         = 0;       // Consecutive surgical edits that changed nothing.

		for ( $attempt = 0; $attempt < $tool_call_budget; $attempt++ ) {
			error_log( sprintf( '[AMB] tool_edit_loop: attempt %d/%d', $attempt + 1, $tool_call_budget ) );
			$this->send_sse_event(
				'progress_update',
				array(
					'stepId' => 'write',
					'status' => 'active',
					'label'  => $resolved ? 'Writing the change' : 'Planning the change',
				)
			);

			// Provider call. Reasoning/thinking budgets come from the
			// user's General-tab settings (override the conservative
			// tool-edit defaults). OpenAI-compatible providers (xAI,
			// DeepSeek, OpenCodeZen) reuse the OpenAI client with their
			// own base URL — `reasoning_effort` is OpenAI-only and is
			// dropped for the others by the call site.
			if ( AMBLD_Antimanual_Ai_Provider::is_openai_compatible( $provider ) ) {
				$model = ! empty( $settings['model'] )
					? (string) $settings['model']
					: ( isset( $settings[ $provider . '_model' ] ) ? (string) $settings[ $provider . '_model' ] : 'gpt-5-mini' );
				$base_url = ! empty( $settings['base_url'] )
					? (string) $settings['base_url']
					: AMBLD_Antimanual_Ai_Provider::get_base_url( $provider );
				$reasoning_for_call = ( 'openai' === $provider ) ? $reasoning_effort : '';
				$call_result = $this->call_openai_tool_edit( $api_key, $model, $system_prompt, $recent, $tools, $reasoning_for_call, $max_output_tokens, $base_url, $image_attachments );
			} elseif ( 'gemini' === $provider ) {
				$model = isset( $settings['gemini_model'] ) ? $settings['gemini_model'] : 'gemini-3-flash-preview';
				$call_result = $this->call_gemini_tool_edit( $api_key, $model, $system_prompt, $recent, $tools, $thinking_budget, $max_output_tokens, $image_attachments );
			} else {
				return false;
			}

			if ( is_wp_error( $call_result ) ) {
				$this->send_sse_event(
					'commentary',
					array(
						'phase'   => 'tool_edit_error',
						'message' => $call_result->get_error_message(),
					)
				);
				return false; // fall back
			}

			// Surface tool-edit MAX_TOKENS the same way the legacy path does.
			// Tool-edit hit-rate suffers heavily here: the model truncates
			// its tool-call JSON, which decodes to broken args, which
			// produce no DOM mutation.
			$tool_finish = isset( $call_result['finish_reason'] ) ? (string) $call_result['finish_reason'] : '';
			if ( '' !== $tool_finish ) {
				error_log( sprintf(
					'[AMB-BUDGET] tool-edit returned finish_reason=%s | applied_max_output_tokens=%d',
					$tool_finish,
					$max_output_tokens
				) );
				if ( 'length' === $tool_finish || 'MAX_TOKENS' === $tool_finish ) {
					$this->send_sse_event( 'commentary', array(
						'phase'   => 'max_tokens',
						'message' => sprintf(
							/* translators: %d: applied max-output-tokens value */
							'Tool-edit hit the max-output-tokens ceiling (%d). The model\'s function call was truncated; this section may not have changed. Raise "Maximum Output Tokens" in plugin Settings → AI Token Budget.',
							$max_output_tokens
						),
					) );
				}
			}

			$tool_name = isset( $call_result['name'] ) ? (string) $call_result['name'] : '';
			$tool_args = isset( $call_result['args'] ) ? (array) $call_result['args'] : array();

			// Model emitted no tool call.
			if ( '' === $tool_name ) {
				if ( null !== $pending_ok_state ) {
					// AI finished its fix sequence — emit the accumulated result.
					error_log( '[AMB] ai_tool_call: AI done after multi-step fix — finalising' );
					$this->send_sse_event(
						'page_refined',
						array(
							'html'    => $pending_ok_state['html'],
							'css'     => $pending_ok_state['css'],
							'js'      => $pending_ok_state['js'],
							'scripts' => null,
							'message' => $pending_ok_summary,
						)
					);
					$elapsed = round( ( microtime( true ) - $start_time ), 2 );
					$this->send_sse_event(
						'operation_summary',
						array(
							'operation' => 'refine',
							'summary'   => $pending_ok_summary,
							'details'   => $pending_ok_tools,
							'elapsed'   => $elapsed,
						)
					);
					$this->send_sse_event( 'done', array() );
					return true;
				}
				error_log( '[AMB] ai_tool_call: AI returned NO tool call — falling back to legacy refinement' );
				return false;
			}

			$args_preview = array();
			foreach ( $tool_args as $k => $v ) {
				$args_preview[ $k ] = is_string( $v ) && strlen( $v ) > 80 ? substr( $v, 0, 77 ) . '…' : $v;
			}
			error_log( sprintf(
				'[AMB] ai_tool_call: tool="%s" | args=%s',
				$tool_name,
				wp_json_encode( $args_preview )
			) );

			// The specific tool the model picked (patch_css / replace_section /
			// search_section …) is an internal implementation detail — it is
			// logged above for debugging but intentionally NOT surfaced to the
			// user via a `commentary` SSE event. The activity card should show
			// human-facing progress, not tool-call mechanics.

			// Execute.
			$exec = $this->execute_edit_tool( $tool_name, $tool_args, $page_state );
			error_log( sprintf(
				'[AMB] execute_edit_tool result: status="%s"%s',
				$exec['status'],
				isset( $exec['error'] ) ? ' | error="' . $exec['error'] . '"' : ( isset( $exec['summary'] ) ? ' | summary="' . $exec['summary'] . '"' : '' )
			) );

			if ( 'ok' === $exec['status'] ) {
				$new_state = $exec['state'];

				// Normalize image hosts (replace_section / append_section supply
				// new HTML; patch_css may emit background-image urls).
				$new_state['html'] = $this->rewrite_placeholder_image_urls( $new_state['html'] );
				$new_state['css']  = $this->rewrite_placeholder_image_urls( $new_state['css'] );

				$is_surgical = ( 'patch_js' === $tool_name || 'patch_css' === $tool_name || 'patch_html' === $tool_name || 'fix_js' === $tool_name || 'attach_cdn' === $tool_name );

				// No-op guard: did this surgical call actually change the page?
				// A surgical tool that returns an identical page is a dead end —
				// the model re-writing a marker block with code it already has,
				// or a fix that can't take effect because a conflicting copy
				// lives OUTSIDE the section's editable scope. Repeating it is the
				// runaway-loop symptom (5× fix_js on one section), so we break
				// out of it instead of burning the whole budget.
				// Compare ALL mutable state, not just html/css/js: attach_cdn
				// changes only `scripts` (JS libraries → manifest) or `cdn`
				// (stylesheets), so omitting them would misread a real library
				// attach as a dead-end no-op.
				$prev_scripts = isset( $page_state['scripts'] ) ? $page_state['scripts'] : null;
				$next_scripts = isset( $new_state['scripts'] ) ? $new_state['scripts'] : null;
				$prev_cdn     = isset( $page_state['cdn'] ) ? $page_state['cdn'] : null;
				$next_cdn     = isset( $new_state['cdn'] ) ? $new_state['cdn'] : null;
				$state_changed = $new_state['html'] !== $page_state['html']
					|| $new_state['css'] !== $page_state['css']
					|| $new_state['js'] !== $page_state['js']
					|| $prev_scripts != $next_scripts
					|| $prev_cdn != $next_cdn;

				if ( $is_surgical && ! $state_changed ) {
					$noop_streak++;
					error_log( sprintf(
						'[AMB] tool_edit_loop: surgical no-op (tool=%s, section=%s, streak=%d)',
						$tool_name,
						isset( $tool_args['section_id'] ) ? (string) $tool_args['section_id'] : '?',
						$noop_streak
					) );

					// A real edit already landed this turn → the fix is in;
					// further no-op retries add nothing. Finalize with it.
					if ( null !== $pending_ok_state ) {
						$elapsed = round( ( microtime( true ) - $start_time ), 2 );
						$this->send_sse_event(
							'page_refined',
							array(
								'html'    => $pending_ok_state['html'],
								'css'     => $pending_ok_state['css'],
								'js'      => $pending_ok_state['js'],
								'scripts' => null,
								'message' => $pending_ok_summary,
							)
						);
						$this->send_sse_event(
							'operation_summary',
							array(
								'operation' => 'refine',
								'summary'   => $pending_ok_summary,
								'details'   => $pending_ok_tools,
								'elapsed'   => $elapsed,
							)
						);
						$this->send_sse_event( 'done', array() );
						return true;
					}

					// Nothing has changed all turn. Stop after a second dead
					// attempt and tell the user honestly rather than loop.
					if ( $noop_streak >= 2 ) {
						$this->send_sse_event(
							'assistant_message',
							array(
								'content'  => "I couldn't apply that change by editing the section's code — the code I wrote is already present, or the behavior is controlled by a conflicting script elsewhere on the page. Try asking me to \"rebuild this section\" (I'll regenerate its markup + JS cleanly), or open the JS tab in code view to spot the duplicate handler.",
								'toolName' => 'noop_stop',
							)
						);
						$this->send_sse_event( 'done', array() );
						return true;
					}

					// First dead attempt — nudge the model toward a different
					// approach instead of repeating the same edit.
					$recent[] = array(
						'role'    => 'user',
						'content' => 'That edit produced NO change to the page — the code you wrote is already present, or the relevant behavior is defined by a DIFFERENT script on the page. Do NOT repeat the same edit. Either call get_page_code to inspect the FULL JS and locate the conflicting/duplicate handler, use replace_section to rewrite this section cleanly, or — if it is already correct — stop and call no tools.',
					);
					$invalid_in_a_row = 0;
					continue;
				}

				$noop_streak = 0;
				$page_state  = $new_state;

				// Accumulate for potential multi-step fix sequences.
				$pending_ok_state    = $page_state;
				$pending_ok_summary  = '' !== $pending_ok_summary
					? $pending_ok_summary . ' + ' . $exec['summary']
					: $exec['summary'];
				$pending_ok_tools[]  = $tool_name;
				if ( $is_surgical && isset( $tool_args['section_id'] ) && '' !== (string) $tool_args['section_id'] ) {
					$edited_sections[ (string) $tool_args['section_id'] ] = true;
				}

				$this->send_sse_event(
					'tool_executed',
					array(
						'tool'    => $tool_name,
						'args'    => $this->sanitize_tool_args_for_ui( $tool_args ),
						'summary' => $exec['summary'],
					)
				);
				$this->send_sse_event(
					'progress_update',
					array(
						'stepId' => 'write',
						'status' => 'active',
						'label'  => $exec['summary'],
					)
				);

				// Terminal tools complete the operation in a single call — emit
				// immediately.  Only patch_js and patch_css are surgical enough
				// to chain (e.g. a bug that requires both a JS fix AND a CSS fix
				// in one turn).  attach_cdn also chains — it's a precursor: the
				// AI attaches a library, then writes the code that uses it in the
				// next hop.  Allowing replace_section / append_section /
				// move_section / etc. to continue causes duplicate rebuilds when
				// the AI treats each continuation hop as an invitation to
				// "improve" the section it just replaced.
				// ($is_surgical was computed above for the no-op guard.)

				if ( ! $is_surgical ) {
					$elapsed = round( ( microtime( true ) - $start_time ), 2 );
					$this->send_sse_event(
						'page_refined',
						array(
							'html'    => $pending_ok_state['html'],
							'css'     => $pending_ok_state['css'],
							'js'      => $pending_ok_state['js'],
							'scripts' => ( 'edit_js_file' === $tool_name && isset( $pending_ok_state['scripts'] ) ) ? $pending_ok_state['scripts'] : null,
							'message' => $pending_ok_summary,
						)
					);
					$this->send_sse_event(
						'operation_summary',
						array(
							'operation' => 'refine',
							'summary'   => $pending_ok_summary,
							'details'   => $pending_ok_tools,
							'elapsed'   => $elapsed,
						)
					);
					$this->send_sse_event( 'done', array() );
					return true;
				}

				// Surgical tool succeeded — allow one more complementary patch.
				// Budget check: if no room left, finalize now.
				$remaining_budget = $tool_call_budget - $attempt - 1;
				if ( $remaining_budget <= 0 ) {
					$elapsed = round( ( microtime( true ) - $start_time ), 2 );

					// Fast mode (no planner) chains surgical edits in a single
					// turn capped at the tool budget. Hitting the cap after a
					// run of surgical edits means a cross-cutting request (e.g.
					// "animate every section") was likely cut off mid-way — tell
					// the user instead of silently finishing partial. The planner
					// path fans out per section, so it's exempt (pass_directive
					// is set there).
					if ( empty( $pass_directive ) ) {
						// Distinguish a genuine multi-section cut-off from a run of
						// edits that all hammered ONE section (the stuck-loop
						// shape) — the advice "run it again to continue" only
						// helps the former; for the latter it just re-loops.
						if ( count( $edited_sections ) <= 1 ) {
							$budget_msg = sprintf(
								"I applied %d edits to one section but ran out of edit room this turn without fully resolving it — there's likely a conflicting script elsewhere on the page fighting the change. Open the JS tab in code view to check for a duplicate handler, or ask me to \"rebuild this section\" for a clean regeneration of its markup + JS.",
								count( $pending_ok_tools )
							);
						} else {
							$budget_msg = sprintf(
								"I applied %d edits but hit this turn's edit limit, so a multi-section change may not be finished. Run the same request again to continue where I left off — or switch to **Plan mode** (the Plan/Fast toggle by the send button) to apply it across every section in one pass.",
								count( $pending_ok_tools )
							);
						}
						$this->send_sse_event(
							'assistant_message',
							array(
								'content'  => $budget_msg,
								'toolName' => 'partial_budget',
							)
						);
					}

					$this->send_sse_event(
						'page_refined',
						array(
							'html'    => $pending_ok_state['html'],
							'css'     => $pending_ok_state['css'],
							'js'      => $pending_ok_state['js'],
							'scripts' => null,
							'message' => $pending_ok_summary,
						)
					);
					$this->send_sse_event(
						'operation_summary',
						array(
							'operation' => 'refine',
							'summary'   => $pending_ok_summary,
							'details'   => $pending_ok_tools,
							'elapsed'   => $elapsed,
						)
					);
					$this->send_sse_event( 'done', array() );
					return true;
				}

				// Inject a targeted continuation: only complementary surgical
				// tools are allowed as a follow-up (e.g. a slider transform that
				// needs both patch_js for behavior AND patch_css for the track).
				$recent[] = array(
					'role'    => 'user',
					'content' => 'Patch applied: ' . $exec['summary'] . ".\n\n"
						. "If the CSS is ALSO needed (e.g. slider track overflow/flex, layout, invisible elements), call `patch_css` now.\n"
						. "If the JS is ALSO needed (interaction/behavior), call `patch_js` now. To repair a JS bug, call `fix_js`.\n"
						. "If the change is COMPLETE, do NOT call any more tools — stop immediately.",
				);
				$invalid_in_a_row = 0;
				continue;
			}

			if ( 'clarify' === $exec['status'] ) {
				// ask_clarification — surface the question to the user and end the
				// turn WITHOUT editing. The user clicks an option in the chat,
				// which re-sends the original prompt with their answer appended.
				$this->send_sse_event(
					'tool_executed',
					array(
						'tool'    => $tool_name,
						'args'    => $this->sanitize_tool_args_for_ui( $tool_args ),
						'summary' => 'Asked for clarification',
					)
				);
				$this->send_sse_event(
					'clarification_needed',
					array(
						'question'       => $exec['question'],
						'options'        => $exec['options'],
						'originalPrompt' => isset( $ctx['user_prompt'] ) ? (string) $ctx['user_prompt'] : '',
					)
				);
				$this->send_sse_event( 'done', array() );
				return true;
			}

			if ( 'read' === $exec['status'] ) {
				// get_section / search_section / get_page_code executed — feed result back and loop.
				$result_payload = wp_json_encode( $exec['result'] );
				$result_section_id = isset( $exec['result']['section_id'] )
					? $exec['result']['section_id']
					: ( isset( $tool_args['section_id'] ) ? $tool_args['section_id'] : '' );

				if ( 'get_page_code' === $tool_name ) {
					$tool_label = 'Retrieved full page code';
					$inject_msg = 'Full page code: ' . $result_payload . "\n\n"
						. "You now have the complete HTML, CSS, and JS for the entire page.\n\n"
						. "DIAGNOSE based on the user's request, then call the appropriate edit tool(s):\n"
						. "- Global CSS token changes (colors, fonts, radius, spacing) → set_global_style\n"
						. "- Section-scoped CSS fix → patch_css\n"
						. "- Section JS fix → patch_js\n"
						. "- Section HTML rebuild → replace_section\n"
						. "- Multiple sections → call tools in sequence (patch_css section A, then patch_css section B)\n\n"
						. 'Apply the MINIMUM change that fulfils the request.';
				} else {
					$is_search  = ( 'search_section' === $tool_name );
					$tool_label = $is_search
						? 'Fetched code for "' . ( isset( $exec['result']['section_id'] ) ? $exec['result']['section_id'] : ( isset( $tool_args['keyword'] ) ? $tool_args['keyword'] : '' ) ) . '"'
						: 'Read section "' . $result_section_id . '"';
					$inject_msg = 'Tool result for ' . $tool_name . ': ' . $result_payload . "\n\n"
						. 'You now have the complete HTML, CSS, and JS for section "' . $result_section_id . "\".\n\n"
						. "DIAGNOSE the issue based on the user's request and the code above, then call the correct tool(s):\n"
						. "- JS bug (null refs, broken selectors, event listeners not firing, undefined vars, logic errors) → patch_js\n"
						. "- CSS bug (layout broken, element invisible, wrong sizing/color/overflow) → patch_css\n"
						. "- HTML structure broken (wrong markup, missing containers, semantic issues) → replace_section\n"
						. "- Multiple issues → call patch_js first, then patch_css (or vice versa) — you can chain calls\n"
						. "- Cosmetic improvement (not a bug) → patch_css or replace_section per the verb table\n\n"
						. 'Apply the MINIMUM change that fixes the problem. Do NOT rebuild the whole section unless the HTML is fundamentally wrong.';
				}

				$recent[] = array(
					'role'    => 'user',
					'content' => $inject_msg,
				);

				$this->send_sse_event(
					'tool_executed',
					array(
						'tool'    => $tool_name,
						'args'    => $this->sanitize_tool_args_for_ui( $tool_args ),
						'summary' => $tool_label,
					)
				);
				$invalid_in_a_row = 0;
				continue;
			}

			// status === 'error'
			$invalid_in_a_row++;
			$this->send_sse_event(
				'commentary',
				array(
					'phase'   => 'tool_edit_invalid',
					'message' => $exec['error'],
				)
			);

			if ( $invalid_in_a_row >= 2 ) {
				return false; // fall back to refinement
			}

			// Provide a corrective tool result for the next hop.
			$recent[] = array(
				'role'    => 'user',
				'content' => 'The previous tool call failed: ' . $exec['error']
					. ( ! empty( $exec['valid'] ) ? "\nValid ids: " . implode( ', ', $exec['valid'] ) : '' )
					. "\nTry again with a valid argument.",
			);
		}

		// Loop exhausted — if we accumulated at least one successful edit, emit it.
		if ( null !== $pending_ok_state ) {
			$this->send_sse_event(
				'page_refined',
				array(
					'html'    => $pending_ok_state['html'],
					'css'     => $pending_ok_state['css'],
					'js'      => $pending_ok_state['js'],
					'scripts' => null,
					'message' => $pending_ok_summary,
				)
			);
			$elapsed = round( ( microtime( true ) - $start_time ), 2 );
			$this->send_sse_event(
				'operation_summary',
				array(
					'operation' => 'refine',
					'summary'   => $pending_ok_summary,
					'details'   => $pending_ok_tools,
					'elapsed'   => $elapsed,
				)
			);
			$this->send_sse_event( 'done', array() );
			return true;
		}

		return false; // loop exhausted with no successful edit
	}

	/**
	 * Trim tool args before echoing them back to the UI via `tool_executed`.
	 *
	 * Long `new_html`/`new_css` blobs would bloat the SSE payload and add
	 * nothing — the UI only needs the tool name + a label-sized preview.
	 *
	 * @param  array $args
	 * @return array
	 */
	private function sanitize_tool_args_for_ui( $args ) {
		$out = array();
		foreach ( $args as $k => $v ) {
			if ( is_string( $v ) && strlen( $v ) > 160 ) {
				$out[ $k ] = substr( $v, 0, 157 ) . '…';
			} elseif ( is_scalar( $v ) ) {
				$out[ $k ] = $v;
			}
		}
		return $out;
	}

	/**
	 * Server-side CSS registry builder — minified compact form used in
	 * the tool-edit system prompt. Mirrors buildCssRegistry in
	 * src/editor/utils/html-merge.ts; kept here because we don't want a
	 * provider round-trip just to ship the registry.
	 *
	 * @param  string $css Full customCss blob.
	 * @return string     Registry string (empty when input is empty).
	 */
	private function build_css_registry_for_prompt( $css ) {
		if ( empty( $css ) ) {
			return '';
		}

		$css = $this->minify_content( $css );
		if ( '' === $css ) {
			return '';
		}

		$lines       = array();
		$custom_vars = array();
		$len         = strlen( $css );
		$i           = 0;

		$read_until_brace = function () use ( $css, &$i, $len ) {
			$start = $i;
			while ( $i < $len && '{' !== $css[ $i ] && ';' !== $css[ $i ] && '}' !== $css[ $i ] ) {
				$i++;
			}
			return trim( substr( $css, $start, $i - $start ) );
		};

		$read_body = function () use ( $css, &$i, $len ) {
			if ( $i >= $len || '{' !== $css[ $i ] ) {
				return '';
			}
			$i++;
			$start = $i;
			$depth = 1;
			while ( $i < $len && $depth > 0 ) {
				$ch = $css[ $i ];
				if ( '{' === $ch ) {
					$depth++;
				} elseif ( '}' === $ch ) {
					$depth--;
				}
				if ( 0 === $depth ) {
					break;
				}
				$i++;
			}
			$body = substr( $css, $start, $i - $start );
			if ( $i < $len && '}' === $css[ $i ] ) {
				$i++;
			}
			return $body;
		};

		$collect_vars = function ( $body ) use ( &$custom_vars ) {
			if ( preg_match_all( '/(--[A-Za-z0-9_-]+)\s*:\s*([^;]+);?/', $body, $vm, PREG_SET_ORDER ) ) {
				foreach ( $vm as $v ) {
					$custom_vars[ $v[1] ] = trim( $v[2] );
				}
			}
		};

		while ( $i < $len ) {
			while ( $i < $len && ctype_space( $css[ $i ] ) ) {
				$i++;
			}
			if ( $i >= $len ) {
				break;
			}
			if ( '}' === $css[ $i ] ) {
				$i++;
				continue;
			}

			$prefix = $read_until_brace();
			if ( '' === $prefix ) {
				if ( $i < $len && ( ';' === $css[ $i ] ) ) {
					$i++;
				}
				continue;
			}

			if ( '@' === $prefix[0] ) {
				$at = '';
				if ( preg_match( '/^@[\w-]+/', $prefix, $am ) ) {
					$at = strtolower( $am[0] );
				}
				if ( '@import' === $at || '@charset' === $at || '@namespace' === $at ) {
					if ( $i < $len && ';' === $css[ $i ] ) {
						$i++;
					}
					continue;
				}
				if ( '@media' === $at || '@supports' === $at || '@container' === $at || '@layer' === $at ) {
					$body = $read_body();
					// Recurse (simple — just list selectors).
					$inner_lines = array();
					$jj          = 0;
					$blen        = strlen( $body );
					while ( $jj < $blen ) {
						while ( $jj < $blen && ctype_space( $body[ $jj ] ) ) {
							$jj++;
						}
						if ( $jj >= $blen ) {
							break;
						}
						$s = $jj;
						while ( $jj < $blen && '{' !== $body[ $jj ] ) {
							$jj++;
						}
						$sel = trim( substr( $body, $s, $jj - $s ) );
						if ( '' !== $sel && $jj < $blen ) {
							$inner_lines[] = '  ' . $sel . ' {}';
							$jj++;
							$depth = 1;
							while ( $jj < $blen && $depth > 0 ) {
								$ch = $body[ $jj ];
								if ( '{' === $ch ) {
									$depth++;
								} elseif ( '}' === $ch ) {
									$depth--;
								}
								$jj++;
							}
						} else {
							$jj++;
						}
					}
					if ( ! empty( $inner_lines ) ) {
						$lines[] = $prefix . ' {';
						$lines   = array_merge( $lines, $inner_lines );
						$lines[] = '}';
					}
					continue;
				}
				if ( '@keyframes' === $at ) {
					$lines[] = $prefix . ' {}';
					$read_body();
					continue;
				}
				// Other at-rule with body.
				$lines[] = $prefix . ' {}';
				$read_body();
				continue;
			}

			// Normal selector — collect its vars, then emit empty body.
			$body = $read_body();
			$collect_vars( $body );
			$lines[] = $prefix . ' {}';
		}

		$out = array();
		if ( ! empty( $custom_vars ) ) {
			$out[] = ':root {';
			foreach ( $custom_vars as $name => $value ) {
				$out[] = '  ' . $name . ': ' . $value . ';';
			}
			$out[] = '}';
		}
		$out = array_merge( $out, $lines );
		return implode( "\n", $out );
	}

	/**
	 * Keep only the most recent conversation turns for provider requests.
	 *
	 * @param array $messages Conversation history.
	 * @param int   $limit    Max messages to keep.
	 * @return array
	 */
	private function get_recent_messages( $messages, $limit = 8 ) {
		if ( ! is_array( $messages ) ) {
			return array();
		}

		return array_slice( $messages, 0 - absint( $limit ) );
	}

	/**
	 * Emit a simple keepalive heartbeat while waiting on the AI provider.
	 *
	 * This sends a lightweight SSE status event so the browser connection
	 * does not time out during long AI generations. No fake commentary.
	 *
	 * @param array $state Streaming state.
	 * @param bool  $force Whether to force an immediate heartbeat.
	 * @return void
	 */
	private function emit_keepalive_heartbeat( &$state, $force = false ) {
		$now = microtime( true );

		if ( ! $force && ( $now - $state['last_heartbeat_at'] ) < 8.0 ) {
			return;
		}

		$this->send_sse_event(
			'status',
			array(
				'step'    => 'calling_ai',
				'message' => 'Waiting for AI response...',
			)
		);

		$state['last_heartbeat_at'] = $now;
	}

	/**
	 * Process a Gemini streamed SSE event.
	 *
	 * @param string $event          SSE event text.
	 * @param array  $result         Aggregated result.
	 * @param array  $tool_call_map  Aggregated tool calls keyed by name.
	 * @param array  $state          Streaming state.
	 * @return void
	 */
	private function process_gemini_stream_event( $event, &$result, &$tool_call_map, &$state ) {
		$lines      = preg_split( '/\r?\n/', $event );
		$data_lines = array();

		foreach ( $lines as $line ) {
			if ( 0 === strpos( $line, 'data:' ) ) {
				$data_lines[] = ltrim( substr( $line, 5 ) );
			}
		}

		$payload = trim( implode( "\n", $data_lines ) );

		// If no data: lines were found, the response body may be plain JSON
		// (e.g. a 400/401/403 error from Gemini). Try to parse the raw event.
		if ( empty( $payload ) ) {
			$raw = trim( $event );
			if ( ! empty( $raw ) ) {
				$raw_decoded = json_decode( $raw, true );
				if ( is_array( $raw_decoded ) && isset( $raw_decoded['error']['message'] ) ) {
					$state['api_error_message'] = $raw_decoded['error']['message'];
				}
			}
			return;
		}

		if ( '[DONE]' === $payload ) {
			return;
		}

		$decoded = json_decode( $payload, true );

		if ( ! is_array( $decoded ) ) {
			return;
		}

		if ( isset( $decoded['error']['message'] ) ) {
			$state['api_error_message'] = $decoded['error']['message'];
			return;
		}

		if ( empty( $state['saw_first_chunk'] ) ) {
			$state['saw_first_chunk'] = true;
			$this->send_sse_event(
				'status',
				array(
					'step'    => 'calling_ai',
					'message' => 'AI is generating...',
				)
			);
		}

		$parts = isset( $decoded['candidates'][0]['content']['parts'] ) && is_array( $decoded['candidates'][0]['content']['parts'] )
			? $decoded['candidates'][0]['content']['parts']
			: array();

		foreach ( $parts as $part ) {
			if ( isset( $part['text'] ) ) {
				$chunk = $part['text'];
				$result['content'] .= $chunk;
				$this->send_sse_event( 'token', array( 'content' => $chunk ) );

				// Detect [STEP: ...] markers in the accumulated content.
				$this->detect_and_emit_steps( $result['content'], $state );
			}
		}

		// Capture finishReason from the final chunk. Gemini emits it on
		// the LAST `candidates[0]` object. Stored on $result so the caller
		// can log and surface MAX_TOKENS to the user.
		if ( isset( $decoded['candidates'][0]['finishReason'] ) && '' !== (string) $decoded['candidates'][0]['finishReason'] ) {
			$result['finish_reason'] = (string) $decoded['candidates'][0]['finishReason'];
		}
	}




	/**
	 * Send a progress step update.
	 *
	 * @param string $step_id Step identifier.
	 * @param string $status  Step status (pending, active, done).
	 * @param string $label   Optional label override.
	 * @param string $message Optional UI status message.
	 * @return void
	 */
	private function send_progress_update( $step_id, $status, $label = '', $message = '' ) {
		$this->send_sse_event(
			'progress_update',
			array(
				'stepId'    => sanitize_text_field( $step_id ),
				'status'    => sanitize_text_field( $status ),
				'label'     => sanitize_text_field( $label ),
				'message'   => sanitize_text_field( $message ),
				'timestamp' => round( microtime( true ) * 1000 ),
			)
		);
	}

	/**
	 * Handle SSE streaming chat request.
	 *
	 * Uses SSE for client updates while the backend coordinates provider
	 * calls and tool/function execution. Gemini is streamed incrementally
	 * so the UI keeps receiving heartbeat updates instead of appearing
	 * stuck during long generations.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return void Outputs SSE stream directly.
	 */
	public function chat_stream( $request ) {
		$messages         = $request->get_param( 'messages' );
		$current_html     = $request->get_param( 'currentHtml' ) ?: '';
		$current_css      = $request->get_param( 'currentCss' ) ?: '';
		$current_js       = $request->get_param( 'currentJs' ) ?: '';
		$js_errors        = $request->get_param( 'jsErrors' );
		$js_errors        = is_array( $js_errors ) ? $js_errors : array();
		$imported_scripts = $request->get_param( 'importedScripts' );
		$imported_scripts = is_array( $imported_scripts ) ? $imported_scripts : array();
		// Live external-CDN list (from the editor). Falls back to saved meta
		// below when the request omits it.
		$cdn_resources    = $request->get_param( 'cdnResources' );
		$cdn_resources    = is_array( $cdn_resources ) ? $cdn_resources : array();
		$selected_section = $request->get_param( 'selectedSection' ) ?: '';
		$page_title       = $request->get_param( 'pageTitle' ) ?: '';
		$page_settings    = $request->get_param( 'pageSettings' ) ?: array();

		// Merge global design defaults (set in plugin Settings → Design) into
		// the per-page settings. The sidebar UI falls back to defaults for
		// display, but historically only `pageSettings` reached the AI — so
		// when a user set Brand Colors / fonts / tone globally and never
		// overrode them on a specific page, the AI never saw them. Per-page
		// values still win; defaults only fill holes. Header/footer template
		// fallback is handled deeper in `AMBLD_Render_Template_Resolver`, so we
		// don't need to copy those keys here.
		$page_settings = $this->merge_design_defaults( is_array( $page_settings ) ? $page_settings : array() );
		$use_kb_context   = rest_sanitize_boolean( $request->get_param( 'useKnowledgeBase' ) );
		$raw_attachments  = $request->get_param( 'attachments' ) ?: array();
		$approved_plan    = $request->get_param( 'approvedPlan' );
		$pass_directive   = $request->get_param( 'passDirective' );
		$entity_type      = (string) $request->get_param( 'entityType' );
		$template_kind    = (string) $request->get_param( 'templateKind' );
		$settings = AMBLD_Antimanual_Ai_Provider::get_runtime_settings();
		$provider = isset( $settings['provider'] ) ? $settings['provider'] : 'openai';
		$api_key  = $this->get_api_key( $settings, $provider );

		if ( empty( $api_key ) ) {
			$this->send_error_response( AMBLD_Antimanual_Ai_Provider::get_status_message() );
			return;
		}

		// --- Agent harness: bracket the turn with retrieval, guardrails, and a turn log ---
		$post_id = (int) $request->get_param( 'postId' );
		$harness = new AMBLD_Agent_Harness();
		$harness->set_provider( $provider, isset( $settings['model'] ) ? (string) $settings['model'] : '' );
		$harness->set_post_id( $post_id );
		$this->ambld_harness = $harness; // expose so downstream methods (tool-edit, refine, etc.) can record tool calls and sources

		// Regenerate signal — if there's a still-pending turn for this post,
		// flipping it to `regenerated` means the user didn't accept the prior
		// output and is asking the AI to try again. Bump helpfulness on each
		// of that turn's sources downward (bounded inside bump_helpfulness).
		if ( $post_id > 0 && class_exists( 'AMBLD_Agent_Turn_Logger' ) ) {
			$prior = AMBLD_Agent_Turn_Logger::mark_last_turn_outcome( $post_id, 'regenerated' );
			if ( is_array( $prior ) && ! empty( $prior['sources'] ) && is_array( $prior['sources'] ) ) {
				foreach ( $prior['sources'] as $src ) {
					if ( empty( $src['type'] ) || empty( $src['id'] ) ) {
						continue;
					}
					AMBLD_Rag_Store::bump_helpfulness( (string) $src['type'], (string) $src['id'], -1 );
				}
			}
		}
		// Finalize the per-turn log no matter how this stream exits (normal
		// done, exception, or fatal). Done is the common case but several
		// branches above call exit() directly.
		register_shutdown_function( static function () use ( $harness ) {
			$harness->finish( 'shutdown' );
		} );

		// Build a single retrieval query from user prompt + page context.
		$rag_query = trim(
			implode(
				"\n",
				array_filter(
					array(
						$this->get_latest_user_message( $messages ),
						$page_title,
						wp_strip_all_tags( (string) $selected_section ),
					)
				)
			)
		);

		// Parent Antimanual KB still gates on the user's `useKnowledgeBase` toggle.
		// Builder-side RAG always runs when content is indexed — it's the local
		// version of "the model knows our templates," not a third-party data source.
		$parent_kb_context = '';
		if ( $use_kb_context ) {
			$parent_kb_context = AMBLD_Antimanual_Knowledge_Base::build_context_for_query( $rag_query );
		}
		$knowledge_base_context = $harness->build_context( $rag_query, $parent_kb_context );

		// Extract readable text from user-attached files.
		error_log( '[AMB] chat_stream: raw_attachments received = ' . ( is_array( $raw_attachments ) ? count( $raw_attachments ) : 0 ) . ' item(s)' );
		if ( is_array( $raw_attachments ) && ! empty( $raw_attachments ) ) {
			error_log( '[AMB] chat_stream: first attachment keys = ' . implode( ', ', array_keys( $raw_attachments[0] ?? array() ) ) );
		}
		$attachment_context = $this->build_attachment_context( $raw_attachments );
		$image_attachments = $this->get_image_attachments( $raw_attachments );

		// Render the user-approved plan (Phase 1) into a prose directive so
		// the generation phase follows the editorial brief the user signed
		// off on. Empty string when planner mode is off or no plan.
		$plan_context = $this->build_approved_plan_context( $approved_plan );

		// Render the current pass directive (Phase 2 of Plan → Execute).
		// Empty string when only a single pass is running.
		$pass_context = $this->build_pass_directive_context( $pass_directive );

		// Extract pass mode (foundation|section) — controls the base output
		// format instruction. Section mode makes the AI emit ONLY a new
		// <section> snippet so the frontend can splice it into <main>
		// instead of replaying the full page through the model every turn.
		$pass_mode = '';
		if ( is_array( $pass_directive ) && ! empty( $pass_directive['mode'] ) ) {
			$mode_raw = sanitize_key( (string) $pass_directive['mode'] );
			if ( in_array( $mode_raw, array( 'foundation', 'section', 'tool_edit' ), true ) ) {
				$pass_mode = $mode_raw;
			}
		}

		// Load page code from the database when editing a saved post.
		// This eliminates the need for the frontend to send large
		// currentHtml / currentCss / currentJs blobs on every request.
		// Exception: section-append passes send a pre-processed outline
		// instead of full HTML — skip the override so that optimisation
		// is preserved.
		if ( $post_id > 0 && 'section' !== $pass_mode ) {
			$db_html = (string) get_post_meta( $post_id, '_ambld_rendered_html', true );
			if ( '' !== trim( $db_html ) ) {
				$current_html = $db_html;
				$current_css  = (string) get_post_meta( $post_id, '_ambld_custom_css', true );
				$current_js   = (string) get_post_meta( $post_id, '_ambld_custom_js', true );
			}
		}

		// Determine if the user has existing page content.
		$has_existing = ! empty( $current_html ) || ! empty( $current_css );

		// Build the legacy system prompt only for non-tool-edit paths.
		// Tool-edit passes use build_tool_edit_system_prompt() inside
		// handle_tool_edit_flow() — calling build_system_prompt() here
		// would wastefully embed the full HTML/CSS into a prompt that
		// is never sent to the AI.  For auto-detected tool-edit (no
		// explicit pass_mode) we still build it as a fallback in case
		// the tool-edit flow fails and we need to fall through.
		$system_prompt = ( 'tool_edit' === $pass_mode )
			? ''
			: $this->build_system_prompt(
				$current_html,
				$current_css,
				$page_title,
				$selected_section,
				$page_settings,
				$knowledge_base_context,
				$attachment_context,
				$plan_context,
				$pass_context,
				$pass_mode,
				$entity_type,
				$template_kind
			);

		// Track the start time for elapsed reporting.
		$start_time = microtime( true );

		// Disable response compression for this stream BEFORE any output.
		// gzip/zlib compression buffers the whole response until it has
		// enough bytes to emit a block (or until the connection closes),
		// which defeats SSE — events flushed by PHP never reach the browser
		// until the request ends, so the live timeline only updates at the
		// very end. zlib.output_compression is a separate mechanism from PHP
		// output buffers and is NOT removed by ob_end_clean(); it must be
		// turned off via ini_set before the first byte is sent.
		@ini_set( 'zlib.output_compression', '0' );
		@ini_set( 'output_buffering', '0' );
		@ini_set( 'implicit_flush', '1' );
		if ( function_exists( 'apache_setenv' ) ) {
			@apache_setenv( 'no-gzip', '1' );
			@apache_setenv( 'dont-vary', '1' );
		}

		// Set up SSE headers.
		header( 'Content-Type: text/event-stream; charset=UTF-8' );
		header( 'Cache-Control: no-cache' );
		header( 'Connection: keep-alive' );
		header( 'X-Accel-Buffering: no' );
		// Declare the body as already identity-encoded. This is the critical
		// fix for streaming over fetch(): when an upstream gzip module
		// (nginx/Apache) compresses a text/event-stream response, the
		// browser's fetch() decompression layer buffers the ENTIRE gzip
		// stream before emitting any decompressed bytes to the ReadableStream
		// reader — so JS receives every event in one burst at the very end,
		// even though the server sent them incrementally (DevTools, which
		// parses the network layer, still shows them arriving live, which is
		// what made this misleading). gzip modules skip a response that
		// already carries a Content-Encoding header, so declaring `identity`
		// keeps the stream uncompressed and lets fetch() deliver each event
		// the moment it arrives. `identity` is the RFC token for "no
		// transformation" and is safe (no decode mismatch).
		header( 'Content-Encoding: identity' );

		// Disable output buffering.
		// Use ob_end_clean() instead of ob_end_flush() to prevent plugin-registered
		// buffer callbacks (e.g. gzip/compression) from crashing or producing garbled
		// output inside a streaming SSE response.
		while ( ob_get_level() > 0 ) {
			ob_end_clean();
		}
		// Force PHP to flush each write immediately rather than accumulating.
		ob_implicit_flush( true );

		// Prime the connection: a padding comment large enough to push past
		// the minimum buffer thresholds some servers/proxies impose before
		// they release the first chunk to the client. SSE comment lines start
		// with ':' and are ignored by EventSource/our parser.
		echo ': ' . str_repeat( ' ', 4096 ) . "\n\n";
		flush();

		// --- Initial status ---
		$provider_label = AMBLD_Antimanual_Ai_Provider::get_provider_label( $provider );
		$this->send_sse_event( 'status', array( 'step' => 'calling_ai', 'message' => 'Connecting to ' . $provider_label . '...' ) );

		// Surface the retrieval result to the editor. We emit this event
		// ALWAYS (not just when hits > 0) so the user can confirm RAG
		// actually ran each turn, AND so the silent-failure modes are
		// loud:
		//   - skip_reason = 'no_embedder' → parent plugin's AI provider isn't configured
		//   - skip_reason = 'no_rows'     → corpus never indexed
		//   - hits = 0 AND skipped_dim > 0 AND skipped_dim === rows_loaded
		//                                 → DIM MISMATCH (most common silent fail)
		//                                   stored vectors were embedded at one
		//                                   dim, current query is at another;
		//                                   user needs to reseed.
		//   - hits = 0 AND skipped_dim = 0 AND rows_loaded > 0
		//                                 → low-similarity query; usually fine.
		$harness_sources    = $harness->get_sources();
		// Defensive method-exists check protects against the partial-deploy
		// case where chat-api.php is the new version but the cached
		// AMBLD_Agent_Harness in opcache lacks get_diagnostic() — without
		// this, calling a missing method fatals the stream mid-turn and
		// no `sources_used` event ever reaches the client (which is
		// exactly the symptom: badge-on-mount works, but in-turn events
		// are silent).
		$harness_diagnostic = method_exists( $harness, 'get_diagnostic' )
			? $harness->get_diagnostic()
			: null;
		$this->send_sse_event( 'sources_used', array(
			'sources'    => $harness_sources,
			'diagnostic' => $harness_diagnostic,
		) );

		// Confirm-the-emit log. If this line shows up in wp-content/debug.log
		// but you DON'T see [AMB-RAG] sources_used in the browser console,
		// the server is fine — your frontend bundle is stale (run `npm run build`).
		// If this line DOESN'T show up at all, PHP is serving stale code
		// from opcache; clear it (or set `opcache.enable=0` in php.ini for
		// dev) and restart php-fpm / Apache.
		error_log( sprintf(
			'[AMB-RAG] sources_used SSE emitted | sources_count=%d | diagnostic_present=%s | skip_reason=%s | rows_loaded=%d | scored=%d | skipped_dim=%d | query_dim=%d',
			count( $harness_sources ),
			null !== $harness_diagnostic ? 'yes' : 'no',
			is_array( $harness_diagnostic ) && isset( $harness_diagnostic['skip_reason'] ) ? ( $harness_diagnostic['skip_reason'] ?: '(none)' ) : '(no-diag)',
			is_array( $harness_diagnostic ) ? (int) ( $harness_diagnostic['rows_loaded'] ?? 0 ) : 0,
			is_array( $harness_diagnostic ) ? (int) ( $harness_diagnostic['scored'] ?? 0 ) : 0,
			is_array( $harness_diagnostic ) ? (int) ( $harness_diagnostic['skipped_dim'] ?? 0 ) : 0,
			is_array( $harness_diagnostic ) ? (int) ( $harness_diagnostic['query_dim'] ?? 0 ) : 0
		) );

		// Output budget — user-configurable from the General settings tab.
		// The user's ceiling is honored verbatim for ALL passes; we no
		// longer silently clamp section passes to 4000. The old cap was
		// premised on "one section ≈ 2.5k tokens" but the current system
		// prompt (mandatory .container wrapper, semantic classes on every
		// element, accessibility attributes, seed-corpus animation
		// patterns) regularly drives sections to 4000–7000 tokens. With
		// the old cap, the model would hit MAX_TOKENS mid-section, return
		// truncated HTML, and the extractor would find nothing usable —
		// "the section response comes but doesn't attach to the canvas."
		//
		// We DO still enforce a per-pass minimum so a misconfigured small
		// admin setting (e.g. 1000) can't produce un-renderable truncated
		// output. The floor is intentionally generous.
		$ai_budget          = AMBLD_API_Ai_Api::get_ai_budget();
		$active_model       = isset( $settings['model'] ) ? (string) $settings['model'] : '';
		$user_max           = (int) $ai_budget['max_output_tokens'];
		// Default (0) = "auto": use the active model's full output ceiling read
		// from the parent catalog. A positive saved value is the user's cap,
		// clamped down to the model's real limit. Fall back to 8000 only when
		// the model's limit is unknown (parent metadata missing).
		$model_ceiling      = AMBLD_Antimanual_Ai_Provider::get_model_max_output( $provider, $active_model );
		$model_ceiling      = $model_ceiling > 0 ? $model_ceiling : 8000;
		$effective_max      = $user_max > 0 ? min( $user_max, $model_ceiling ) : $model_ceiling;
		// Resolve effort against the model (clamped to its advertised levels,
		// '' for non-reasoning models) and derive Gemini's thinking budget.
		$user_reasoning     = (string) $ai_budget['reasoning_effort'];
		$effective_effort   = AMBLD_Antimanual_Ai_Provider::resolve_effort_for_model( $provider, $active_model, $user_reasoning );
		$user_thinking      = AMBLD_Antimanual_Ai_Provider::effort_to_thinking_budget( $effective_effort );
		$pass_floor         = ( 'section' === $pass_mode ) ? 6000 : 4000;
		$max_output_tokens  = max( $pass_floor, $effective_max );

		error_log( sprintf(
			'[AMB-BUDGET] chat_stream | pass_mode=%s | model=%s | user_max=%d | model_ceiling=%d | pass_floor=%d | applied_max_output_tokens=%d | effort=%s | thinking_budget=%d',
			'' === $pass_mode ? '(legacy)' : $pass_mode,
			'' === $active_model ? '(unknown)' : $active_model,
			$user_max,
			$model_ceiling,
			$pass_floor,
			$max_output_tokens,
			'' === $effective_effort ? '(none)' : $effective_effort,
			$user_thinking
		) );

		// --- Tool-edit gate ---
		// When the user chats on an existing page WITHOUT the planner flow
		// (no approved plan, no passDirective, not a section-edit), route
		// through native function-calling instead of full-page refinement.
		// This is the main output-token win: the model picks a scoped tool
		// (patch_css / replace_section / …) and returns only the change
		// needed, not the whole page.
		$user_prompt      = $this->get_latest_user_message( $messages );
		// Tool-edit (native function calling) is enabled for any provider that
		// speaks the OpenAI Chat Completions wire format (OpenAI itself plus
		// xAI / DeepSeek / OpenCodeZen) and for Gemini. If a model in this set
		// doesn't actually support function calling, the existing graceful
		// fallback to legacy refinement kicks in below.
		$is_whitelisted   = AMBLD_Antimanual_Ai_Provider::is_openai_compatible( $provider ) || ( 'gemini' === $provider );
		// When the user has a section explicitly selected in the canvas,
		// the prompt is ALWAYS an edit — even if it's phrased as a
		// question ("can you make this bigger?"). The selection is the
		// imperative; trust it and skip the prompt-text heuristic.
		$selection_present = ! empty( $selected_section );

		// Issue 1E: Force tool-edit when a placeholder section is present.
		// Placeholders (ambld-new-XXXX) are always valid edit targets because
		// their HTML is being sent via selectedSection.
		$is_placeholder_section = ! empty( $selected_section )
			&& ( false !== strpos( $selected_section, 'ambld-new-section' )
				|| false !== strpos( $selected_section, 'data-amb-section="ambld-new-' ) );

		$is_tool_edit_pass = ( 'tool_edit' === $pass_mode );
		$should_tool_edit  = (
			$has_existing
			&& $is_whitelisted
			&& (
				$is_tool_edit_pass                                      // planned tool-edit from planner mode
				|| ( empty( $pass_mode ) && ( $selection_present || $this->detect_edit_intent( $user_prompt ) ) )  // legacy non-plan tool-edit
			)
		);

		// If a placeholder is present, force tool-edit unconditionally
		// (bypasses $plan_context gate and any other failure mode).
		if ( $is_placeholder_section ) {
			$should_tool_edit = true;
		}

		error_log( sprintf(
			'[AMB] chat_stream: route=%s | has_existing=%s | selection=%s | provider=%s | prompt="%s"',
			$should_tool_edit ? 'tool-edit' : ( ! empty( $pass_mode ) ? 'pass-'.$pass_mode : ( ! empty( $plan_context ) ? 'planned' : 'legacy' ) ),
			$has_existing ? 'yes' : 'no',
			$selection_present ? 'set' : 'none',
			$provider,
			substr( $user_prompt, 0, 80 )
		) );

		if ( $should_tool_edit ) {
			$this->send_sse_event(
				'progress_update',
				array( 'stepId' => 'locate', 'status' => 'active', 'label' => 'Locating the target section' )
			);

			$tool_handled = $this->handle_tool_edit_flow( array(
				'provider'           => $provider,
				'api_key'            => $api_key,
				'settings'           => $settings,
				'messages'           => $messages,
				'current_html'       => $current_html,
				'current_css'        => $current_css,
				'current_js'         => $current_js,
				'js_errors'          => $js_errors,
				'loaded_libraries'   => $this->get_loaded_library_urls( $post_id ),
				'imported_scripts'   => $imported_scripts,
				'cdn_resources'      => ! empty( $cdn_resources )
					? $cdn_resources
					: $this->load_saved_cdn_resources( $post_id ),
				'selected_section'   => $selected_section,
				'page_title'         => $page_title,
				'page_settings'     => $page_settings,
				'kb_context'         => $knowledge_base_context,
				'attachment_context' => $attachment_context,
				'image_attachments' => $image_attachments,
				'user_prompt'        => $user_prompt,
				'start_time'         => $start_time,
				// Section edits never need a huge output window, so we cap the
				// model-aware effective max at 32000 here (it keeps tool-edit
				// requests modest) with a generous 4000 floor so a small admin
				// value can't truncate a replace_section output. The old cap
				// was `min(user_max, 3000)` which truncated every section >3k
				// tokens — the dominant cause of `finish_reason: MAX_TOKENS`.
				'max_output_tokens'  => min( 32000, max( 4000, $effective_max ) ),
				'thinking_budget'    => $user_thinking,
				'reasoning_effort'   => $effective_effort,
				'pass_directive'     => $pass_directive,
			) );

			if ( $tool_handled ) {
				return; // full tool-edit flow ran end-to-end
			}
			// Tool-edit flow could not complete. Rather than falling through to
			// the legacy full-page refinement path (which would overwrite ALL
			// HTML/CSS/JS and lose every other section), stop here and tell the
			// user to click the section in the canvas and try again.
			$this->send_sse_event(
				'commentary',
				array(
					'phase'   => 'tool_edit_failed',
					'message' => 'Could not auto-locate the target section. Try clicking the section in the canvas first, then resend your request.',
				)
			);
			$this->send_sse_event( 'assistant_message', array(
				'content'  => 'I had trouble locating the exact section to edit. Please click the section you want to change in the canvas (it will highlight in purple), then send your request again — I\'ll be able to target it precisely.',
				'toolName' => '',
			) );
			$this->send_sse_event( 'done', array() );
			return;
		}

		$result = null;
		// `$effective_effort` is already resolved against the model's metadata:
		// it is non-empty only for models that advertise effort-style reasoning
		// (today, OpenAI's). The OpenAI client only attaches the field when
		// non-empty, so xAI/DeepSeek/OpenCodeZen — which carry no effort
		// metadata — naturally get nothing.
		$reasoning_for_call = $effective_effort;
		if ( AMBLD_Antimanual_Ai_Provider::is_openai_compatible( $provider ) ) {
			$result = $this->call_openai_with_tools( $api_key, $settings, $system_prompt, $messages, $has_existing, $max_output_tokens, $reasoning_for_call, $image_attachments );
		} elseif ( 'gemini' === $provider ) {
			$result = $this->call_gemini_with_tools( $api_key, $settings, $system_prompt, $messages, $has_existing, $max_output_tokens, $user_thinking, $image_attachments );
		} else {
			$this->send_sse_event( 'error', array( 'message' => 'Unknown AI provider: ' . $provider ) );
		}

		// Process the result.
		if ( $result && ! is_wp_error( $result ) ) {
			$page_emitted = false;

			// Provider-side finish_reason check. If the model hit the
			// output-tokens ceiling (OpenAI: `length`, Gemini: `MAX_TOKENS`),
			// the response body is TRUNCATED — extract will likely fail or
			// produce broken HTML. Log it greppably and surface a
			// commentary chip so the user sees the cause in the editor
			// without digging through debug.log.
			$finish_reason = isset( $result['finish_reason'] ) ? (string) $result['finish_reason'] : '';
			if ( '' !== $finish_reason ) {
				error_log( sprintf(
					'[AMB-BUDGET] provider returned finish_reason=%s | applied_max_output_tokens=%d | content_chars=%d',
					$finish_reason,
					$max_output_tokens,
					strlen( (string) ( $result['content'] ?? '' ) )
				) );
				$is_max_tokens = ( 'length' === $finish_reason || 'MAX_TOKENS' === $finish_reason );
				if ( $is_max_tokens ) {
					$this->send_sse_event( 'commentary', array(
						'phase'   => 'max_tokens',
						'message' => sprintf(
							/* translators: %d: applied max-output-tokens value */
							'The AI hit the max-output-tokens ceiling (%d). The response was truncated, so the canvas may not have updated correctly. Raise the "Maximum Output Tokens" value in plugin Settings → AI Token Budget.',
							$max_output_tokens
						),
					) );
				}
			}

			if ( ! empty( $result['content'] ) ) {
				$content = $result['content'];

				// HTML extraction — multi-strategy. The AI is INSTRUCTED to
				// wrap its output in a ```html fence, but on long multi-task
				// plan runs it sometimes drifts: emits prose before the fence,
				// nests inner fences (```css … ```), or omits fences entirely
				// and just inlines `<section …>` in the response. Every one
				// of those produced an empty `$html` and silently broke the
				// "section attaches to canvas" flow on later tasks — the
				// `assistant_message` event still fired (chat showed the
				// text) but `page_refined` never did (canvas stayed unchanged).
				//
				// The new extractor tries the strict fence first, then walks
				// down through progressively looser patterns. For section
				// passes it also runs a section-specific regex that ignores
				// fence noise entirely.
				$html = $this->extract_html_from_ai_content( $content, $pass_mode );

				error_log( sprintf(
					'[AMB-PLAN] extract: pass_mode=%s | content_chars=%d | extracted_chars=%d | extracted_preview="%s"',
					'' === $pass_mode ? '(none)' : $pass_mode,
					strlen( $content ),
					strlen( $html ),
					substr( preg_replace( '/\s+/', ' ', $html ), 0, 100 )
				) );

				// Section-append safety net: if the AI emitted a full HTML
				// document despite the FORBIDDEN list, strip the doctype/
				// html/head/body/main wrappers and keep ONLY the section
				// (plus any adjacent <style>/<script>). Without this the
				// frontend's appendSectionToMain would splice the whole
				// doc INSIDE <main>, nesting tasks and corrupting prior
				// sections from task 4 onward.
				//
				// Also dedup: extract the data-amb-section ids already on
				// the page from the request's currentHtml (works on both
				// the full-HTML and outline formats — both contain the
				// `data-amb-section="X"` substring) so the normalizer can
				// drop any sections the AI re-emitted that already exist.
				if ( '' !== $html && 'section' === $pass_mode ) {
					$existing_ids = array();
					if ( ! empty( $current_html ) && preg_match_all( '/data-amb-section\s*=\s*"([^"]+)"/i', $current_html, $id_matches ) ) {
						$existing_ids = array_values( array_unique( $id_matches[1] ) );
					}
					$pre_norm_chars = strlen( $html );
					$html           = $this->normalize_section_append_html( $html, $existing_ids );
					$post_norm_chars = strlen( $html );
					$section_count = preg_match_all( '/<section\b[^>]*data-amb-section=/i', $html, $_count_m );
					error_log( sprintf(
						'[AMB-PLAN] normalize: section pass | existing_ids=[%s] | pre=%d chars | post=%d chars | sections_in_output=%d',
						implode( ',', $existing_ids ),
						$pre_norm_chars,
						$post_norm_chars,
						(int) $section_count
					) );
				}

				if ( '' !== $html ) {
					$tool_name = $has_existing ? 'refine_page' : 'generate_page';

					// Announce tool if not already announced during streaming.
					if ( empty( $result['tool_announced'] ) ) {
						$this->send_sse_event(
							'tool_call',
							array(
								'tool'    => $tool_name,
								'step'    => 'building',
								'message' => 'refine_page' === $tool_name
									? 'Refining your page...'
									: 'Building your page...',
								'steps'   => array(),
							)
						);
					}

					// Mark last AI step as done.
					$step_count = 0;
					if ( preg_match_all( '/\[STEP:\s*(.+?)\]/', $content, $_step_matches ) ) {
						$step_count = count( $_step_matches[1] );
					}
					if ( $step_count > 0 ) {
						$last_step_id = 'ai_step_' . ( $step_count - 1 );
						$this->send_progress_update( $last_step_id, 'done' );
					}

					$this->execute_tool( $tool_name, array(
						'html'    => $html,
						'title'   => 'Generated Page',
						'summary' => 'Page successfully created/updated.',
					) );
					$page_emitted = true;

					// Narrate structural details of the generated HTML.
					$this->send_html_analysis_commentary( $html );

					$elapsed = round( microtime( true ) - $start_time, 1 );
					$this->send_operation_summary( $tool_name, array( 'html' => $html, 'title' => 'Generated Page', 'summary' => 'Page successfully created/updated.' ), $elapsed );

					// Send a clean assistant message (no code, no step markers).
					$clean_msg = $this->clean_assistant_message( $content, $page_emitted );
					$this->send_sse_event( 'assistant_message', array( 'content' => $clean_msg, 'toolName' => $tool_name ) );
				} else {
					// No HTML extracted from the AI response. This is the
					// failure mode the user describes as "responses come
					// but the section doesn't attach to the canvas" —
					// `assistant_message` will still display the AI's
					// prose in the chat, but no `page_refined`/`page_generated`
					// event fires so the canvas stays unchanged.
					error_log( sprintf(
						'[AMB-PLAN] NO HTML EMITTED: pass_mode=%s | has_existing=%s | content_chars=%d | content_preview="%s"',
						'' === $pass_mode ? '(none)' : $pass_mode,
						$has_existing ? 'yes' : 'no',
						strlen( $content ),
						substr( preg_replace( '/\s+/', ' ', $content ), 0, 200 )
					) );

					// Surface to the editor so the user isn't left wondering
					// why the canvas didn't update. Front-end shows this as
					// a commentary line under the chat. Only fires for plan
					// passes that were supposed to emit HTML; conversational
					// turns (mode = '') stay silent.
					if ( 'section' === $pass_mode || 'foundation' === $pass_mode ) {
						$this->send_sse_event( 'commentary', array(
							'phase'   => 'extract_failed',
							'message' => 'The AI responded but didn\'t return parseable HTML for this task — canvas was not updated. See the AI activity log for details.',
						) );
					}

					// No code block — conversational response. Clean step markers only.
					$clean_msg = $this->clean_assistant_message( $content, $page_emitted );
					$this->send_sse_event( 'assistant_message', array( 'content' => $clean_msg, 'toolName' => 'answer_question' ) );
				}

			} else {
				$this->send_sse_event( 'assistant_message', array(
					'content' => 'I received your message but I\'m not sure how to respond. Could you describe what you\'d like me to do?',
				) );
			}
		} elseif ( is_wp_error( $result ) ) {
			$this->send_commentary( 'error', 'Encountered an error: ' . $result->get_error_message() );
			$this->send_sse_event( 'error', array( 'message' => $result->get_error_message() ) );
		}

		$this->send_sse_event( 'done', array() );
		echo "data: [DONE]\n\n";
		flush();

		exit;
	}



	/**
	 * Process an OpenAI streamed SSE event.
	 *
	 * Accumulates content and tool-call argument deltas into the result
	 * array, emitting commentary when the first chunk or tool call arrives.
	 *
	 * @since  1.1.0
	 * @param  string $event          SSE event text.
	 * @param  array  $result         Aggregated result.
	 * @param  array  $tool_call_map  Aggregated tool calls keyed by index.
	 * @param  array  $state          Streaming state.
	 * @return void
	 */
	private function process_openai_stream_event( $event, &$result, &$tool_call_map, &$state ) {
		$lines      = preg_split( '/\r?\n/', $event );
		$data_lines = array();

		foreach ( $lines as $line ) {
			if ( 0 === strpos( $line, 'data:' ) ) {
				$data_lines[] = ltrim( substr( $line, 5 ) );
			}
		}

		$payload = trim( implode( "\n", $data_lines ) );

		if ( empty( $payload ) || '[DONE]' === $payload ) {
			return;
		}

		$decoded = json_decode( $payload, true );

		if ( ! is_array( $decoded ) ) {
			return;
		}

		if ( isset( $decoded['error']['message'] ) ) {
			$state['api_error_message'] = $decoded['error']['message'];
			return;
		}

		if ( empty( $state['saw_first_chunk'] ) ) {
			$state['saw_first_chunk'] = true;
			$this->send_sse_event(
				'status',
				array(
					'step'    => 'calling_ai',
					'message' => 'AI is generating...',
				)
			);
		}

		$delta = isset( $decoded['choices'][0]['delta'] ) ? $decoded['choices'][0]['delta'] : array();

		// Accumulate content tokens.
		if ( isset( $delta['content'] ) ) {
			$chunk = $delta['content'];
			$result['content'] .= $chunk;
			$this->send_sse_event( 'token', array( 'content' => $chunk ) );

			// Detect [STEP: ...] markers in the accumulated content.
			$this->detect_and_emit_steps( $result['content'], $state );
		}

		// Capture finish_reason from the final chunk. OpenAI sends it on
		// the LAST `choices[0]` object alongside an empty delta. We store
		// it on $result so the caller can log it and surface MAX_TOKENS
		// to the user.
		if ( isset( $decoded['choices'][0]['finish_reason'] ) && '' !== (string) $decoded['choices'][0]['finish_reason'] ) {
			$result['finish_reason'] = (string) $decoded['choices'][0]['finish_reason'];
		}
	}

	/**
	 * Call OpenAI API via cURL streaming.
	 *
	 * Uses OpenAI's streaming mode so SSE heartbeat events are emitted
	 * during the wait, preventing the UI from appearing stuck.
	 *
	 * @since  1.1.0
	 * @param  string $api_key  API key.
	 * @param  array  $settings AI settings.
	 * @param  string $system   System prompt.
	 * @param  array  $messages Conversation messages.
	 * @return array|WP_Error Result with 'content' and/or 'tool_calls'.
	 */
	private function call_openai_with_tools( $api_key, $settings, $system, $messages, $has_existing = false, $max_output_tokens = 8000, $reasoning_effort = '', $image_attachments = array() ) {
		// Active provider's model wins; fall back to the legacy openai_model
		// key for callers that haven't been migrated to the per-active-provider
		// `model` field.
		$model = ! empty( $settings['model'] )
			? $settings['model']
			: ( isset( $settings['openai_model'] ) ? $settings['openai_model'] : 'gpt-5-mini' );

		// Base URL for OpenAI-compatible providers (xAI, DeepSeek, OpenCodeZen
		// reuse this client). Defaults to OpenAI when not supplied.
		$base_url = ! empty( $settings['base_url'] )
			? rtrim( (string) $settings['base_url'], '/' )
			: 'https://api.openai.com/v1';

		// Build messages array.
		$api_messages = array(
			array( 'role' => 'system', 'content' => $system ),
		);

		$recent = $this->get_recent_messages( $messages, 10 );
		$last_user_index = -1;
		foreach ( $recent as $idx => $msg ) {
			$role = isset( $msg['role'] ) ? $msg['role'] : 'user';
			if ( in_array( $role, array( 'user', 'assistant' ), true ) ) {
				$api_messages[] = array(
					'role'    => $role,
					'content' => isset( $msg['content'] ) ? $msg['content'] : '',
				);
				if ( 'user' === $role ) {
					$last_user_index = count( $api_messages ) - 1;
				}
			}
		}

		$image_parts = $this->build_openai_image_parts( $image_attachments );
		if ( ! empty( $image_parts ) && $last_user_index >= 0 ) {
			$original_content = $api_messages[ $last_user_index ]['content'];
			$api_messages[ $last_user_index ]['content'] = array(
				array( 'type' => 'text', 'text' => $original_content ),
			);
			foreach ( $image_parts as $part ) {
				$api_messages[ $last_user_index ]['content'][] = $part;
			}
		}

		$token_key = AMBLD_Antimanual_Ai_Provider::uses_max_completion_tokens( $model )
			? 'max_completion_tokens'
			: 'max_tokens';

		$body = array(
			'model'       => $model,
			'messages'    => $api_messages,
			$token_key    => max( 1000, (int) $max_output_tokens ),
			'stream'      => true,
		);

		// 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 ) ) {
			$body['temperature'] = 0.7;
		}

		// Attach reasoning_effort only when non-empty — older chat models
		// (gpt-4o, gpt-4-turbo) reject the field.
		if ( ! empty( $reasoning_effort ) ) {
			$body['reasoning_effort'] = AMBLD_Antimanual_Ai_Provider::normalize_reasoning_effort( $reasoning_effort );
		}

		// Prefer cURL streaming so we can emit heartbeats during the wait.
		if ( function_exists( 'curl_init' ) ) {
			return $this->call_openai_with_tools_streaming( $api_key, $body, $has_existing, $base_url );
		}

		return $this->call_openai_with_tools_fallback( $api_key, $body, $base_url );
	}

	/**
	 * Call OpenAI's streaming endpoint and emit live heartbeats.
	 *
	 * @since  1.1.0
	 * @param  string $api_key API key.
	 * @param  array  $body    Request body (stream: true already set).
	 * @return array|WP_Error
	 */
	private function call_openai_with_tools_streaming( $api_key, $body, $has_existing = false, $base_url = 'https://api.openai.com/v1' ) {
		$url           = rtrim( (string) $base_url, '/' ) . '/chat/completions';
		$result        = array(
			'content'    => '',
			'tool_calls' => array(),
		);
		$tool_call_map = array();
		$state         = array(
			'started_at'          => microtime( true ),
			'last_heartbeat_at'   => 0,
			'saw_first_chunk'     => false,
			'announced_tool_call' => false,
			'api_error_message'   => '',
			'buffer'              => '',
			'ai_step_count'       => 0,
			'has_existing'        => $has_existing,
		);

		$this->emit_keepalive_heartbeat( $state, true );

		// Stream the response in real time via CURLOPT_WRITEFUNCTION. To stay
		// within the WordPress HTTP API (required by WordPress.org — no direct
		// curl_exec in runtime code), the streaming read/progress callbacks are
		// attached to WordPress's own cURL handle through the `http_api_curl`
		// action, which fires immediately before WordPress runs the transfer.
		// wp_remote_post then owns the handle's entire lifecycle. A non-streaming
		// wp_remote_post fallback lives in call_openai_with_tools_fallback() for
		// hosts without cURL.
		$write_callback = function ( $curl_handle, $chunk ) use ( &$result, &$tool_call_map, &$state ) {
			$state['buffer'] .= str_replace( "\r\n", "\n", $chunk );

			while ( false !== strpos( $state['buffer'], "\n\n" ) ) {
				$delimiter       = strpos( $state['buffer'], "\n\n" );
				$event           = substr( $state['buffer'], 0, $delimiter );
				$state['buffer'] = substr( $state['buffer'], $delimiter + 2 );
				$this->process_openai_stream_event( $event, $result, $tool_call_map, $state );
			}

			return strlen( $chunk );
		};

		$progress_callback = function () use ( &$state ) {
			$this->emit_keepalive_heartbeat( $state );
			return 0;
		};

		$curl_stream_hook = static function ( $handle ) use ( $write_callback, $progress_callback ) {
			// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt -- WordPress hands its live cURL handle to this action specifically so callers can tune the transport; here we only attach the streaming read/progress callbacks.
			curl_setopt( $handle, CURLOPT_WRITEFUNCTION, $write_callback );
			curl_setopt( $handle, CURLOPT_BUFFERSIZE, 1024 );
			curl_setopt( $handle, CURLOPT_CONNECTTIMEOUT, 10 );
			curl_setopt( $handle, CURLOPT_NOPROGRESS, false );
			if ( defined( 'CURLOPT_XFERINFOFUNCTION' ) ) {
				curl_setopt( $handle, CURLOPT_XFERINFOFUNCTION, $progress_callback );
			} else {
				curl_setopt( $handle, CURLOPT_PROGRESSFUNCTION, $progress_callback );
			}
			// phpcs:enable
		};

		add_action( 'http_api_curl', $curl_stream_hook );
		$response = wp_remote_post(
			$url,
			array(
				'headers'     => array(
					'Content-Type'  => 'application/json',
					'Authorization' => 'Bearer ' . $api_key,
				),
				'body'        => wp_json_encode( $body ),
				'timeout'     => 120,
				'sslverify'   => true,
				'httpversion' => '1.1',
			)
		);
		remove_action( 'http_api_curl', $curl_stream_hook );

		// Process any remaining buffer.
		if ( ! empty( trim( $state['buffer'] ) ) ) {
			$this->process_openai_stream_event( $state['buffer'], $result, $tool_call_map, $state );
		}

		if ( ! empty( $state['api_error_message'] ) ) {
			return new \WP_Error( 'ai_error', $state['api_error_message'], array( 'status' => 500 ) );
		}

		if ( is_wp_error( $response ) ) {
			$message = $response->get_error_message();
			if ( '' === $message || false !== stripos( $message, 'timed out' ) || false !== stripos( $message, 'timeout' ) ) {
				$message = 'OpenAI timed out. Try again or use a faster model.';
			}

			return new \WP_Error( 'ai_error', $message, array( 'status' => 500 ) );
		}

		$http_code = (int) wp_remote_retrieve_response_code( $response );

		if ( $http_code >= 400 ) {
			return new \WP_Error( 'ai_error', 'OpenAI returned HTTP ' . $http_code . '.', array( 'status' => $http_code ) );
		}

		// Convert accumulated tool call map to final format.
		foreach ( $tool_call_map as $tc ) {
			$fn_args = json_decode( $tc['args_str'], true );
			if ( ! is_array( $fn_args ) ) {
				$fn_args = array();
			}

			$result['tool_calls'][] = array(
				'name' => $tc['name'],
				'args' => $fn_args,
			);
		}

		$result['tool_announced'] = ! empty( $state['announced_tool_call'] );

		return $result;
	}

	/**
	 * Fallback OpenAI call when cURL streaming is not available.
	 *
	 * Uses wp_remote_post without streaming. Less responsive but functional.
	 *
	 * @since  1.1.0
	 * @param  string $api_key API key.
	 * @param  array  $body    Request body.
	 * @return array|WP_Error
	 */
	private function call_openai_with_tools_fallback( $api_key, $body, $base_url = 'https://api.openai.com/v1' ) {
		// Disable streaming for the fallback.
		$body['stream'] = false;

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

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

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

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

		$result = array(
			'content'    => '',
			'tool_calls' => array(),
		);

		if ( isset( $resp_body['choices'][0]['message'] ) ) {
			$msg = $resp_body['choices'][0]['message'];

			if ( ! empty( $msg['content'] ) ) {
				$result['content'] = $msg['content'];
			}

			if ( ! empty( $msg['tool_calls'] ) ) {
				foreach ( $msg['tool_calls'] as $tc ) {
					$fn_name     = isset( $tc['function']['name'] ) ? $tc['function']['name'] : '';
					$fn_args_str = isset( $tc['function']['arguments'] ) ? $tc['function']['arguments'] : '{}';
					$fn_args     = json_decode( $fn_args_str, true );

					if ( ! is_array( $fn_args ) ) {
						$fn_args = array();
					}

					$result['tool_calls'][] = array(
						'name' => $fn_name,
						'args' => $fn_args,
					);
				}
			}
		}

		return $result;
	}

	/**
	 * Call Gemini API with streamed generation or a non-streaming fallback.
	 *
	 * @param string $api_key  API key.
	 * @param array  $settings AI settings.
	 * @param string $system   System prompt.
	 * @param array  $messages Conversation messages.
	 * @return array|WP_Error Result with 'content' and/or 'tool_calls'.
	 */
	private function call_gemini_with_tools( $api_key, $settings, $system, $messages, $has_existing = false, $max_output_tokens = 8000, $thinking_budget = -1, $image_attachments = array() ) {
		$model = isset( $settings['gemini_model'] ) ? $settings['gemini_model'] : 'gemini-3-flash-preview';

		// Build contents array.
		$contents = array();
		$recent   = $this->get_recent_messages( $messages, 8 );
		$last_user_index = -1;
		$last_user_parts = array();

		foreach ( $recent as $idx => $msg ) {
			$role = isset( $msg['role'] ) ? $msg['role'] : 'user';
			if ( 'assistant' === $role ) {
				$role = 'model';
			}
			if ( in_array( $role, array( 'user', 'model' ), true ) ) {
				$content_parts = array( array( 'text' => isset( $msg['content'] ) ? $msg['content'] : '' ) );
				if ( 'user' === $msg['role'] ) {
					$last_user_index = count( $contents );
					$last_user_parts = $content_parts;
				}
				$contents[] = array(
					'role'  => $role,
					'parts' => $content_parts,
				);
			}
		}

		$image_parts = $this->build_gemini_image_parts( $image_attachments );
		if ( ! empty( $image_parts ) && $last_user_index >= 0 ) {
			$contents[ $last_user_index ]['parts'] = array_merge( $last_user_parts, $image_parts );
		}

		$body = array(
			'system_instruction' => array(
				'parts' => array( array( 'text' => $system ) ),
			),
			'contents'         => $contents,
			'generationConfig' => array(
				'maxOutputTokens' => max( 1000, (int) $max_output_tokens ),
				'temperature'     => 0.5,
			),
		);

		// Attach thinkingBudget when explicitly set (>= 0). The API
		// interprets 0 as "no extended thinking" — a meaningful, distinct
		// value from "unset/default". We use -1 as the sentinel for
		// "let the model decide".
		if ( $thinking_budget >= 0 ) {
			$body['generationConfig']['thinkingConfig'] = array(
				'thinkingBudget' => (int) $thinking_budget,
			);
		}

		if ( function_exists( 'curl_init' ) ) {
			return $this->call_gemini_with_tools_streaming( $api_key, $model, $body, $has_existing );
		}

		return $this->call_gemini_with_tools_fallback( $api_key, $model, $body );
	}

	/**
	 * Call Gemini's SSE endpoint so the builder can emit live progress updates.
	 *
	 * @param string $api_key API key.
	 * @param string $model   Model name.
	 * @param array  $body    Request body.
	 * @return array|WP_Error
	 */
	private function call_gemini_with_tools_streaming( $api_key, $model, $body, $has_existing = false ) {
		$url           = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?alt=sse&key=' . $api_key;
		$result        = array(
			'content'    => '',
			'tool_calls' => array(),
		);
		$tool_call_map = array();
		$state         = array(
			'started_at'          => microtime( true ),
			'last_heartbeat_at'   => 0,
			'saw_first_chunk'     => false,
			'announced_tool_call' => false,
			'api_error_message'   => '',
			'buffer'              => '',
			'ai_step_count'       => 0,
			'has_existing'        => $has_existing,
		);

		$this->emit_keepalive_heartbeat( $state, true );

		// Stream the response in real time via CURLOPT_WRITEFUNCTION. To stay
		// within the WordPress HTTP API (required by WordPress.org — no direct
		// curl_exec in runtime code), the streaming read/progress callbacks are
		// attached to WordPress's own cURL handle through the `http_api_curl`
		// action, which fires immediately before WordPress runs the transfer.
		// wp_remote_post then owns the handle's entire lifecycle. A non-streaming
		// wp_remote_post fallback lives in call_gemini_with_tools_fallback() for
		// hosts without cURL.
		$write_callback = function ( $curl_handle, $chunk ) use ( &$result, &$tool_call_map, &$state ) {
			$state['buffer'] .= str_replace( "\r\n", "\n", $chunk );

			while ( false !== strpos( $state['buffer'], "\n\n" ) ) {
				$delimiter = strpos( $state['buffer'], "\n\n" );
				$event     = substr( $state['buffer'], 0, $delimiter );
				$state['buffer'] = substr( $state['buffer'], $delimiter + 2 );
				$this->process_gemini_stream_event( $event, $result, $tool_call_map, $state );
			}

			return strlen( $chunk );
		};

		$progress_callback = function () use ( &$state ) {
			$this->emit_keepalive_heartbeat( $state );
			return 0;
		};

		$curl_stream_hook = static function ( $handle ) use ( $write_callback, $progress_callback ) {
			// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt -- WordPress hands its live cURL handle to this action specifically so callers can tune the transport; here we only attach the streaming read/progress callbacks.
			curl_setopt( $handle, CURLOPT_WRITEFUNCTION, $write_callback );
			curl_setopt( $handle, CURLOPT_BUFFERSIZE, 1024 );
			curl_setopt( $handle, CURLOPT_CONNECTTIMEOUT, 10 );
			curl_setopt( $handle, CURLOPT_NOPROGRESS, false );
			if ( defined( 'CURLOPT_XFERINFOFUNCTION' ) ) {
				curl_setopt( $handle, CURLOPT_XFERINFOFUNCTION, $progress_callback );
			} else {
				curl_setopt( $handle, CURLOPT_PROGRESSFUNCTION, $progress_callback );
			}
			// phpcs:enable
		};

		add_action( 'http_api_curl', $curl_stream_hook );
		$response = wp_remote_post(
			$url,
			array(
				'headers'     => array( 'Content-Type' => 'application/json' ),
				'body'        => wp_json_encode( $body ),
				'timeout'     => 120,
				'sslverify'   => true,
				'httpversion' => '1.1',
			)
		);
		remove_action( 'http_api_curl', $curl_stream_hook );

		if ( ! empty( trim( $state['buffer'] ) ) ) {
			$this->process_gemini_stream_event( $state['buffer'], $result, $tool_call_map, $state );
		}

		if ( ! empty( $state['api_error_message'] ) ) {
			return new \WP_Error( 'ai_error', $state['api_error_message'], array( 'status' => 500 ) );
		}

		if ( is_wp_error( $response ) ) {
			$message = $response->get_error_message();
			if ( '' === $message || false !== stripos( $message, 'timed out' ) || false !== stripos( $message, 'timeout' ) ) {
				$message = 'Gemini timed out before returning a usable response. The request size has been reduced, but if this persists, switch to a Flash or Flash-Lite model.';
			}

			return new \WP_Error( 'ai_error', $message, array( 'status' => 500 ) );
		}

		$http_code = (int) wp_remote_retrieve_response_code( $response );

		if ( $http_code >= 400 ) {
			return new \WP_Error( 'ai_error', 'Gemini returned HTTP ' . $http_code . '.', array( 'status' => $http_code ) );
		}

		$result['tool_calls']     = array_values( $tool_call_map );
		$result['tool_announced'] = ! empty( $state['announced_tool_call'] );

		return $result;
	}

	/**
	 * Fallback Gemini call when cURL streaming is not available.
	 *
	 * @param string $api_key API key.
	 * @param string $model   Model name.
	 * @param array  $body    Request body.
	 * @return array|WP_Error
	 */
	private function call_gemini_with_tools_fallback( $api_key, $model, $body ) {

		$url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . $api_key;

		$response = wp_remote_post(
			$url,
			array(
				'timeout'   => 120,
				'headers'   => array(
					'Content-Type' => 'application/json',
				),
				'body'    => wp_json_encode( $body ),
			)
		);

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

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

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

		$result = array(
			'content'    => '',
			'tool_calls' => array(),
		);

		if ( isset( $resp_body['candidates'][0]['content']['parts'] ) ) {
			foreach ( $resp_body['candidates'][0]['content']['parts'] as $part ) {
				if ( isset( $part['text'] ) ) {
					$result['content'] .= $part['text'];
				}
				if ( isset( $part['functionCall'] ) ) {
					$result['tool_calls'][] = array(
						'name' => $part['functionCall']['name'],
						'args' => isset( $part['functionCall']['args'] ) ? $part['functionCall']['args'] : array(),
					);
				}
			}
		}

		return $result;
	}



	/**
	 * Execute a tool call and send the result to the client.
	 *
	 * @param string $tool_name Tool name.
	 * @param array  $args      Tool arguments.
	 */
	private function execute_tool( $tool_name, $args ) {
		switch ( $tool_name ) {
			case 'generate_page':
				$this->send_sse_event( 'status', array( 'step' => 'building', 'message' => 'Building your page...' ) );

				$html  = isset( $args['html'] ) ? $args['html'] : '';
				$title = isset( $args['title'] ) ? sanitize_text_field( $args['title'] ) : '';

				// Normalize image hosts BEFORE running guardrails — guardrails
				// inspect the final string for things like script tags from
				// untrusted hosts, so any rewriting must precede that check.
				$html = $this->rewrite_placeholder_image_urls( $html );

				$this->run_guardrails_and_emit( $html, isset( $args['css'] ) ? (string) $args['css'] : '' );

				$this->send_sse_event( 'page_generated', array(
					'html'    => $html,
					'title'   => $title,
					'message' => 'Page generated successfully!',
				) );
				if ( $this->ambld_harness ) {
					$this->ambld_harness->record_tool_call( 'generate_page' );
				}
				break;

			case 'refine_page':
				$this->send_sse_event( 'status', array( 'step' => 'applying', 'message' => 'Applying changes...' ) );

				$html    = isset( $args['html'] ) ? $args['html'] : '';
				$summary = isset( $args['summary'] ) ? $args['summary'] : 'Page updated.';

				$html = $this->rewrite_placeholder_image_urls( $html );

				$this->run_guardrails_and_emit( $html, isset( $args['css'] ) ? (string) $args['css'] : '' );

				$this->send_sse_event( 'page_refined', array(
					'html'    => $html,
					'message' => $summary,
				) );
				if ( $this->ambld_harness ) {
					$this->ambld_harness->record_tool_call( 'refine_page' );
				}
				break;

			case 'answer_question':
				$response = isset( $args['response'] ) ? $args['response'] : '';

				$this->send_sse_event( 'assistant_message', array(
					'content' => $response,
				) );
				break;

			default:
				$this->send_sse_event( 'error', array(
					'message' => 'Unknown tool: ' . $tool_name,
				) );
		}
	}

	/**
	 * Detect [STEP: ...] markers in accumulated content and emit progress events.
	 *
	 * Called after each streamed chunk to detect new step markers from the AI
	 * and emit them as progress_update SSE events in real-time.
	 *
	 * @param string $content Accumulated response content so far.
	 * @param array  $state   Streaming state (passed by reference).
	 * @return void
	 */
	private function detect_and_emit_steps( $content, &$state ) {
		preg_match_all( '/\[STEP:\s*(.+?)\]/', $content, $matches );

		$found_count = count( $matches[1] );

		if ( $found_count <= $state['ai_step_count'] ) {
			return;
		}

		// On first step detection, announce the tool to the frontend so the
		// progress panel activates with the correct tool-specific header.
		if ( 0 === $state['ai_step_count'] && empty( $state['announced_tool_call'] ) ) {
			$tool_name = ! empty( $state['has_existing'] ) ? 'refine_page' : 'generate_page';
			$this->send_sse_event(
				'tool_call',
				array(
					'tool'    => $tool_name,
					'step'    => 'building',
					'message' => 'refine_page' === $tool_name
						? 'Refining your page...'
						: 'Building your page...',
					'steps'   => array(),
				)
			);
			$state['announced_tool_call'] = true;
		}

		// Emit progress for new steps only.
		for ( $i = $state['ai_step_count']; $i < $found_count; $i++ ) {
			$step_label = trim( $matches[1][ $i ] );
			$step_id    = 'ai_step_' . $i;

			// Mark previous step as done.
			if ( $i > 0 ) {
				$prev_id = 'ai_step_' . ( $i - 1 );
				$this->send_progress_update( $prev_id, 'done' );
			}

			// Mark current step as active.
			$this->send_progress_update( $step_id, 'active', $step_label, $step_label . '...' );
		}

		$state['ai_step_count'] = $found_count;
	}

	/**
	 * Clean assistant message content for chat display.
	 *
	 * Strips [STEP: ...] markers, code blocks (```html...```), and
	 * excessive whitespace so the chat shows a clean, readable summary.
	 *
	 * @param string $content      Raw AI response content.
	 * @param bool   $page_emitted Whether a page_generated/page_refined event was actually sent this turn.
	 * @return string Cleaned message suitable for chat display.
	 */
	private function clean_assistant_message( $content, $page_emitted = false ) {
		if ( empty( $content ) ) {
			return '';
		}

		// Remove [STEP: ...] markers.
		$cleaned = preg_replace( '/\[STEP:\s*.+?\]\s*/s', '', $content );

		// Remove html code blocks (```html ... ``` or unclosed ```html ... EOF).
		$cleaned = preg_replace( '/```(?:html|css)?\s*<[\s\S]*?```/is', '', $cleaned );
		$cleaned = preg_replace( '/```(?:html|css)?\s*<[\s\S]*$/is', '', $cleaned );

		// Remove any remaining code fences.
		$cleaned = preg_replace( '/```[\s\S]*?```/s', '', $cleaned );

		// Collapse excessive whitespace / blank lines.
		$cleaned = preg_replace( '/\n{3,}/', "\n\n", $cleaned );
		$cleaned = trim( $cleaned );

		// If everything was stripped, return a fallback. Only claim the page was
		// updated when we actually emitted a page_generated/page_refined event —
		// otherwise the user sees "updated" while the canvas stays unchanged.
		if ( empty( $cleaned ) ) {
			return $page_emitted
				? 'Done! The page has been updated — check the preview.'
				: "I couldn't produce any page changes from that response. Try rephrasing your request or ask me to regenerate.";
		}

		return $cleaned;
	}

	/**
	 * Send a commentary SSE event.
	 *
	 * Commentary events narrate what the AI is doing in real-time,
	 * making the build process transparent and engaging.
	 *
	 * @param string $phase   The current phase identifier.
	 * @param string $message Human-readable commentary message.
	 */
	private function send_commentary( $phase, $message ) {
		$this->send_sse_event( 'commentary', array(
			'phase'   => sanitize_text_field( $phase ),
			'message' => sanitize_text_field( $message ),
		) );
	}

	/**
	 * Analyze generated HTML and send commentary about its structure.
	 *
	 * Counts sections, detects styles, fonts, and notable features
	 * to narrate the build process.
	 *
	 * @param string $html The generated HTML.
	 */
	private function send_html_analysis_commentary( $html ) {
		if ( empty( $html ) ) {
			return;
		}

		// Count sections.
		$section_count = preg_match_all( '/data-amb-section/i', $html );
		if ( $section_count > 0 ) {
			$this->send_commentary( 'structure', 'Generating ' . $section_count . ' section' . ( $section_count > 1 ? 's' : '' ) . ' for the page layout.' );
		}

		// Check for style block.
		if ( preg_match( '/<style/i', $html ) ) {
			$this->send_commentary( 'styling', 'Writing custom CSS styles and responsive rules.' );
		}

		// Check for responsive queries.
		if ( preg_match( '/@media/i', $html ) ) {
			$this->send_commentary( 'responsive', 'Adding responsive breakpoints for mobile and tablet.' );
		}
	}

	/**
	 * Send a final operation summary after tool execution.
	 *
	 * Provides a walkthrough of what was accomplished, including
	 * section count, styling details, and elapsed time.
	 *
	 * @param string $tool_name Tool that was executed.
	 * @param array  $args      Tool arguments.
	 * @param float  $elapsed   Elapsed time in seconds.
	 */
	private function send_operation_summary( $tool_name, $args, $elapsed ) {
		$summary_parts = array();

		switch ( $tool_name ) {
			case 'generate_page':
				$html  = ! empty( $args['html'] ) ? $args['html'] : '';
				$title = ! empty( $args['title'] ) ? $args['title'] : 'Untitled';

				$summary_parts[] = '✅ Page "' . $title . '" generated';

				$section_count = preg_match_all( '/data-amb-section/i', $html );
				if ( $section_count > 0 ) {
					$summary_parts[] = $section_count . ' section' . ( $section_count > 1 ? 's' : '' ) . ' created';
				}

				if ( preg_match( '/<style/i', $html ) ) {
					$summary_parts[] = 'custom CSS included';
				}

				if ( preg_match( '/@media/i', $html ) ) {
					$summary_parts[] = 'responsive design ready';
				}

				$summary_parts[] = 'completed in ' . $elapsed . 's';

				$this->send_sse_event( 'operation_summary', array(
					'operation' => 'generate',
					'title'     => $title,
					'summary'   => implode( ' · ', $summary_parts ),
					'details'   => $summary_parts,
					'elapsed'   => $elapsed,
				) );
				break;

			case 'refine_page':
				$change_summary = ! empty( $args['summary'] ) ? $args['summary'] : 'Page updated';

				$summary_parts[] = '✏️ ' . $change_summary;
				$summary_parts[] = 'completed in ' . $elapsed . 's';

				$this->send_sse_event( 'operation_summary', array(
					'operation' => 'refine',
					'summary'   => implode( ' · ', $summary_parts ),
					'details'   => $summary_parts,
					'elapsed'   => $elapsed,
				) );
				break;

			case 'answer_question':
				$this->send_sse_event( 'operation_summary', array(
					'operation' => 'answer',
					'summary'   => '💬 Answered your question in ' . $elapsed . 's',
					'details'   => array( 'Question answered', 'completed in ' . $elapsed . 's' ),
					'elapsed'   => $elapsed,
				) );
				break;
		}
	}

	/**
	 * Send an SSE event.
	 *
	 * @param string $event Event name.
	 * @param array  $data  Event data.
	 */
	/**
	 * Run guardrails on emitted HTML/CSS and surface violations.
	 *
	 * Always emits a `commentary` SSE event in soft mode so the user sees
	 * what the model produced that we flagged; in strict mode it additionally
	 * emits an `error`-style commentary so frontends can refuse the output.
	 */
	private function run_guardrails_and_emit( $html, $css ) {
		if ( ! $this->ambld_harness ) {
			return;
		}
		$result = $this->ambld_harness->guard( (string) $html, (string) $css );
		if ( $result['ok'] ) {
			return;
		}
		$message = sprintf(
			'Guardrails flagged %d issue%s: %s',
			count( $result['violations'] ),
			count( $result['violations'] ) === 1 ? '' : 's',
			implode( ' | ', $result['violations'] )
		);
		$this->send_sse_event( 'commentary', array(
			'phase'   => 'guardrails',
			'message' => $message,
		) );
	}

	private function send_sse_event( $event, $data ) {
		$event_name = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $event );
		echo 'event: ' . esc_html( $event_name ) . "\n";
		echo 'data: ' . wp_json_encode( $data ) . "\n\n";
		if ( ob_get_level() > 0 ) {
			ob_flush();
		}
		flush();
	}

	/**
	 * Send an error response (non-streaming fallback).
	 *
	 * @param string $message Error message.
	 */
	private function send_error_response( $message ) {
		header( 'Content-Type: text/event-stream; charset=UTF-8' );
		header( 'Cache-Control: no-cache' );
		echo "event: error\n";
		echo 'data: ' . wp_json_encode( array( 'message' => $message ) ) . "\n\n";
		echo "event: done\n";
		echo "data: []\n\n";
		echo "data: [DONE]\n\n";
		flush();
		exit;
	}
}
