<?php
/**
 * PressGo MCP — tool implementations.
 *
 * Each tool method takes the arguments object passed by the AI client and
 * returns either an array shape that PressGo_MCP_Server will wrap into a
 * tools/call response, or a WP_Error.
 *
 * All tools require an authenticated WP user with edit_pages capability.
 * Capability checks live in PressGo_MCP_Server::dispatch_tool().
 */

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

class PressGo_MCP_Tools {

	const VALID_SECTION_TYPES = array(
		'hero', 'stats', 'social_proof', 'features', 'steps', 'schedule', 'results',
		'competitive_edge', 'testimonials', 'faq', 'blog', 'pricing',
		'logo_bar', 'team', 'gallery', 'newsletter', 'map', 'cta_final',
		'sticky_bar', 'footer', 'disclaimer',
	);

	/**
	 * Tool catalogue advertised over MCP. Schemas are inline JSON Schema
	 * (lean — full schema available via the pressgo://schema resource).
	 */
	public static function definitions() {
		$section_enum = self::VALID_SECTION_TYPES;
		$defs = array(
			array(
				'name'        => 'create_page',
				'description' =>
					"Create a new draft page, rendered through the site's configured page builder " .
					"(Elementor, Gutenberg, Divi, or Bricks). If `config` is provided, the full page is built " .
					"in one shot. If omitted, an empty draft is created so you can incrementally add " .
					"sections with add_section/set_globals. Read the pressgo://schema resource first " .
					"to see the full config shape.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'title'  => array( 'type' => 'string', 'description' => 'Page title (also used as the WP slug)' ),
						'config' => array( 'type' => 'object', 'description' => 'Optional full page config — see pressgo://schema' ),
					),
					'required' => array( 'title' ),
				),
			),
			array(
				'name'        => 'add_section',
				'description' =>
					"Append a single section to an existing PressGo page. The section is built using the same " .
					"variant catalogue as full-page generation. To replace an existing section instead, use " .
					"update_section. Use get_brain to discover variant options before calling this.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'post_id' => array( 'type' => 'integer', 'description' => 'Target page ID' ),
						'type'    => array( 'type' => 'string', 'enum' => $section_enum, 'description' => 'Section type' ),
						'variant' => array( 'type' => 'string', 'description' => 'Optional variant name (e.g. "split", "image", "minimal")' ),
						'data'    => array( 'type' => 'object', 'description' => 'Section payload, matching the per-section shape in pressgo://schema' ),
					),
					'required' => array( 'post_id', 'type', 'data' ),
				),
			),
			array(
				'name'        => 'update_section',
				'description' =>
					"Replace a section on an existing PressGo page. Pass `section_index` (zero-based, in document order) " .
					"to target which section to replace. The new section can be a different type/variant.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'post_id'       => array( 'type' => 'integer' ),
						'section_index' => array( 'type' => 'integer', 'description' => 'Zero-based index of the section to replace' ),
						'type'          => array( 'type' => 'string', 'enum' => $section_enum ),
						'variant'       => array( 'type' => 'string' ),
						'data'          => array( 'type' => 'object' ),
					),
					'required' => array( 'post_id', 'section_index', 'type', 'data' ),
				),
			),
			array(
				'name'        => 'set_globals',
				'description' =>
					"Update the page-level styling (colors, fonts, layout). Already-rendered sections inherit the " .
					"new globals on next render. Pass any subset of `colors`, `fonts`, `layout` — omitted keys are " .
					"left untouched.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'post_id' => array( 'type' => 'integer' ),
						'colors'  => array( 'type' => 'object', 'description' => 'Partial colors block (primary, dark_bg, light_bg, white, text_dark, text_muted, accent, etc.)' ),
						'fonts'   => array( 'type' => 'object', 'description' => 'Partial fonts block (heading, body)' ),
						'layout'  => array( 'type' => 'object', 'description' => 'Partial layout block (boxed_width, section_padding, card_radius, button_radius)' ),
						'force'   => array( 'type' => 'boolean', 'description' => 'Only when the user explicitly asks to change site-wide colors/fonts/layout: set true to override the global-styles lock if one is set.' ),
					),
					'required' => array( 'post_id' ),
				),
			),
			array(
				'name'        => 'list_pages',
				'description' => "List PressGo-built pages (drafts + published). Returns ID, title, status, edit URL.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'limit'  => array( 'type' => 'integer', 'description' => 'Max pages to return (default 20, max 100)' ),
						'status' => array( 'type' => 'string', 'enum' => array( 'any', 'draft', 'publish' ), 'description' => 'Filter by status (default any)' ),
					),
				),
			),
			array(
				'name'        => 'get_brain',
				'description' =>
					"Return the PressGo design brain — the full catalogue of variants per section type, plus the " .
					"variant-pairing patterns and industry recommendations. Read this once before building pages " .
					"so you know what variants exist.",
				'inputSchema' => array( 'type' => 'object', 'properties' => new stdClass() ),
			),
			array(
				'name'        => 'add_sections',
				'description' =>
					"Append SEVERAL sections to a page in a single call. **Strongly preferred over " .
					"calling add_section in a loop** — one DB write, much faster, and avoids the " .
					"round-trip latency. Pass an array of `{type, variant?, data}` objects in the " .
					"order they should appear.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'post_id'  => array( 'type' => 'integer' ),
						'sections' => array(
							'type'        => 'array',
							'description' => 'Ordered list of sections to append.',
							'items'       => array(
								'type'       => 'object',
								'properties' => array(
									'type'    => array( 'type' => 'string', 'enum' => $section_enum ),
									'variant' => array( 'type' => 'string' ),
									'data'    => array( 'type' => 'object' ),
								),
								'required'   => array( 'type', 'data' ),
							),
						),
					),
					'required' => array( 'post_id', 'sections' ),
				),
			),
			array(
				'name'        => 'screenshot_page',
				'description' =>
					"Capture screenshots of a PressGo-built page so you can SEE what you've built. Use this " .
					"liberally — every time you change a section, look at the result. Cheap and fast.\n\n" .
					"Modes:\n" .
					"  - viewport=desktop|tablet|mobile (default desktop) — single viewport\n" .
					"  - viewport=all — returns three PNGs (desktop+tablet+mobile) in one call\n" .
					"  - full_page=true — capture the entire scrolled length, not just the viewport fold\n" .
					"  - section_index=N — crop to just one section so you can iterate on it tightly\n\n" .
					"Pair viewport=all with full_page=true for full mobile/tablet/desktop QA in one shot.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'post_id'       => array( 'type' => 'integer', 'description' => 'Page to screenshot' ),
						'viewport'      => array(
							'type'        => 'string',
							'enum'        => array( 'desktop', 'mobile', 'tablet', 'all' ),
							'description' => '`all` returns three images (desktop+tablet+mobile). Defaults to desktop.',
						),
						'full_page'     => array(
							'type'        => 'boolean',
							'description' => 'If true, capture the entire scrolled length. Default false.',
						),
						'section_index' => array(
							'type'        => 'integer',
							'description' => 'Zero-based section index to crop to. Default: full page (or first viewport).',
						),
					),
					'required' => array( 'post_id' ),
				),
			),
			array(
				'name'        => 'screenshot_url',
				'description' =>
					"Capture a screenshot of ANY public URL — competitor sites, an aspirational design, " .
					"another WordPress install you own, a blog post on this same site, anything. Same modes " .
					"as `screenshot_page` (viewport, full_page, section_index) but takes a URL instead of a " .
					"post_id, so you can render pages this plugin doesn't manage.\n\n" .
					"Use this when:\n" .
					"  - the user shares a competitor or reference URL and you want to look at it\n" .
					"  - you're managing multiple WP installs and want to screenshot one without installing the plugin there\n" .
					"  - you need a screenshot of a non-PressGo page on this site (e.g. a blog post)\n\n" .
					"Counts against the same daily screenshot quota as `screenshot_page`. Private/local IPs " .
					"are rejected by the service.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'url'           => array(
							'type'        => 'string',
							'description' => 'Absolute http or https URL to render.',
						),
						'viewport'      => array(
							'type'        => 'string',
							'enum'        => array( 'desktop', 'mobile', 'tablet', 'all' ),
							'description' => '`all` returns three images (desktop+tablet+mobile). Defaults to desktop.',
						),
						'full_page'     => array(
							'type'        => 'boolean',
							'description' => 'If true, capture the entire scrolled length. Default false.',
						),
						'section_index' => array(
							'type'        => 'integer',
							'description' => 'Zero-based top-level section index to crop to. Default: full page (or first viewport).',
						),
					),
					'required' => array( 'url' ),
				),
			),
			array(
				'name'        => 'clone_page',
				'description' =>
					"Duplicate an existing PressGo page so you can iterate on a variant without losing the " .
					"original. Returns the new page's edit/watch URLs.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'post_id'  => array( 'type' => 'integer', 'description' => 'Page to clone' ),
						'new_title' => array( 'type' => 'string', 'description' => 'Title for the clone (defaults to original + " (copy)")' ),
					),
					'required' => array( 'post_id' ),
				),
			),
			// ── Pro tier (header/footer) ─────────────────────────────────
			// These tools are advertised on every install, but if the site
			// doesn't have a Pro license they return an isError with an
			// upgrade message — so Claude can tell the user exactly how to
			// unlock them.
			array(
				'name'        => 'set_header',
				'description' =>
					"Pro: set the site-wide header (logo + nav + primary CTA) for every PressGo page on this site. " .
					"Pass `items` as ordered nav links and a `cta` for the primary button. The header gets injected " .
					"as the first section on every PressGo-built page. Requires PressGo Plus ($12/mo).",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'logo'  => array( 'type' => 'object', 'description' => 'Logo {text, url, image_url?}' ),
						'items' => array( 'type' => 'array', 'description' => 'Nav links [{text, url}]' ),
						'cta'   => array( 'type' => 'object', 'description' => 'Primary CTA {text, url}' ),
						'style' => array( 'type' => 'string', 'enum' => array( 'minimal', 'centered', 'pill' ), 'description' => 'Optional layout style' ),
					),
					'required' => array(),
				),
			),
			array(
				'name'        => 'set_footer',
				'description' =>
					"Pro: set the site-wide footer for every PressGo page on this site. Pass `brand`, `columns` " .
					"(link sections), and optional `social`. The footer gets injected as the last section on every " .
					"PressGo-built page. Requires PressGo Plus ($12/mo).",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'brand'   => array( 'type' => 'object', 'description' => 'Brand block {name, description, logo?}' ),
						'columns' => array( 'type' => 'array', 'description' => 'Link columns [{title, items:[{text,url}]}]' ),
						'social'  => array( 'type' => 'array', 'description' => 'Social icons [{platform, url}]' ),
						'copyright' => array( 'type' => 'string' ),
						'variant' => array( 'type' => 'string', 'enum' => array( 'default', 'light' ) ),
					),
					'required' => array(),
				),
			),
			array(
				'name'        => 'get_header',
				'description' => "Pro: read the current site-wide header template. Requires PressGo Plus ($12/mo).",
				'inputSchema' => array( 'type' => 'object', 'properties' => new stdClass() ),
			),
			array(
				'name'        => 'get_footer',
				'description' => "Pro: read the current site-wide footer template. Requires PressGo Plus ($12/mo).",
				'inputSchema' => array( 'type' => 'object', 'properties' => new stdClass() ),
			),

			array(
				'name'        => 'undo_last_change',
				'description' =>
					"Roll back the most recent write operation on a page. Every add_section, add_sections, " .
					"update_section, and set_globals call snapshots the prior state, so you can experiment " .
					"freely and undo if you don't like what you did. Up to 20 levels of history per page.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'post_id' => array( 'type' => 'integer' ),
					),
					'required' => array( 'post_id' ),
				),
			),
			array(
				'name'        => 'inspect_page',
				'description' =>
					"Return a compact summary of a page's current sections — type, variant, and a short " .
					"label per section. Cheaper than reading the full pressgo://pages/{id} resource. " .
					"Use this to check state between operations without flooding context with section JSON.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'post_id' => array( 'type' => 'integer' ),
					),
					'required' => array( 'post_id' ),
				),
			),
			array(
				'name'        => 'list_recent_media',
				'description' =>
					"List the most recent images in the WordPress media library. The PRIMARY way " .
					"to handle user images: tell the user to drop the image into their WP admin's " .
					"Media Library (https://<site>/wp-admin/upload.php), then call this tool to " .
					"find it. Returns up to `limit` recent image attachments with {id, url, " .
					"filename, mime, alt, width, height, uploaded_at}.\n\n" .
					"**DO NOT curl/fetch the returned URLs.** The site host is not in your sandbox " .
					"network allowlist — external fetches return 403 host_not_allowed. You don't " .
					"need to fetch them: when there are multiple results (or auto-generated " .
					"filenames), this tool auto-embeds each thumbnail as an MCP image content " .
					"block, which your client renders straight into your vision input. The bytes " .
					"are already in your context. Don't reach for web_fetch.\n\n" .
					"When to use this vs the upload tools:\n" .
					"  - **First choice — almost always**: tell user \"open " .
					"     https://<site>/wp-admin/upload.php in a new tab, drop your image there, " .
					"     and tell me when done\" → then call `list_recent_media({since_minutes: 5})` " .
					"     → use the URL. 5 seconds of user effort, no token-budget gymnastics, no " .
					"     chunking, just works for any image size including multi-MB photos.\n" .
					"  - Use `upload_media({url})` when the user shares a public URL.\n" .
					"  - Use `upload_media({data})` only for tiny images (base64 < 16K).\n" .
					"  - Use `upload_media_chunked` only as last resort (slow, fragile).\n\n" .
					"**since_minutes guidance:** if the user just said they uploaded (in this turn), " .
					"use 5. If you're scanning for an older asset, use 60 (default). Going wider " .
					"surfaces stale leftovers from prior sessions.\n\n" .
					"If multiple recent uploads match, ask the user which one (read filenames " .
					"back to them). Don't guess — the wrong image is worse than asking.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'limit'              => array( 'type' => 'integer', 'description' => 'Max items to return (default 10, max 50).' ),
						'since_minutes'      => array( 'type' => 'integer', 'description' => 'Only return uploads within this many minutes. Use 5 if user just uploaded this turn; 60 (default) otherwise. Max 1440.' ),
						'mime_prefix'        => array( 'type' => 'string', 'description' => 'Filter by MIME prefix (default "image/").' ),
						'include_thumbnails' => array( 'type' => 'boolean', 'description' => 'Embed each result\'s thumbnail as an MCP image content block so you can SEE what each image is. Defaults to true when there are multiple results OR auto-generated filenames (UUIDs, IMG_*, etc). Pass false to skip and save tokens when you only need URLs.' ),
					),
				),
			),
			array(
				'name'        => 'upload_media_chunked',
				'description' =>
					"Upload a LARGE image by chunking. Use this when the base64 of the image is more " .
					"than ~16KB (single-shot upload_media fails on anything bigger). The constraint " .
					"is YOUR output budget per tool call, not the server — each call's args have to " .
					"fit in one of your responses.\n\n" .
					"### Chunk size — IMPORTANT\n" .
					"**Use chunks of EXACTLY 16,000 base64 characters or smaller**. Larger chunks " .
					"hit your max_tokens ceiling on a single response and the tool call gets cut off " .
					"mid-stream (you'll see the request spin forever and never get a reply). 16K " .
					"base64 ≈ 12KB raw bytes ≈ ~4K output tokens, which fits comfortably.\n\n" .
					"For a typical 500KB photo: base64 is ~680KB → 43 chunks at 16K each. That sounds " .
					"like a lot, but each call is fast and the user sees real progress (\"45% uploaded\").\n\n" .
					"### Procedure\n" .
					"  1. Compute the total: `total = ceil(base64.length / 16000)`.\n" .
					"  2. First call: chunk = base64[0:16000], index=0, total=N, (filename, alt). " .
					"     Server returns `upload_id`.\n" .
					"  3. Subsequent calls: chunk = base64[i*16000 : (i+1)*16000], index=i, total=N, " .
					"     upload_id=<the id from step 2>. (Pass `alt` again on the last call too.)\n" .
					"  4. Last call (index=total-1) finalizes — server assembles + sideloads + " .
					"     returns the permanent media URL + attachment ID.\n\n" .
					"Server enforces 10MB total. Allowed MIMEs: PNG, JPG, GIF, WEBP.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'chunk'     => array( 'type' => 'string', 'description' => 'Base64-encoded slice of image bytes (with or without data: prefix on first chunk).' ),
						'index'     => array( 'type' => 'integer', 'description' => 'Zero-based chunk index.' ),
						'total'     => array( 'type' => 'integer', 'description' => 'Total number of chunks for this upload.' ),
						'upload_id' => array( 'type' => 'string', 'description' => 'Returned by the first call. Pass on every subsequent chunk.' ),
						'filename'  => array( 'type' => 'string', 'description' => 'Suggested filename.' ),
						'alt'       => array( 'type' => 'string', 'description' => 'Alt text. Set on the last call.' ),
					),
					'required' => array( 'chunk', 'index', 'total' ),
				),
			),
			array(
				'name'        => 'set_user_profile',
				'description' =>
					"Save the current user's profile so future chats don't have to re-ask onboarding " .
					"questions. Persists across sessions on the WordPress install. Call after the user " .
					"answers the welcome-wizard questions on their FIRST interaction. After that, " .
					"`get_user_profile` returns these values automatically and the welcome wizard is " .
					"skipped. All fields optional — pass only what the user told you. Whenever the user " .
					"corrects something about themselves later (\"actually I'm a developer\"), call this " .
					"again to update.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'user_type'        => array( 'type' => 'string', 'enum' => array( 'designer', 'developer', 'marketer', 'business_owner', 'agency', 'exploring' ), 'description' => 'Who the user is.' ),
						'site_type'        => array( 'type' => 'string', 'enum' => array( 'small_business', 'personal_blog', 'portfolio', 'ecommerce', 'saas', 'nonprofit', 'agency_client', 'other' ), 'description' => 'What kind of website overall.' ),
						'expertise_level'  => array( 'type' => 'string', 'enum' => array( 'beginner', 'intermediate', 'advanced' ), 'description' => 'How comfortable with WordPress / Elementor / web design.' ),
						'voice_preference' => array( 'type' => 'string', 'description' => 'Plain-text capture of preferred tone (e.g. "playful but premium").' ),
						'notes'            => array( 'type' => 'string', 'description' => 'Anything else useful to remember across chats.' ),
					),
				),
			),
			array(
				'name'        => 'get_user_profile',
				'description' =>
					"Read the current user's saved profile. Returns `{ known: false }` for first-time " .
					"users — run the welcome wizard (3-4 questions max) before building anything, then " .
					"call `set_user_profile` to remember the answers. Returns `{ known: true, profile }` " .
					"for returning users — skip the wizard and use the profile to calibrate. You don't " .
					"need to call this on every chat — the same data is auto-injected into your " .
					"initialize instructions on every connection.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => new stdClass(),
				),
			),
			array(
				'name'        => 'set_brand_foundation',
				'description' =>
					"Save the site's BRAND FOUNDATION — the reusable design system every page should follow: " .
					"brand name, logo, voice/tone, color palette, heading/body fonts, and corner radii. Call this " .
					"ONCE after your discovery interview (or when the user gives you brand assets / a style " .
					"reference), and update it whenever the brand changes. It persists across all chats and is " .
					"auto-injected into your initialize instructions, so future sessions start from the same design " .
					"system instead of re-deriving colors and fonts from scratch. New pages inherit these tokens by " .
					"default. All fields optional — pass only what you've established.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'brand_name' => array( 'type' => 'string', 'description' => 'The brand / business name.' ),
						'logo_url'   => array( 'type' => 'string', 'description' => 'Logo image URL (from upload_media / list_recent_media).' ),
						'voice'      => array( 'type' => 'string', 'description' => 'Voice & tone in plain text (e.g. "warm, plain, confident; no hype").' ),
						'colors'     => array( 'type' => 'object', 'description' => 'Palette: primary, accent, dark_bg, light_bg, white, text_dark, text_muted (hex).' ),
						'fonts'      => array( 'type' => 'object', 'description' => 'Typography: heading, body (Google Font family names).' ),
						'layout'     => array( 'type' => 'object', 'description' => 'Tokens: boxed_width, section_padding, card_radius, button_radius.' ),
					),
				),
			),
			array(
				'name'        => 'get_brand_foundation',
				'description' =>
					"Read the saved brand foundation (design system) for this site — brand name, logo, voice, " .
					"colors, fonts, layout tokens — or empty if none set. You usually don't need to call this; the " .
					"foundation is auto-injected into your initialize instructions on every connection.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => new stdClass(),
				),
			),
			array(
				'name'        => 'upload_media',
				'description' =>
					"Upload an image into the WordPress media library. Provide EITHER `data` (base64-encoded " .
					"image bytes — with or without `data:image/...;base64,` prefix) for direct upload, OR " .
					"`url` (https://...) to fetch and copy from a public source. Returns the attachment ID + " .
					"permanent URL you can pass into image fields (hero.image.url, features.items[].image.url, " .
					"footer.brand.logo.url, etc.). PNG / JPG / WEBP / GIF supported, 10MB max. Use this when " .
					"the user pastes an image into chat (base64 from Claude's image attachment), when they " .
					"share a competitor or aspirational URL you want to reference, or after `screenshot_page` " .
					"if you want to preserve the screenshot in the media library.",
				'inputSchema' => array(
					'type'       => 'object',
					'properties' => array(
						'data'     => array( 'type' => 'string', 'description' => 'Base64-encoded image bytes (with or without `data:image/...;base64,` prefix). Use this OR `url`.' ),
						'url'      => array( 'type' => 'string', 'description' => 'Public HTTPS URL of an image to fetch + copy into the media library. Use this OR `data`.' ),
						'filename' => array( 'type' => 'string', 'description' => 'Suggested filename, e.g. "hero-bg.jpg". Auto-generated if omitted.' ),
						'alt'      => array( 'type' => 'string', 'description' => 'Alt text for accessibility + SEO. Strongly recommended.' ),
					),
				),
			),
		);

		// Annotations — required by the Anthropic Connectors Directory.
		// Each tool gets a human display title and either a readOnlyHint
		// (no side effects) or a destructiveHint (modifies state).
		$annotations = array(
			'create_page'      => array( 'title' => 'Create page',           'destructiveHint' => true,  'idempotentHint' => false ),
			'add_section'      => array( 'title' => 'Add section',           'destructiveHint' => true,  'idempotentHint' => false ),
			'add_sections'     => array( 'title' => 'Add sections (batched)','destructiveHint' => true,  'idempotentHint' => false ),
			'update_section'   => array( 'title' => 'Update section',        'destructiveHint' => true,  'idempotentHint' => true  ),
			'set_globals'      => array( 'title' => 'Update page globals',   'destructiveHint' => true,  'idempotentHint' => true  ),
			'list_pages'       => array( 'title' => 'List pages',            'readOnlyHint'    => true,  'idempotentHint' => true  ),
			'get_brain'        => array( 'title' => 'Read PressGo brain',    'readOnlyHint'    => true,  'idempotentHint' => true  ),
			'screenshot_page'  => array( 'title' => 'Screenshot page',       'readOnlyHint'    => true,  'idempotentHint' => true,  'openWorldHint' => true ),
			'screenshot_url'   => array( 'title' => 'Screenshot URL',        'readOnlyHint'    => true,  'idempotentHint' => true,  'openWorldHint' => true ),
			'inspect_page'     => array( 'title' => 'Inspect page state',    'readOnlyHint'    => true,  'idempotentHint' => true  ),
			'clone_page'       => array( 'title' => 'Clone page',            'destructiveHint' => true,  'idempotentHint' => false ),
			'undo_last_change' => array( 'title' => 'Undo last change',      'destructiveHint' => true,  'idempotentHint' => false ),
			'set_header'       => array( 'title' => 'Set site-wide header (Pro)', 'destructiveHint' => true, 'idempotentHint' => true ),
			'set_footer'       => array( 'title' => 'Set site-wide footer (Pro)', 'destructiveHint' => true, 'idempotentHint' => true ),
			'get_header'       => array( 'title' => 'Read site-wide header', 'readOnlyHint'    => true,  'idempotentHint' => true  ),
			'get_footer'       => array( 'title' => 'Read site-wide footer', 'readOnlyHint'    => true,  'idempotentHint' => true  ),
			'upload_media'     => array( 'title' => 'Upload media',          'destructiveHint' => true,  'idempotentHint' => false, 'openWorldHint' => true ),
			'upload_media_chunked' => array( 'title' => 'Upload media (chunked)', 'destructiveHint' => true, 'idempotentHint' => false, 'openWorldHint' => true ),
			'list_recent_media' => array( 'title' => 'List recent media',     'readOnlyHint'    => true,  'idempotentHint' => true ),
			'set_user_profile' => array( 'title' => 'Save user profile',     'destructiveHint' => true,  'idempotentHint' => true ),
			'get_user_profile' => array( 'title' => 'Read user profile',     'readOnlyHint'    => true,  'idempotentHint' => true ),
		);
		foreach ( $defs as &$def ) {
			if ( isset( $def['name'], $annotations[ $def['name'] ] ) ) {
				$def['annotations'] = $annotations[ $def['name'] ];
			}
		}
		unset( $def );
		return $defs;
	}

	/* ─── Dispatch ──────────────────────────────────────────────────── */

	public static function call( $name, $args, $user ) {
		if ( ! is_array( $args ) ) {
			$args = (array) $args;
		}
		switch ( $name ) {
			case 'create_page':     return self::create_page( $args, $user );
			case 'add_section':     return self::add_section( $args, $user );
			case 'update_section':  return self::update_section( $args, $user );
			case 'set_globals':     return self::set_globals( $args, $user );
			case 'list_pages':      return self::list_pages( $args, $user );
			case 'get_brain':       return self::get_brain( $args, $user );
			case 'screenshot_page': return self::screenshot_page( $args, $user );
			case 'screenshot_url':  return self::screenshot_url( $args, $user );
			case 'add_sections':    return self::add_sections( $args, $user );
			case 'clone_page':      return self::clone_page( $args, $user );
			case 'inspect_page':    return self::inspect_page( $args, $user );
			case 'undo_last_change': return self::undo_last_change( $args, $user );
			case 'upload_media':    return self::upload_media( $args, $user );
			case 'upload_media_chunked': return self::upload_media_chunked( $args, $user );
			case 'list_recent_media': return self::list_recent_media( $args, $user );
			case 'set_user_profile': return self::set_user_profile( $args, $user );
			case 'get_user_profile': return self::get_user_profile( $args, $user );
			case 'set_brand_foundation': return self::set_brand_foundation( $args, $user );
			case 'get_brand_foundation': return self::get_brand_foundation( $args, $user );

			// Pro-tier tools — gated by PressGo_License.
			case 'set_header':
			case 'set_footer':
			case 'get_header':
			case 'get_footer':
				return self::pro_dispatch( $name, $args, $user );
		}
		return new WP_Error( 'mcp_unknown_tool', "Unknown tool: {$name}" );
	}

	/* ─── Tools ─────────────────────────────────────────────────────── */

	private static function create_page( $args, $user ) {
		$title  = isset( $args['title'] ) ? sanitize_text_field( $args['title'] ) : '';
		$config = isset( $args['config'] ) && is_array( $args['config'] ) ? $args['config'] : null;
		if ( '' === $title ) {
			return new WP_Error( 'mcp_bad_args', 'create_page requires a non-empty title.' );
		}

		// Free-tier daily cap on page creation. Pro is unlimited.
		$cap_check = self::check_free_create_cap();
		if ( is_wp_error( $cap_check ) ) {
			return $cap_check;
		}

		// What will this site render new pages through? Only seed Elementor
		// claim-metas when the page will actually be an Elementor page —
		// otherwise stamp the resolved target so every later tool call (and
		// the renderer's neutralize step) treats it as a config-driven page.
		$default_target = class_exists( 'PressGo_Render_Targets' ) ? PressGo_Render_Targets::resolve( 0 ) : 'elementor';
		$seed_meta = ( 'elementor' === $default_target )
			? array(
				'_elementor_edit_mode'      => 'builder',
				'_elementor_template_type'  => 'wp-page',
				'_wp_page_template'         => 'elementor_canvas',
				'_elementor_data'           => '[]',
				'_elementor_page_settings'  => array( 'hide_title' => 'yes' ),
			)
			: array(
				'_pressgo_target_builder'   => $default_target,
			);

		// If config provided, build incrementally (one bad section shouldn't
		// nuke the whole call). Otherwise create an empty draft.
		if ( $config ) {
			$validated = PressGo_Config_Validator::validate( $config );
			if ( is_wp_error( $validated ) ) {
				return $validated;
			}

			// First create an empty draft so we can use the same code path as add_sections.
			$post_id = wp_insert_post( array(
				'post_title'  => $title,
				'post_status' => 'draft',
				'post_type'   => 'page',
				'post_author' => (int) $user->ID,
				'meta_input'  => $seed_meta,
			), true );
			if ( is_wp_error( $post_id ) ) {
				return $post_id;
			}
			update_post_meta( $post_id, '_pressgo_built', '1' );
			self::set_page_globals( $post_id, array(
				'colors' => $validated['colors'],
				'fonts'  => $validated['fonts'],
				'layout' => $validated['layout'],
			) );
			self::maybe_inject_globals( $post_id );

			// Non-Elementor target: the config IS the page — apply it in one
			// shot through the full pipeline (stores _pressgo_ai_config,
			// renders post_content, purges caches).
			if ( 'elementor' !== $default_target ) {
				$apply = self::apply_config( $post_id, $config );
				if ( is_wp_error( $apply ) ) {
					return $apply;
				}
				self::increment_free_create_counter();
				self::send_heartbeat();
				$count     = isset( $apply['sections'] ) ? (int) $apply['sections'] : 0;
				$watch_url = class_exists( 'PressGo_MCP_Admin' ) ? PressGo_MCP_Admin::watch_url( $post_id ) : '';
				return self::page_summary( $post_id,
					"Page {$post_id} built with {$count} section(s) from your config via the {$default_target} renderer.\n\n" .
					"⚠️ Tell the user this URL to see it: {$watch_url}"
				);
			}

			// Convert config -> sections array and dispatch through add_sections logic.
			$order = isset( $validated['sections'] ) && is_array( $validated['sections'] )
				? $validated['sections']
				: array_keys( $validated );
			$sections = array();
			foreach ( $order as $section_key ) {
				if ( ! isset( $validated[ $section_key ] ) ) { continue; }
				if ( ! in_array( $section_key, self::VALID_SECTION_TYPES, true ) ) { continue; }
				$data = $validated[ $section_key ];
				$variant = isset( $data['variant'] ) ? $data['variant'] : null;
				if ( $variant ) { unset( $data['variant'] ); }
				$sections[] = array( 'type' => $section_key, 'variant' => $variant, 'data' => $data );
			}

			if ( $sections ) {
				$batch = self::add_sections( array( 'post_id' => $post_id, 'sections' => $sections ), $user );
				$failed = isset( $batch['structuredContent']['failed'] ) ? $batch['structuredContent']['failed'] : array();
				$built  = count( $sections ) - count( $failed );
				$watch_url = class_exists( 'PressGo_MCP_Admin' ) ? PressGo_MCP_Admin::watch_url( $post_id ) : '';
				$note = "Page {$post_id} built with {$built}/" . count( $sections ) . " sections from your config.\n\n" .
					"⚠️ Tell the user this URL to see it: {$watch_url}";
				if ( $failed ) {
					$note .= "\n\nFailed sections: " . wp_json_encode( $failed );
				}
				return self::page_summary( $post_id, $note );
			}
		} else {
			$post_id = wp_insert_post( array(
				'post_title'  => $title,
				'post_status' => 'draft',
				'post_type'   => 'page',
				'post_author' => (int) $user->ID,
				'meta_input'  => $seed_meta,
			), true );
			if ( is_wp_error( $post_id ) ) {
				return $post_id;
			}
			self::stamp_pressgo_meta( $post_id, array() );
			self::maybe_inject_globals( $post_id );
			// Non-Elementor: seed an empty AI config so incremental add_section
			// calls have a config to append to (the config is the source of
			// truth on these targets).
			if ( 'elementor' !== $default_target ) {
				$empty = array_merge( self::default_globals(), array( 'sections' => array() ) );
				update_post_meta( $post_id, '_pressgo_ai_config', wp_slash( wp_json_encode( $empty ) ) );
			}
		}

		// Increment the daily free counter only on successful create.
		self::increment_free_create_counter();

		// Fire-and-forget heartbeat to pressgo.app for real-time visibility.
		self::send_heartbeat();

		$watch_url = class_exists( 'PressGo_MCP_Admin' ) ? PressGo_MCP_Admin::watch_url( $post_id ) : '';
		$note = "Page created (id={$post_id}).\n\n" .
			"⚠️ ACTION REQUIRED: Tell the user this URL to watch you build the page live in their browser:\n" .
			"  {$watch_url}\n\n" .
			"Don't paraphrase — give them the exact URL. They open it in another tab, then every section " .
			"you add appears within ~1.5s. This is the headline feature; don't skip it.";
		return self::page_summary( $post_id, $note );
	}

	/**
	 * Free-tier daily cap on create_page. Pro tier is unlimited.
	 *
	 * Counter is per-WP-install, daily-rotating, stored in wp_options
	 * (`pressgo_free_creates_YYYY-MM-DD`). Resets at UTC midnight.
	 *
	 * Returns null if allowed, WP_Error if cap reached. Filterable cap:
	 *   add_filter('pressgo_free_create_cap_per_day', fn() => 5);
	 */
	public static function check_free_create_cap() {
		if ( class_exists( 'PressGo_License' ) && ( new PressGo_License() )->is_pro() ) {
			return null; // Pro = no cap
		}
		$cap   = (int) apply_filters( 'pressgo_free_create_cap_per_day', 3 );
		$key   = 'pressgo_free_creates_' . gmdate( 'Y-m-d' );
		$count = (int) get_option( $key, 0 );
		if ( $count < $cap ) {
			return null;
		}
		$msg = sprintf(
			"Free-tier daily limit reached: %d/%d pages today. Three options to share with the user:\n\n" .
			"  1. Upgrade to PressGo Pro — unlimited builds + custom header/footer + 1,000 screenshots/day.\n" .
			"     Sign up: https://pressgo.app/dashboard\n\n" .
			"  2. Wait until UTC midnight — the limit resets each day.\n\n" .
			"  3. Use the credit-based generator inside WordPress (PressGo menu > Generate). " .
			"     Pay-per-page, separate from this MCP flow.\n\n" .
			"Tell the user about all three; let them pick. Do not retry the create until tomorrow " .
			"or until the user upgrades.",
			$count, $cap
		);
		return new WP_Error( 'mcp_free_cap_exceeded', $msg, array(
			'status'      => 429,
			'used'        => $count,
			'cap'         => $cap,
			'remaining'   => 0,
			'upgrade_url' => 'https://pressgo.app/dashboard',
			'resets_at'   => gmdate( 'Y-m-d\TH:i:s\Z', strtotime( 'tomorrow midnight UTC' ) ),
		) );
	}

	/**
	 * Increment today's free-tier create counter. No-op for Pro sites.
	 */
	public static function increment_free_create_counter() {
		if ( class_exists( 'PressGo_License' ) && ( new PressGo_License() )->is_pro() ) {
			return;
		}
		$key   = 'pressgo_free_creates_' . gmdate( 'Y-m-d' );
		$count = (int) get_option( $key, 0 );
		update_option( $key, $count + 1, false ); // autoload=false so this doesn't bloat object cache
	}

	/**
	 * Fire-and-forget heartbeat POST to pressgo.app/api/plugin/heartbeat after
	 * a successful create_page. Tells the backend our current count + tier so
	 * we have real-time per-site visibility on the admin endpoint.
	 *
	 * Non-blocking wp_remote_post. Timeout must be >= 0.5s: at 0.01s the TLS
	 * handshake never completes and NO heartbeat ever arrives (confirmed via
	 * nginx logs 2026-07-21 — months of MCP heartbeats, zero received).
	 * Failures are silently ignored.
	 */
	public static function send_heartbeat() {
		global $wp_version;
		$is_pro = class_exists( 'PressGo_License' ) && ( new PressGo_License() )->is_pro();
		$tier   = $is_pro ? 'pro' : 'free';
		$count  = $is_pro ? 0 : (int) get_option( 'pressgo_free_creates_' . gmdate( 'Y-m-d' ), 0 );

		wp_remote_post( 'https://pressgo.app/api/plugin/heartbeat', array(
			'timeout'  => 0.5, // fire-and-forget, but long enough for TLS to start
			'blocking' => false,
			'headers'  => array(
				'Content-Type'   => 'application/json',
				'X-Pressgo-Site' => md5( home_url() ),
				'X-Pressgo-Tier' => $tier,
			),
			'body'     => wp_json_encode( array(
				'creates_today'  => $count,
				'plugin_version' => defined( 'PRESSGO_VERSION' ) ? PRESSGO_VERSION : '0',
				'wp_version'     => $wp_version ?? '',
			) ),
		) );
	}

	/**
	 * On create_page / clone_page, drop in the global header/footer if any
	 * exist AND the site has a Pro license.
	 */
	private static function maybe_inject_globals( $post_id ) {
		if ( ! class_exists( 'PressGo_License' ) ) { return; }
		$license = new PressGo_License();
		if ( ! $license->is_pro() ) { return; }

		$header = get_option( self::HEADER_OPTION, null );
		$footer = get_option( self::FOOTER_OPTION, null );
		if ( $header ) { self::apply_global_section( $post_id, 'header', $header ); }
		if ( $footer ) { self::apply_global_section( $post_id, 'footer', $footer ); }
	}

	private static function add_section( $args, $user ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		$type    = isset( $args['type'] ) ? sanitize_key( $args['type'] ) : '';
		$variant = isset( $args['variant'] ) ? sanitize_key( $args['variant'] ) : '';
		$data    = ( isset( $args['data'] ) && is_array( $args['data'] ) ) ? $args['data'] : null;

		$err = self::guard_post( $post_id, $user );
		if ( is_wp_error( $err ) ) { return $err; }
		$pause = self::check_pause( $post_id, $args );
		if ( is_wp_error( $pause ) ) { return $pause; }
		if ( ! in_array( $type, self::VALID_SECTION_TYPES, true ) ) {
			return new WP_Error( 'mcp_bad_args', "Unknown section type: {$type}" );
		}
		if ( ! $data ) {
			return new WP_Error( 'mcp_bad_args', 'Missing or invalid `data` payload.' );
		}

		// Non-Elementor targets: the stored AI config is the source of truth —
		// append the section there and re-apply through the full pipeline.
		$target = self::page_target( $post_id );
		if ( 'elementor' !== $target ) {
			return self::config_add_sections( $post_id, array(
				array( 'type' => $type, 'variant' => $variant, 'data' => $data ),
			), $target );
		}

		// Build a single-section partial config using the page's existing globals.
		$section_data = $data;
		if ( $variant ) {
			$section_data['variant'] = $variant;
		}

		$globals = self::get_page_globals( $post_id );
		$partial = array_merge( $globals, array(
			'sections' => array( $type ),
			$type      => $section_data,
		) );

		$validated = PressGo_Config_Validator::validate( $partial );
		if ( is_wp_error( $validated ) ) { return $validated; }

		$generator = new PressGo_Generator();
		$elements  = $generator->generate( $validated );
		if ( empty( $elements[0] ) ) {
			return new WP_Error( 'mcp_build_failed', 'Section builder returned no elements (the section data may be invalid).' );
		}

		// Snapshot the prior state so undo_last_change can restore it.
		self::push_undo( $post_id, "add_section {$type}" . ( $variant ? "/{$variant}" : '' ) );

		// Append to existing _elementor_data.
		$existing = self::read_elementor_data( $post_id );
		$existing[] = $elements[0];
		self::write_elementor_data( $post_id, $existing );

		// Track the original config so update_section can re-render later.
		self::append_section_record( $post_id, $type, $variant, $data );

		return self::page_summary( $post_id, "Added {$type}" . ( $variant ? " ({$variant})" : '' ) . ". Section index = " . ( count( $existing ) - 1 ) . "." );
	}

	private static function add_sections( $args, $user ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		$err     = self::guard_post( $post_id, $user );
		if ( is_wp_error( $err ) ) { return $err; }
		$pause = self::check_pause( $post_id, $args );
		if ( is_wp_error( $pause ) ) { return $pause; }
		if ( ! isset( $args['sections'] ) || ! is_array( $args['sections'] ) || ! $args['sections'] ) {
			return new WP_Error( 'mcp_bad_args', '`sections` must be a non-empty array.' );
		}

		// Non-Elementor targets: append the whole batch to the stored AI config
		// and re-apply once through the full pipeline.
		$target = self::page_target( $post_id );
		if ( 'elementor' !== $target ) {
			return self::config_add_sections( $post_id, $args['sections'], $target );
		}

		$globals  = self::get_page_globals( $post_id );
		$gen      = new PressGo_Generator();
		$existing = self::read_elementor_data( $post_id );
		$records  = get_post_meta( $post_id, '_pressgo_sections', true );
		if ( ! is_array( $records ) ) { $records = array(); }

		$built  = array();
		$failed = array();

		foreach ( $args['sections'] as $i => $sec ) {
			$type    = isset( $sec['type'] ) ? sanitize_key( $sec['type'] ) : '';
			$variant = isset( $sec['variant'] ) ? sanitize_key( $sec['variant'] ) : '';
			$data    = ( isset( $sec['data'] ) && is_array( $sec['data'] ) ) ? $sec['data'] : null;

			if ( ! in_array( $type, self::VALID_SECTION_TYPES, true ) ) {
				$failed[] = array( 'index' => $i, 'reason' => "Unknown section type: {$type}" );
				continue;
			}
			if ( ! $data ) {
				$failed[] = array( 'index' => $i, 'reason' => 'Missing or invalid data' );
				continue;
			}

			$section_data = $data;
			if ( $variant ) { $section_data['variant'] = $variant; }

			$partial = array_merge( $globals, array(
				'sections' => array( $type ),
				$type      => $section_data,
			) );
			$validated = PressGo_Config_Validator::validate( $partial );
			if ( is_wp_error( $validated ) ) {
				$failed[] = array( 'index' => $i, 'reason' => $validated->get_error_message() );
				continue;
			}

			try {
				$elements = $gen->generate( $validated );
			} catch ( Throwable $e ) {
				$failed[] = array( 'index' => $i, 'reason' => 'Builder threw: ' . $e->getMessage() );
				continue;
			}
			if ( empty( $elements[0] ) ) {
				$failed[] = array( 'index' => $i, 'reason' => 'Builder returned no element' );
				continue;
			}

			$existing[] = $elements[0];
			$records[]  = array( 'type' => $type, 'variant' => $variant ?: null, 'data' => $data );
			$built[]    = $type . ( $variant ? "/{$variant}" : '' );
		}

		// Snapshot prior state so undo can roll back the whole batch.
		self::push_undo( $post_id, 'add_sections (' . count( $built ) . ')' );

		// Single DB write for the entire batch.
		self::write_elementor_data( $post_id, $existing );
		update_post_meta( $post_id, '_pressgo_sections', $records );

		$note = 'Built ' . count( $built ) . ' section(s): ' . implode( ', ', $built );
		if ( $failed ) {
			$note .= ' — ' . count( $failed ) . ' failed: ' . implode( '; ', array_map(
				function ( $f ) { return "[{$f['index']}] {$f['reason']}"; }, $failed
			) );
		}

		$summary = self::page_summary( $post_id, $note );
		// Surface failures in the structured response so the AI can iterate.
		if ( $failed ) {
			$summary['structuredContent']['failed'] = $failed;
		}
		return $summary;
	}

	private static function update_section( $args, $user ) {
		$post_id      = (int) ( $args['post_id'] ?? 0 );
		$index        = (int) ( $args['section_index'] ?? -1 );
		$type         = isset( $args['type'] ) ? sanitize_key( $args['type'] ) : '';
		$variant_arg  = isset( $args['variant'] ) ? sanitize_key( $args['variant'] ) : null;
		$variant_passed = array_key_exists( 'variant', $args );
		$data         = ( isset( $args['data'] ) && is_array( $args['data'] ) ) ? $args['data'] : null;

		$err = self::guard_post( $post_id, $user );
		if ( is_wp_error( $err ) ) { return $err; }
		$pause = self::check_pause( $post_id, $args );
		if ( is_wp_error( $pause ) ) { return $pause; }
		if ( ! in_array( $type, self::VALID_SECTION_TYPES, true ) ) {
			return new WP_Error( 'mcp_bad_args', "Unknown section type: {$type}" );
		}
		if ( ! $data ) {
			return new WP_Error( 'mcp_bad_args', 'Missing or invalid `data` payload.' );
		}

		// Non-Elementor targets: section_index maps to the stored config's
		// sections[] order; merge into that entry and re-apply.
		$target = self::page_target( $post_id );
		if ( 'elementor' !== $target ) {
			return self::config_update_section( $post_id, $index, $type, $variant_arg, $variant_passed, $data, $target );
		}

		$existing = self::read_elementor_data( $post_id );
		if ( $index < 0 || $index >= count( $existing ) ) {
			return new WP_Error( 'mcp_bad_args', "section_index {$index} is out of range (page has " . count( $existing ) . " sections)." );
		}

		// Preserve the existing variant if the caller didn't pass one. AI
		// clients commonly omit `variant` expecting it to stay the same;
		// silently switching to the default variant (and a different builder)
		// is a high-impact failure mode.
		$variant = '';
		if ( $variant_passed && $variant_arg ) {
			$variant = $variant_arg;
		} else {
			$prior_records = get_post_meta( $post_id, '_pressgo_sections', true );
			if ( is_array( $prior_records ) && isset( $prior_records[ $index ] ) ) {
				$prior = $prior_records[ $index ];
				if ( isset( $prior['type'] ) && $prior['type'] === $type && ! empty( $prior['variant'] ) ) {
					$variant = sanitize_key( $prior['variant'] );
				}
			}
		}

		$section_data = $data;
		if ( $variant ) {
			$section_data['variant'] = $variant;
		}

		$globals = self::get_page_globals( $post_id );
		$partial = array_merge( $globals, array(
			'sections' => array( $type ),
			$type      => $section_data,
		) );

		$validated = PressGo_Config_Validator::validate( $partial );
		if ( is_wp_error( $validated ) ) { return $validated; }

		$generator = new PressGo_Generator();
		$elements  = $generator->generate( $validated );
		if ( empty( $elements[0] ) ) {
			return new WP_Error( 'mcp_build_failed', 'Section builder returned no elements.' );
		}

		// Snapshot prior state so undo can restore it.
		self::push_undo( $post_id, "update_section {$index} → {$type}" . ( $variant ? "/{$variant}" : '' ) );

		$existing[ $index ] = $elements[0];
		self::write_elementor_data( $post_id, $existing );

		self::replace_section_record( $post_id, $index, $type, $variant, $data );

		return self::page_summary( $post_id, "Updated section {$index} ({$type}" . ( $variant ? "/{$variant}" : '' ) . ")." );
	}

	const GLOBALS_LOCK_OPTION = 'pressgo_globals_locked';

	/** Whether the site owner has locked global styles (colors/fonts/layout). */
	public static function globals_locked() {
		return (bool) get_option( self::GLOBALS_LOCK_OPTION, false );
	}

	/**
	 * Globals lock guard. When the owner locks global styles in
	 * PressGo > MCP Server, set_globals refuses to overwrite the brand tokens
	 * unless the user explicitly passes force:true.
	 */
	private static function check_globals_lock( $args = array() ) {
		if ( ! empty( $args['force'] ) ) { return null; }
		if ( ! self::globals_locked() ) { return null; }
		return new WP_Error( 'mcp_globals_locked',
			"This site's global styles (colors, fonts, layout) are LOCKED by the owner " .
			"(PressGo > MCP Server > Lock global styles). I won't change them — I'll match the " .
			"existing palette and fonts instead. If the user explicitly asks to change the site-wide " .
			"design, call set_globals again with force: true."
		);
	}

	private static function set_globals( $args, $user ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		$err     = self::guard_post( $post_id, $user );
		if ( is_wp_error( $err ) ) { return $err; }
		$pause = self::check_pause( $post_id, $args );
		if ( is_wp_error( $pause ) ) { return $pause; }
		$lock = self::check_globals_lock( $args );
		if ( is_wp_error( $lock ) ) { return $lock; }

		// set_globals re-renders through the Elementor section records — on a
		// page that renders through another builder it would report success
		// while the live page never changes. Refuse honestly.
		$target = self::page_target( $post_id );
		if ( 'elementor' !== $target ) {
			return new WP_Error( 'mcp_wrong_target',
				"Page {$post_id} renders through {$target}, not Elementor. set_globals is Elementor-only — " .
				"on this page colors/fonts/layout live in its AI config: change them via the AI Builder chat, " .
				"or use update_section to edit individual sections."
			);
		}

		// Snapshot prior state so undo can roll back palette/typography changes.
		self::push_undo( $post_id, 'set_globals' );

		$globals = self::get_page_globals( $post_id );
		foreach ( array( 'colors', 'fonts', 'layout' ) as $key ) {
			if ( isset( $args[ $key ] ) && is_array( $args[ $key ] ) ) {
				$globals[ $key ] = array_merge( $globals[ $key ], $args[ $key ] );
			}
		}
		self::set_page_globals( $post_id, $globals );

		// Re-render existing sections with new globals so the live page reflects the change.
		self::rerender_all_sections( $post_id, $globals );

		return self::page_summary( $post_id, "Globals updated. Existing sections were re-rendered with the new palette/fonts/layout." );
	}

	private static function list_pages( $args, $user ) {
		$limit  = max( 1, min( 100, (int) ( $args['limit'] ?? 20 ) ) );
		$status = isset( $args['status'] ) ? sanitize_key( $args['status'] ) : 'any';
		$states = ( 'any' === $status ) ? array( 'draft', 'publish' ) : array( $status );

		$query  = new WP_Query( array(
			'post_type'      => 'page',
			'post_status'    => $states,
			'posts_per_page' => $limit,
			'meta_key'       => '_pressgo_built',
			'meta_value'     => '1',
			'orderby'        => 'modified',
			'order'          => 'DESC',
			'no_found_rows'  => true,
		) );

		$out = array();
		foreach ( $query->posts as $p ) {
			$out[] = array(
				'post_id'     => (int) $p->ID,
				'title'       => $p->post_title,
				'status'      => $p->post_status,
				'modified_at' => $p->post_modified_gmt . 'Z',
				'edit_url'    => admin_url( "post.php?post={$p->ID}&action=elementor" ),
				'view_url'    => get_permalink( $p->ID ),
			);
		}
		wp_reset_postdata();

		return array(
			'content' => array(
				array( 'type' => 'text', 'text' => wp_json_encode( $out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ),
			),
			'structuredContent' => array( 'pages' => $out ),
		);
	}

	private static function screenshot_page( $args, $user ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		$err     = self::guard_post( $post_id, $user );
		if ( is_wp_error( $err ) ) { return $err; }

		$viewport_arg  = isset( $args['viewport'] ) ? sanitize_key( $args['viewport'] ) : 'desktop';
		$full_page     = ! empty( $args['full_page'] );
		$section_index = isset( $args['section_index'] ) ? (int) $args['section_index'] : null;

		$title = get_the_title( $post_id );
		return self::fetch_screenshot_viewports(
			self::auth_preview_url( $post_id ),
			self::resolve_viewports( $viewport_arg ),
			$full_page,
			$section_index,
			"Post {$post_id} (\"{$title}\")"
		);
	}

	private static function screenshot_url( $args, $user ) {
		$url = isset( $args['url'] ) ? esc_url_raw( $args['url'] ) : '';
		if ( '' === $url ) {
			return new WP_Error( 'mcp_invalid_url', 'url is required (absolute http or https).' );
		}
		$parts = wp_parse_url( $url );
		if ( empty( $parts['scheme'] ) || ! in_array( $parts['scheme'], array( 'http', 'https' ), true ) ) {
			return new WP_Error( 'mcp_invalid_url', 'url must be absolute http or https.' );
		}

		$viewport_arg  = isset( $args['viewport'] ) ? sanitize_key( $args['viewport'] ) : 'desktop';
		$full_page     = ! empty( $args['full_page'] );
		$section_index = isset( $args['section_index'] ) ? (int) $args['section_index'] : null;

		return self::fetch_screenshot_viewports(
			$url,
			self::resolve_viewports( $viewport_arg ),
			$full_page,
			$section_index,
			'URL ' . esc_url( $url )
		);
	}

	/**
	 * Resolve a viewport CLI arg into the list of viewports to capture.
	 * `all` expands to desktop+tablet+mobile; anything else falls back to desktop.
	 */
	private static function resolve_viewports( $viewport_arg ) {
		if ( 'all' === $viewport_arg ) {
			return array( 'desktop', 'tablet', 'mobile' );
		}
		if ( in_array( $viewport_arg, array( 'desktop', 'tablet', 'mobile' ), true ) ) {
			return array( $viewport_arg );
		}
		return array( 'desktop' );
	}

	/**
	 * Render a URL via the PressGo screenshot service for one or more viewports
	 * and pack the bytes into MCP image content blocks.
	 *
	 * Shared by screenshot_page (current site, internal preview URL) and
	 * screenshot_url (any caller-supplied public URL). Both paths count against
	 * the same per-site daily quota via the X-Pressgo-Site header.
	 *
	 * @param string   $url            Public http/https URL to render.
	 * @param array    $viewports      Viewports to capture (desktop|tablet|mobile).
	 * @param bool     $full_page      If true, capture the whole scrolled length.
	 * @param int|null $section_index  Crop to nth top-level section, or null for none.
	 * @param string   $title_text     Label inserted at the top of the content blocks.
	 * @return array|WP_Error          MCP content array, or WP_Error on failure.
	 */
	private static function fetch_screenshot_viewports( $url, $viewports, $full_page, $section_index, $title_text ) {
		$service = (string) get_option( 'pressgo_screenshot_url', 'https://pressgo.app/api/screenshot' );
		if ( '' === trim( $service ) ) {
			return new WP_Error( 'mcp_misconfigured', 'Screenshot service URL is not configured.' );
		}

		$tier      = ( class_exists( 'PressGo_License' ) && ( new PressGo_License() )->is_pro() ) ? 'pro' : 'free';
		$site_hash = md5( home_url() );

		$content_blocks = array();
		$captured       = array();

		foreach ( $viewports as $vp ) {
			$qs = array(
				'url'      => rawurlencode( $url ),
				'viewport' => $vp,
			);
			if ( $full_page ) {
				$qs['full_page'] = '1';
			}
			if ( null !== $section_index ) {
				$qs['section_index'] = $section_index;
			}
			$endpoint = add_query_arg( $qs, $service );

			$response = wp_remote_get( $endpoint, array(
				'timeout' => 90,
				'headers' => array(
					'Accept'         => 'image/png,image/*',
					'X-Pressgo-MCP'  => '1',
					// Site-scoped rate-limit key — one quota per WP install.
					'X-Pressgo-Site' => $site_hash,
					// Tier signal — Pro sites get a higher daily cap on the screenshot service.
					'X-Pressgo-Tier' => $tier,
				),
			) );
			if ( is_wp_error( $response ) ) {
				return new WP_Error( 'mcp_screenshot_failed',
					"Screenshot service unreachable ({$vp}): " . $response->get_error_message() );
			}
			$status = wp_remote_retrieve_response_code( $response );
			if ( $status >= 400 ) {
				$body = wp_remote_retrieve_body( $response );
				return new WP_Error( 'mcp_screenshot_failed',
					"Screenshot service returned HTTP {$status} for {$vp}: " . substr( $body, 0, 200 ) );
			}
			$bytes = wp_remote_retrieve_body( $response );
			if ( empty( $bytes ) ) {
				return new WP_Error( 'mcp_screenshot_failed', "Empty response for {$vp}." );
			}
			$mime = wp_remote_retrieve_header( $response, 'content-type' ) ?: 'image/png';
			if ( false !== strpos( $mime, ';' ) ) {
				$mime = trim( explode( ';', $mime )[0] );
			}

			$label = $vp;
			if ( null !== $section_index ) { $label .= " section #{$section_index}"; }
			elseif ( $full_page )           { $label .= ' full-page'; }

			$content_blocks[] = array( 'type' => 'image', 'data' => base64_encode( $bytes ), 'mimeType' => $mime );
			$captured[]       = $label . ' (' . number_format( strlen( $bytes ) / 1024, 0 ) . 'KB)';
		}

		array_unshift( $content_blocks, array(
			'type' => 'text',
			'text' => $title_text . ' — captured: ' . implode( ', ', $captured ),
		) );

		return array( 'content' => $content_blocks );
	}

	/**
	 * Gate every Pro-tier tool through one helper. Returns a clear "upgrade"
	 * isError result the AI can read aloud to the user when there's no
	 * license. When licensed, dispatches to the underlying impl.
	 */
	private static function pro_dispatch( $name, $args, $user ) {
		$license = new PressGo_License();
		if ( ! $license->is_pro() ) {
			$upgrade_url = PressGo_License::upgrade_url();
			return new WP_Error( 'mcp_pro_required',
				"`{$name}` requires PressGo Plus ($12/mo). The user can upgrade at {$upgrade_url} — " .
				"once they enter their license key in PressGo > MCP Server, this tool unlocks immediately."
			);
		}
		switch ( $name ) {
			case 'set_header': return self::set_header( $args, $user );
			case 'set_footer': return self::set_footer( $args, $user );
			case 'get_header': return self::get_header( $args, $user );
			case 'get_footer': return self::get_footer( $args, $user );
		}
		return new WP_Error( 'mcp_unknown_tool', "Unknown Pro tool: {$name}" );
	}

	const HEADER_OPTION = 'pressgo_global_header';
	const FOOTER_OPTION = 'pressgo_global_footer';

	private static function set_header( $args, $user ) {
		$tpl = array(
			'logo'    => isset( $args['logo'] ) && is_array( $args['logo'] ) ? $args['logo'] : null,
			'items'   => isset( $args['items'] ) && is_array( $args['items'] ) ? $args['items'] : array(),
			'cta'     => isset( $args['cta'] ) && is_array( $args['cta'] ) ? $args['cta'] : null,
			'style'   => isset( $args['style'] ) ? sanitize_key( $args['style'] ) : 'minimal',
			'updated' => time(),
		);
		update_option( self::HEADER_OPTION, $tpl );
		// Re-apply to every PressGo-built page.
		$count = self::sync_global_section( 'header', $tpl );
		return array(
			'content' => array( array(
				'type' => 'text',
				'text' => "Header updated and applied to {$count} PressGo page(s). (Elementor pages only — pages rendering through gutenberg/divi/bricks are skipped.)",
			) ),
			'structuredContent' => array( 'header' => $tpl, 'pages_updated' => $count ),
		);
	}

	private static function set_footer( $args, $user ) {
		$tpl = array(
			'brand'     => isset( $args['brand'] ) && is_array( $args['brand'] ) ? $args['brand'] : null,
			'columns'   => isset( $args['columns'] ) && is_array( $args['columns'] ) ? $args['columns'] : array(),
			'social'    => isset( $args['social'] ) && is_array( $args['social'] ) ? $args['social'] : array(),
			'copyright' => isset( $args['copyright'] ) ? sanitize_text_field( $args['copyright'] ) : null,
			'variant'   => isset( $args['variant'] ) ? sanitize_key( $args['variant'] ) : 'default',
			'updated'   => time(),
		);
		update_option( self::FOOTER_OPTION, $tpl );
		$count = self::sync_global_section( 'footer', $tpl );
		return array(
			'content' => array( array(
				'type' => 'text',
				'text' => "Footer updated and applied to {$count} PressGo page(s). (Elementor pages only — pages rendering through gutenberg/divi/bricks are skipped.)",
			) ),
			'structuredContent' => array( 'footer' => $tpl, 'pages_updated' => $count ),
		);
	}

	private static function get_header( $args, $user ) {
		$tpl = get_option( self::HEADER_OPTION, null );
		return array(
			'content' => array( array( 'type' => 'text',
				'text' => $tpl ? wp_json_encode( $tpl, JSON_PRETTY_PRINT ) : 'No site header set yet.' ) ),
			'structuredContent' => array( 'header' => $tpl ),
		);
	}

	private static function get_footer( $args, $user ) {
		$tpl = get_option( self::FOOTER_OPTION, null );
		return array(
			'content' => array( array( 'type' => 'text',
				'text' => $tpl ? wp_json_encode( $tpl, JSON_PRETTY_PRINT ) : 'No site footer set yet.' ) ),
			'structuredContent' => array( 'footer' => $tpl ),
		);
	}

	/**
	 * Re-render every PressGo-built page with the current global header/footer
	 * injected. The first element is the header, the last is the footer.
	 *
	 * Storage convention: we tag injected sections with `_pressgo_global` in
	 * the section settings so we can find + replace them on next sync.
	 */
	private static function sync_global_section( $kind, $tpl ) {
		$pages = get_posts( array(
			'post_type'      => 'page',
			'post_status'    => array( 'draft', 'publish', 'pending', 'private' ),
			'posts_per_page' => -1,
			'meta_key'       => '_pressgo_built',
			'meta_value'     => '1',
			'fields'         => 'ids',
		) );
		$count = 0;
		foreach ( $pages as $pid ) {
			if ( self::apply_global_section( $pid, $kind, $tpl ) ) {
				$count++;
			}
		}
		return $count;
	}

	/**
	 * Inject (or replace) a global section on a single page.
	 *
	 * @return bool True if the page was modified.
	 */
	private static function apply_global_section( $post_id, $kind, $tpl ) {
		// Header/footer injection writes _elementor_data — Elementor-only.
		// Pages rendering through gutenberg/divi/bricks are skipped (their
		// chrome belongs to the theme / their own builder).
		if ( 'elementor' !== self::page_target( $post_id ) ) {
			return false;
		}
		$elements = self::read_elementor_data( $post_id );
		$globals  = self::get_page_globals( $post_id );

		$built = self::build_global_section( $kind, $tpl, $globals );
		if ( ! $built ) { return false; }
		$built['settings']['_pressgo_global'] = $kind;

		// Strip any prior global of this kind, then re-insert at the right edge.
		$elements = array_values( array_filter( $elements, function ( $el ) use ( $kind ) {
			return ! ( isset( $el['settings']['_pressgo_global'] ) && $el['settings']['_pressgo_global'] === $kind );
		} ) );
		if ( 'header' === $kind ) {
			array_unshift( $elements, $built );
		} else {
			$elements[] = $built;
		}

		self::write_elementor_data( $post_id, $elements );
		return true;
	}

	/**
	 * Build a header or footer Elementor section from the template + page globals.
	 * Reuses the existing PressGo_Generator builder for footers; the header is
	 * a custom minimal flexbox row since PressGo doesn't ship a "header" builder.
	 */
	private static function build_global_section( $kind, $tpl, $globals ) {
		if ( 'footer' === $kind ) {
			$cfg = array_merge( $globals, array(
				'sections' => array( 'footer' ),
				'footer'   => array_merge( $tpl, array( 'variant' => $tpl['variant'] ?? null ) ),
			) );
			$validated = PressGo_Config_Validator::validate( $cfg );
			if ( is_wp_error( $validated ) ) { return null; }
			$gen = new PressGo_Generator();
			$els = $gen->generate( $validated );
			return $els[0] ?? null;
		}

		// 'header' — build inline (no existing PressGo builder). Minimal
		// horizontal flexbox: logo on the left, nav + CTA on the right.
		$colors = $globals['colors'];
		$fonts  = $globals['fonts'];

		$nav_widgets = array();
		foreach ( ( $tpl['items'] ?? array() ) as $item ) {
			$nav_widgets[] = self::widget( 'button', array(
				'text' => $item['text'] ?? '',
				'link' => array( 'url' => $item['url'] ?? '#', 'is_external' => false ),
				'background_color' => 'transparent',
				'button_text_color' => $colors['text_dark'],
				'button_background_hover_color' => 'transparent',
				'hover_color' => $colors['primary'],
				'typography_typography' => 'custom',
				'typography_font_family' => $fonts['body'],
				'typography_font_weight' => '600',
				'typography_font_size' => array( 'unit' => 'px', 'size' => 14, 'sizes' => array() ),
				'border_border' => 'none',
				'text_padding' => array( 'unit' => 'px', 'top' => '8', 'right' => '12', 'bottom' => '8', 'left' => '12', 'isLinked' => false ),
			) );
		}
		if ( ! empty( $tpl['cta']['text'] ) ) {
			$nav_widgets[] = self::widget( 'button', array(
				'text' => $tpl['cta']['text'],
				'link' => array( 'url' => $tpl['cta']['url'] ?? '#', 'is_external' => false ),
				'background_color' => $colors['primary'],
				'button_text_color' => $colors['white'],
				'typography_typography' => 'custom',
				'typography_font_family' => $fonts['body'],
				'typography_font_weight' => '600',
				'border_radius' => array( 'unit' => 'px', 'top' => '8', 'right' => '8', 'bottom' => '8', 'left' => '8', 'isLinked' => true ),
				'text_padding' => array( 'unit' => 'px', 'top' => '10', 'right' => '18', 'bottom' => '10', 'left' => '18', 'isLinked' => false ),
			) );
		}

		// Prefer a real logo image — explicit template logo, else the brand
		// foundation's saved logo so an uploaded logo actually shows up in the header.
		$brand_logo = self::brand_foundation();
		$logo_img   = '';
		$logo_id    = 0;
		if ( ! empty( $tpl['logo']['image_url'] ) && is_string( $tpl['logo']['image_url'] ) ) {
			$logo_img = $tpl['logo']['image_url'];
		} elseif ( ! empty( $brand_logo['logo_url'] ) && is_string( $brand_logo['logo_url'] ) ) {
			$logo_img = $brand_logo['logo_url'];
			$logo_id  = ! empty( $brand_logo['logo_url_id'] ) ? (int) $brand_logo['logo_url_id'] : 0;
		}
		$logo_link = $tpl['logo']['url'] ?? home_url( '/' );
		if ( '' !== $logo_img ) {
			$logo_widget = self::widget( 'image', array(
				'image'        => array( 'url' => $logo_img, 'id' => $logo_id, 'alt' => ( $tpl['logo']['text'] ?? get_bloginfo( 'name' ) ) ),
				'width'        => array( 'unit' => 'px', 'size' => 150, 'sizes' => array() ),
				'link_to'      => 'custom',
				'link'         => array( 'url' => $logo_link, 'is_external' => false ),
				'align'        => 'left',
			) );
		} else {
			$logo_widget = self::widget( 'heading', array(
				'title' => $tpl['logo']['text'] ?? get_bloginfo( 'name' ),
				'header_size' => 'h3',
				'link' => array( 'url' => $logo_link, 'is_external' => false ),
				'typography_typography' => 'custom',
				'typography_font_family' => $fonts['heading'],
				'typography_font_weight' => '700',
				'typography_font_size' => array( 'unit' => 'px', 'size' => 22, 'sizes' => array() ),
				'title_color' => $colors['text_dark'],
			) );
		}

		$logo_col = self::col( array( $logo_widget ), array( 'flex_align_items' => 'center' ) );
		$nav_col  = self::col( $nav_widgets, array(
			'flex_align_items' => 'center',
			'flex_direction'   => 'row',
			'flex_wrap'        => 'wrap',
			'flex_justify_content' => 'flex-end',
			'flex_gap'         => array( 'column' => 12, 'row' => 8, 'isLinked' => false, 'unit' => 'px', 'size' => 12 ),
		) );

		// Inner flex row: logo sizes to content, nav pushes to the right via
		// space-between. Built as a raw container so row() doesn't force the two
		// columns to equal 50% widths (which is wrong for a header bar).
		$row = array(
			'id'       => PressGo_Element_Factory::eid(),
			'elType'   => 'container',
			'settings' => array(
				'container_type'       => 'flex',
				'content_width'        => 'full',
				'flex_direction'       => 'row',
				'flex_align_items'     => 'center',
				'flex_justify_content' => 'space-between',
				'flex_wrap'            => 'nowrap',
				'flex_gap'             => array( 'unit' => 'px', 'column' => '16', 'row' => '8', 'isLinked' => false, 'size' => 16 ),
			),
			'elements' => array( $logo_col, $nav_col ),
			'isInner'  => true,
		);

		$boxed = isset( $globals['layout']['boxed_width'] ) ? $globals['layout']['boxed_width'] : 1200;
		$cfg   = array( 'layout' => array( 'boxed_width' => $boxed ) );
		$bg    = ( 'minimal' === ( $tpl['style'] ?? 'minimal' ) ) ? $colors['white'] : $colors['light_bg'];

		// Use the factory's outer() with the correct ($cfg, $children, ...) signature.
		// Compact header padding + subtle shadow are applied via $extra (merged last).
		return PressGo_Element_Factory::outer(
			$cfg,
			array( $row ),
			$bg,
			null,
			14, 14,
			array(
				'padding'        => array( 'unit' => 'px', 'top' => '14', 'right' => '24', 'bottom' => '14', 'left' => '24', 'isLinked' => false ),
				'padding_tablet' => array( 'unit' => 'px', 'top' => '14', 'right' => '20', 'bottom' => '14', 'left' => '20', 'isLinked' => false ),
				'padding_mobile' => array( 'unit' => 'px', 'top' => '12', 'right' => '16', 'bottom' => '12', 'left' => '16', 'isLinked' => false ),
				'box_shadow_box_shadow_type' => 'yes',
				'box_shadow_box_shadow'      => array( 'horizontal' => 0, 'vertical' => 1, 'blur' => 6, 'spread' => 0, 'color' => 'rgba(0,0,0,0.05)' ),
			)
		);
	}

	private static function widget( $type, $settings ) {
		return PressGo_Element_Factory::widget( $type, $settings );
	}
	private static function col( $children, $extra = array() ) {
		return PressGo_Element_Factory::col( $children, $extra );
	}

	private static function clone_page( $args, $user ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		$err     = self::guard_post( $post_id, $user );
		if ( is_wp_error( $err ) ) { return $err; }
		$src = get_post( $post_id );

		$new_title = isset( $args['new_title'] ) && $args['new_title']
			? sanitize_text_field( $args['new_title'] )
			: ( $src->post_title . ' (copy)' );

		$new_id = wp_insert_post( wp_slash( array(
			'post_title'  => $new_title,
			'post_status' => 'draft',
			'post_type'   => $src->post_type,
			'post_author' => (int) $user->ID,
			'post_excerpt'=> $src->post_excerpt,
			// Non-Elementor targets render from post_content — without this a
			// gutenberg/divi/bricks clone is a blank page.
			'post_content'=> $src->post_content,
		) ), true );
		if ( is_wp_error( $new_id ) ) { return $new_id; }

		// Copy the meta keys that matter for a PressGo page on ANY render
		// target (Elementor claim-metas, the AI config, the target stamp).
		$meta_keys = array(
			'_elementor_data', '_elementor_edit_mode', '_elementor_template_type',
			'_elementor_version', '_elementor_page_settings', '_wp_page_template',
			'_pressgo_built', '_pressgo_globals', '_pressgo_sections',
			'_pressgo_ai_config', '_pressgo_target_builder',
			'_et_pb_use_builder', '_et_pb_page_layout', '_et_pb_post_hide_nav', '_et_pb_built_for_post_type',
			'_bricks_editor_mode', '_bricks_page_content_2',
		);
		foreach ( $meta_keys as $key ) {
			$val = get_post_meta( $post_id, $key, true );
			if ( '' === $val || null === $val ) { continue; }
			// get_post_meta returns unslashed; update_post_meta unslashes again —
			// wp_slash keeps JSON escapes (\/ , \uXXXX) byte-identical.
			update_post_meta( $new_id, $key, ( is_string( $val ) || is_array( $val ) ) ? wp_slash( $val ) : $val );
		}
		// Stamp pressgo built so list_pages picks it up.
		update_post_meta( $new_id, '_pressgo_built', '1' );

		// Bust caches on the clone.
		clean_post_cache( $new_id );

		return self::page_summary( $new_id, "Cloned post {$post_id} → new draft post {$new_id} (\"{$new_title}\"). Iterate freely." );
	}

	/**
	 * Upload an image into the WP media library. Accepts base64 bytes OR a
	 * remote URL to fetch from. Returns { id, url, alt, mime, width, height }.
	 *
	 * Capability gate: requires upload_files. SVG is intentionally not in the
	 * allowed MIME list (XSS surface). Max size 10MB.
	 */
	private static function upload_media( $args, $user ) {
		if ( ! user_can( $user, 'upload_files' ) ) {
			return new WP_Error( 'mcp_forbidden', 'You do not have permission to upload files on this site.' );
		}

		$data_b64 = isset( $args['data'] ) ? (string) $args['data'] : '';
		$src_url  = isset( $args['url'] )  ? (string) $args['url']  : '';
		$filename = isset( $args['filename'] ) ? sanitize_file_name( (string) $args['filename'] ) : '';
		$alt      = isset( $args['alt'] )      ? sanitize_text_field( (string) $args['alt'] )    : '';

		if ( '' === $data_b64 && '' === $src_url ) {
			return new WP_Error( 'mcp_bad_args', 'Provide either `data` (base64) or `url` (https://...).' );
		}

		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';
		require_once ABSPATH . 'wp-admin/includes/media.php';

		$tmp = '';

		if ( '' !== $src_url ) {
			$src_url = esc_url_raw( $src_url, array( 'http', 'https' ) );
			if ( ! $src_url ) {
				return new WP_Error( 'mcp_bad_args', 'URL must be http/https.' );
			}
			$tmp = download_url( $src_url, 60 );
			if ( is_wp_error( $tmp ) ) {
				return new WP_Error( 'mcp_upload_failed', 'Could not fetch URL: ' . $tmp->get_error_message() );
			}
			if ( '' === $filename ) {
				$filename = sanitize_file_name( wp_basename( wp_parse_url( $src_url, PHP_URL_PATH ) ) );
				if ( '' === $filename ) { $filename = 'pressgo-' . time() . '.png'; }
			}
		} else {
			// Strip data:image/..;base64, prefix if present.
			if ( preg_match( '#^data:image/[a-z+]+;base64,#i', $data_b64 ) ) {
				$data_b64 = substr( $data_b64, strpos( $data_b64, ',' ) + 1 );
			}
			$bytes = base64_decode( $data_b64, true );
			if ( false === $bytes ) {
				return new WP_Error( 'mcp_bad_args', '`data` is not valid base64.' );
			}
			if ( strlen( $bytes ) > 10 * 1024 * 1024 ) {
				return new WP_Error( 'mcp_too_large', 'Image too large (10MB max).' );
			}
			$tmp = wp_tempnam( $filename ?: 'pressgo-upload' );
			if ( ! $tmp ) {
				return new WP_Error( 'mcp_upload_failed', 'Could not create temp file.' );
			}
			file_put_contents( $tmp, $bytes );
			if ( '' === $filename ) { $filename = 'pressgo-upload-' . time() . '.png'; }
		}

		// Move into media library + validate MIME server-side.
		// wp_handle_sideload's first arg is by-reference (PHP 8+ strict),
		// so the file array must be assigned to a variable first.
		$file_arr = array( 'name' => $filename, 'tmp_name' => $tmp );
		$sideload = wp_handle_sideload(
			$file_arr,
			array(
				'test_form' => false,
				'mimes'     => array(
					'png'      => 'image/png',
					'jpg|jpeg' => 'image/jpeg',
					'gif'      => 'image/gif',
					'webp'     => 'image/webp',
				),
			)
		);
		if ( ! empty( $sideload['error'] ) ) {
			@unlink( $tmp );
			return new WP_Error( 'mcp_upload_failed', $sideload['error'] );
		}

		$attach_id = wp_insert_attachment( array(
			'post_mime_type' => $sideload['type'],
			'post_title'     => $alt ?: pathinfo( $filename, PATHINFO_FILENAME ),
			'post_content'   => '',
			'post_status'    => 'inherit',
			'post_author'    => (int) $user->ID,
		), $sideload['file'] );
		if ( is_wp_error( $attach_id ) ) {
			return $attach_id;
		}

		$metadata = wp_generate_attachment_metadata( $attach_id, $sideload['file'] );
		wp_update_attachment_metadata( $attach_id, $metadata );

		if ( '' !== $alt ) {
			update_post_meta( $attach_id, '_wp_attachment_image_alt', $alt );
		}

		$w = $metadata['width']  ?? null;
		$h = $metadata['height'] ?? null;

		return array(
			'content' => array( array(
				'type' => 'text',
				'text' => sprintf(
					"Uploaded media #%d (%s%s). URL: %s\n\nUse this URL in any image field: hero.image.url, features.items[].image.url, footer.brand.logo.url, etc.",
					$attach_id,
					$sideload['type'],
					( $w && $h ) ? ", {$w}×{$h}" : '',
					$sideload['url']
				),
			) ),
			'structuredContent' => array(
				'id'     => $attach_id,
				'url'    => $sideload['url'],
				'mime'   => $sideload['type'],
				'alt'    => $alt,
				'width'  => $w,
				'height' => $h,
			),
		);
	}

	/**
	 * Chunked variant of upload_media. Avoids the per-tool-call token
	 * budget that single-shot base64 upload hits on images larger than
	 * ~70KB (ie. anything more than a thumbnail).
	 *
	 * Caller streams chunks until the last one (index == total-1)
	 * triggers finalization. State lives in a temp file inside
	 * uploads/.pressgo-uploads/{upload_id}.bin until the final chunk
	 * arrives, then sideloads + cleans up.
	 */
	private static function upload_media_chunked( $args, $user ) {
		if ( ! user_can( $user, 'upload_files' ) ) {
			return new WP_Error( 'mcp_forbidden', 'You do not have permission to upload files on this site.' );
		}

		$chunk_b64 = isset( $args['chunk'] ) ? (string) $args['chunk'] : '';
		$index     = isset( $args['index'] ) ? (int) $args['index']   : 0;
		$total     = isset( $args['total'] ) ? max( 1, (int) $args['total'] ) : 1;
		$upload_id = isset( $args['upload_id'] ) ? sanitize_key( (string) $args['upload_id'] ) : '';
		$filename  = isset( $args['filename'] )  ? sanitize_file_name( (string) $args['filename'] ) : '';
		$alt       = isset( $args['alt'] )       ? sanitize_text_field( (string) $args['alt'] )    : '';

		if ( '' === $chunk_b64 ) {
			return new WP_Error( 'mcp_bad_args', '`chunk` is required.' );
		}
		if ( $index < 0 || $index >= $total ) {
			return new WP_Error( 'mcp_bad_args', '`index` must satisfy 0 <= index < total.' );
		}
		// First chunk: server allocates the upload_id. Use lowercase-hex so
		// sanitize_key() round-trips cleanly (it lowercases its input, which
		// would otherwise mismatch a mixed-case wp_generate_password output).
		if ( '' === $upload_id ) {
			if ( 0 !== $index ) {
				return new WP_Error( 'mcp_bad_args', 'No upload_id provided but index != 0. Pass the upload_id from chunk 0 on every subsequent chunk.' );
			}
			$upload_id = 'pgupload_' . bin2hex( random_bytes( 8 ) );
		}

		// Strip data URI prefix if present (only on first chunk in practice).
		if ( preg_match( '#^data:image/[a-z+]+;base64,#i', $chunk_b64 ) ) {
			$chunk_b64 = substr( $chunk_b64, strpos( $chunk_b64, ',' ) + 1 );
		}
		// DON'T decode individual chunks — base64 strings split at arbitrary
		// boundaries aren't individually valid (each chunk must be a multiple
		// of 4 chars, which we can't guarantee for client-supplied splits).
		// Append the base64 TEXT and decode the whole thing on finalize.

		$dirs    = wp_upload_dir();
		$tmp_dir = trailingslashit( $dirs['basedir'] ) . '.pressgo-uploads';
		if ( ! file_exists( $tmp_dir ) ) {
			wp_mkdir_p( $tmp_dir );
			@file_put_contents( $tmp_dir . '/.htaccess', "Deny from all\n" );
			@file_put_contents( $tmp_dir . '/index.html', '' );
		}
		$tmp_file = $tmp_dir . '/' . $upload_id . '.b64';

		clearstatcache( true, $tmp_file );
		$existing_b64 = file_exists( $tmp_file ) ? filesize( $tmp_file ) : 0;
		// Approximate raw bytes from base64 length: 3/4 ratio. 10MB raw cap.
		if ( ( $existing_b64 + strlen( $chunk_b64 ) ) * 3 / 4 > 10 * 1024 * 1024 ) {
			@unlink( $tmp_file );
			return new WP_Error( 'mcp_too_large', 'Upload exceeded 10MB total. Aborted.' );
		}
		file_put_contents( $tmp_file, $chunk_b64, FILE_APPEND );
		clearstatcache( true, $tmp_file );
		$received_b64_len = filesize( $tmp_file );
		$approx_raw      = (int) ( $received_b64_len * 3 / 4 );

		// Not the last chunk — return progress + the upload_id.
		if ( $index < $total - 1 ) {
			return array(
				'content' => array( array( 'type' => 'text', 'text' =>
					sprintf( "Chunk %d/%d received (~%d raw bytes so far). Continue with the next chunk using upload_id=%s.",
						$index + 1, $total, $approx_raw, $upload_id )
				) ),
				'structuredContent' => array(
					'upload_id'      => $upload_id,
					'received_bytes' => $approx_raw,
					'complete'       => false,
				),
			);
		}

		// Final chunk — decode the entire concatenated base64 in one pass.
		$bytes = base64_decode( file_get_contents( $tmp_file ), true );
		if ( false === $bytes ) {
			@unlink( $tmp_file );
			return new WP_Error( 'mcp_bad_args', 'Concatenated base64 was invalid (was a chunk corrupted in transit?).' );
		}
		// Replace the .b64 file with the decoded bytes for sideload.
		@unlink( $tmp_file );
		$tmp_file = $tmp_dir . '/' . $upload_id . '.bin';
		file_put_contents( $tmp_file, $bytes );

		// Final chunk — sideload into media library.
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';
		require_once ABSPATH . 'wp-admin/includes/media.php';

		if ( '' === $filename ) { $filename = 'pressgo-upload-' . time() . '.png'; }

		$file_arr = array( 'name' => $filename, 'tmp_name' => $tmp_file );
		$sideload = wp_handle_sideload(
			$file_arr,
			array(
				'test_form' => false,
				'mimes'     => array(
					'png'      => 'image/png',
					'jpg|jpeg' => 'image/jpeg',
					'gif'      => 'image/gif',
					'webp'     => 'image/webp',
				),
			)
		);
		if ( ! empty( $sideload['error'] ) ) {
			@unlink( $tmp_file );
			return new WP_Error( 'mcp_upload_failed', $sideload['error'] );
		}

		$attach_id = wp_insert_attachment( array(
			'post_mime_type' => $sideload['type'],
			'post_title'     => $alt ?: pathinfo( $filename, PATHINFO_FILENAME ),
			'post_content'   => '',
			'post_status'    => 'inherit',
			'post_author'    => (int) $user->ID,
		), $sideload['file'] );
		if ( is_wp_error( $attach_id ) ) {
			return $attach_id;
		}

		$metadata = wp_generate_attachment_metadata( $attach_id, $sideload['file'] );
		wp_update_attachment_metadata( $attach_id, $metadata );
		if ( '' !== $alt ) {
			update_post_meta( $attach_id, '_wp_attachment_image_alt', $alt );
		}

		$w = $metadata['width']  ?? null;
		$h = $metadata['height'] ?? null;

		return array(
			'content' => array( array(
				'type' => 'text',
				'text' => sprintf(
					"Upload complete (chunked, %d/%d). Media #%d (%s%s). URL: %s",
					$index + 1, $total, $attach_id, $sideload['type'],
					( $w && $h ) ? ", {$w}×{$h}" : '', $sideload['url']
				),
			) ),
			'structuredContent' => array(
				'id'        => $attach_id,
				'url'       => $sideload['url'],
				'mime'      => $sideload['type'],
				'alt'       => $alt,
				'width'     => $w,
				'height'    => $h,
				'upload_id' => $upload_id,
				'complete'  => true,
			),
		);
	}

	/**
	 * List the most recent attachments in the WP media library. Used by AI
	 * clients to find an image the user just dropped via wp-admin → media
	 * library — way smoother than chunked base64 for non-trivial photos.
	 */
	private static function list_recent_media( $args, $user ) {
		$limit         = max( 1, min( 50, isset( $args['limit'] ) ? (int) $args['limit'] : 10 ) );
		$since_minutes = max( 1, min( 1440, isset( $args['since_minutes'] ) ? (int) $args['since_minutes'] : 60 ) );
		$mime_prefix   = isset( $args['mime_prefix'] ) ? (string) $args['mime_prefix'] : 'image/';
		// Default to including thumbnails when there's likely to be ambiguity
		// (more than 1 result OR auto-generated filename detected). This
		// removes the "Claude is blind, has to ask the user" failure mode.
		// Caller can explicitly set false to opt out.
		$include_thumbs = isset( $args['include_thumbnails'] )
			? (bool) $args['include_thumbnails']
			: null; // sentinel: decide after we have results

		$after = gmdate( 'Y-m-d H:i:s', time() - ( $since_minutes * 60 ) );

		$attachments = get_posts( array(
			'post_type'      => 'attachment',
			'post_status'    => 'inherit',
			'posts_per_page' => $limit,
			'orderby'        => 'date',
			'order'          => 'DESC',
			'date_query'     => array( array( 'column' => 'post_date_gmt', 'after' => $after ) ),
		) );

		$out = array();
		foreach ( $attachments as $att ) {
			$mime = (string) $att->post_mime_type;
			if ( $mime_prefix && 0 !== strpos( $mime, $mime_prefix ) ) { continue; }
			$url   = wp_get_attachment_url( $att->ID );
			$meta  = wp_get_attachment_metadata( $att->ID );
			$alt   = (string) get_post_meta( $att->ID, '_wp_attachment_image_alt', true );
			$thumb = wp_get_attachment_image_src( $att->ID, 'thumbnail' );
			$out[] = array(
				'id'            => (int) $att->ID,
				'url'           => $url ?: '',
				'thumbnail_url' => $thumb ? $thumb[0] : '',
				'filename'      => basename( get_attached_file( $att->ID ) ?: '' ),
				'mime'          => $mime,
				'alt'           => $alt,
				'width'         => isset( $meta['width'] )  ? (int) $meta['width']  : null,
				'height'        => isset( $meta['height'] ) ? (int) $meta['height'] : null,
				'uploaded_at'   => $att->post_date_gmt,
				'uploaded_by'   => (int) $att->post_author,
			);
		}

		if ( empty( $out ) ) {
			return array(
				'content' => array( array( 'type' => 'text', 'text' =>
					"No images uploaded in the last {$since_minutes} minutes. Did the user finish the upload? " .
					"Ask them to confirm — they should see the image as a tile in the media library at " .
					site_url( '/wp-admin/upload.php' ) . " after dropping. If they did, try increasing " .
					"`since_minutes` to widen the window."
				) ),
				'structuredContent' => array( 'count' => 0, 'media' => array() ),
			);
		}

		$summary = array();
		foreach ( $out as $m ) {
			$summary[] = sprintf( "  • %s (%s%s, %s) → %s",
				$m['filename'],
				$m['mime'],
				( $m['width'] && $m['height'] ) ? ", {$m['width']}×{$m['height']}" : '',
				human_time_diff( strtotime( $m['uploaded_at'] ), time() ) . ' ago',
				$m['url']
			);
		}
		// Auto-include thumbnails when caller didn't specify and we have
		// either >1 result OR an auto-generated filename (UUID-style,
		// IMG_*, pressgo-upload-*, etc.) — Claude can't reason about
		// filenames it can't interpret.
		if ( null === $include_thumbs ) {
			$has_auto_name = false;
			foreach ( $out as $m ) {
				if ( preg_match( '/^([A-F0-9-]{30,}|IMG_\d+|pressgo-upload-\d+|DSC\d+|Screenshot)/i', $m['filename'] ) ) {
					$has_auto_name = true; break;
				}
			}
			$include_thumbs = ( count( $out ) > 1 ) || $has_auto_name;
		}

		$text = sprintf(
			"Found %d recent image%s in the media library (last %d minutes):\n%s\n\n" .
			"%s",
			count( $out ),
			count( $out ) === 1 ? '' : 's',
			$since_minutes,
			implode( "\n", $summary ),
			$include_thumbs
				? ( count( $out ) === 1
					? "Thumbnail is embedded below — confirm it's what the user wants before using."
					: "Thumbnails are embedded below — look at each one to decide which goes where (hero / team / logo / etc.). When still uncertain after seeing them, ask the user." )
				: "Use this URL in image fields. Confirm with the user it's the right one."
		);

		$content = array( array( 'type' => 'text', 'text' => $text ) );

		// Embed inline thumbnails as MCP image content blocks if requested.
		// Uses WP's auto-generated 'thumbnail' size (typically 150x150) so
		// each is small (~5-15KB base64) — cheap enough to include even for
		// 10 results.
		if ( $include_thumbs ) {
			foreach ( $out as $m ) {
				$tid     = $m['id'];
				$file    = get_attached_file( $tid );
				$th_meta = wp_get_attachment_image_src( $tid, 'thumbnail' );
				if ( ! $th_meta ) { continue; }
				// thumbnail src: [url, width, height, is_intermediate]
				// Need the actual file path for the thumbnail size.
				$meta = wp_get_attachment_metadata( $tid );
				if ( ! empty( $meta['sizes']['thumbnail']['file'] ) ) {
					$th_path = dirname( $file ) . '/' . $meta['sizes']['thumbnail']['file'];
				} else {
					$th_path = $file; // fallback to original
				}
				if ( ! is_readable( $th_path ) ) { continue; }
				$bytes = @file_get_contents( $th_path );
				if ( false === $bytes || strlen( $bytes ) > 200 * 1024 ) { continue; } // 200KB ceiling per thumbnail
				$content[] = array(
					'type' => 'text',
					'text' => sprintf( '↓ Thumbnail of %s (id=%d)', $m['filename'], $tid ),
				);
				$content[] = array(
					'type'     => 'image',
					'data'     => base64_encode( $bytes ),
					'mimeType' => $m['mime'],
				);
			}
		}

		return array(
			'content'           => $content,
			'structuredContent' => array(
				'count'         => count( $out ),
				'since_minutes' => $since_minutes,
				'media'         => $out,
			),
		);
	}

	const PROFILE_META_KEY = 'pressgo_user_profile';

	/**
	 * Read the saved user profile. Public so the MCP server can inject it
	 * into the initialize response (so returning users skip the wizard
	 * automatically without Claude having to call get_user_profile).
	 */
	public static function read_user_profile( $user_id ) {
		$profile = get_user_meta( (int) $user_id, self::PROFILE_META_KEY, true );
		return is_array( $profile ) && ! empty( $profile ) ? $profile : null;
	}

	private static function get_user_profile( $args, $user ) {
		$profile = self::read_user_profile( $user->ID );
		if ( ! $profile ) {
			return array(
				'content' => array( array( 'type' => 'text', 'text' =>
					"No profile saved for this user yet. This is a first-time interaction. " .
					"Run the welcome wizard (3-4 questions max) before building anything, " .
					"then call set_user_profile to remember the answers."
				) ),
				'structuredContent' => array( 'known' => false ),
			);
		}
		return array(
			'content' => array( array( 'type' => 'text', 'text' =>
				"Returning user profile loaded:\n" . wp_json_encode( $profile, JSON_PRETTY_PRINT ) .
				"\n\nUse this to calibrate (technical depth, language style, default site framing). Skip the welcome wizard."
			) ),
			'structuredContent' => array( 'known' => true, 'profile' => $profile ),
		);
	}

	private static function set_user_profile( $args, $user ) {
		$existing = self::read_user_profile( $user->ID );
		$profile  = is_array( $existing ) ? $existing : array();

		$enums = array(
			'user_type'       => array( 'designer', 'developer', 'marketer', 'business_owner', 'agency', 'exploring' ),
			'site_type'       => array( 'small_business', 'personal_blog', 'portfolio', 'ecommerce', 'saas', 'nonprofit', 'agency_client', 'other' ),
			'expertise_level' => array( 'beginner', 'intermediate', 'advanced' ),
		);
		foreach ( $enums as $key => $valid ) {
			if ( isset( $args[ $key ] ) ) {
				$val = sanitize_key( (string) $args[ $key ] );
				if ( in_array( $val, $valid, true ) ) {
					$profile[ $key ] = $val;
				}
			}
		}
		foreach ( array( 'voice_preference', 'notes' ) as $free ) {
			if ( isset( $args[ $free ] ) ) {
				$profile[ $free ] = sanitize_textarea_field( (string) $args[ $free ] );
			}
		}
		$profile['updated_at'] = time();
		update_user_meta( $user->ID, self::PROFILE_META_KEY, $profile );

		return array(
			'content' => array( array( 'type' => 'text', 'text' =>
				"Saved user profile. Future chats will skip the welcome wizard for this user.\n" .
				wp_json_encode( $profile, JSON_PRETTY_PRINT )
			) ),
			'structuredContent' => array( 'profile' => $profile ),
		);
	}

	private static function inspect_page( $args, $user ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		$err     = self::guard_post( $post_id, $user );
		if ( is_wp_error( $err ) ) { return $err; }

		// Non-Elementor targets keep their design in the AI config, not
		// _elementor_data (empty there) — summarize the config instead.
		$target = self::page_target( $post_id );
		if ( 'elementor' !== $target ) {
			return self::inspect_config_page( $post_id, $target );
		}

		$elements = self::read_elementor_data( $post_id );
		$records  = get_post_meta( $post_id, '_pressgo_sections', true );
		$globals  = self::get_page_globals( $post_id );

		// Walk each section to extract the most-meaningful label. Prefer h1
		// (the actual headline), then h2, then any heading. Skips eyebrow
		// h6 unless it's all we've got.
		$find_label = function ( $el ) {
			$by_size = array();
			$walk = function ( $a ) use ( &$walk, &$by_size ) {
				if ( ! is_array( $a ) ) { return; }
				if ( isset( $a['widgetType'] ) && 'heading' === $a['widgetType'] && ! empty( $a['settings']['title'] ) ) {
					$size = $a['settings']['header_size'] ?? 'h2';
					$by_size[ $size ][] = $a['settings']['title'];
				}
				foreach ( $a as $v ) { if ( is_array( $v ) ) { $walk( $v ); } }
			};
			$walk( $el );
			foreach ( array( 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ) as $s ) {
				if ( ! empty( $by_size[ $s ] ) ) {
					return $by_size[ $s ][0];
				}
			}
			return null;
		};

		$sections = array();
		foreach ( $elements as $i => $el ) {
			$rec = is_array( $records ) && isset( $records[ $i ] ) ? $records[ $i ] : array();
			$sections[] = array(
				'index'   => $i,
				'type'    => $rec['type'] ?? null,
				'variant' => $rec['variant'] ?? null,
				'label'   => $find_label( $el ),
			);
		}

		$snapshot = array(
			'post_id'       => $post_id,
			'title'         => get_the_title( $post_id ),
			'status'        => get_post_status( $post_id ),
			'edit_url'      => admin_url( "post.php?post={$post_id}&action=elementor" ),
			'watch_url'     => class_exists( 'PressGo_MCP_Admin' ) ? PressGo_MCP_Admin::watch_url( $post_id ) : '',
			'globals'       => $globals,
			'section_count' => count( $sections ),
			'sections'      => $sections,
		);
		return array(
			'content' => array( array(
				'type' => 'text',
				'text' => wp_json_encode( $snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ),
			) ),
			'structuredContent' => $snapshot,
		);
	}

	/**
	 * inspect_page for config-driven (non-Elementor) targets: summarize the
	 * stored AI config — section order with types/variants + labels, brand
	 * basics, and the render target. Same output shape as the Elementor path.
	 */
	private static function inspect_config_page( $post_id, $target ) {
		$config = self::read_ai_config( $post_id );
		$order  = ( is_array( $config ) && isset( $config['sections'] ) && is_array( $config['sections'] ) )
			? array_values( $config['sections'] )
			: array();

		$sections = array();
		foreach ( $order as $i => $key ) {
			$entry = ( is_array( $config ) && isset( $config[ $key ] ) && is_array( $config[ $key ] ) ) ? $config[ $key ] : array();
			$label = null;
			foreach ( array( 'headline', 'title', 'heading' ) as $lk ) {
				if ( ! empty( $entry[ $lk ] ) && is_string( $entry[ $lk ] ) ) {
					$label = $entry[ $lk ];
					break;
				}
			}
			$sections[] = array(
				'index'   => $i,
				'type'    => self::base_section_type( $key ),
				'variant' => isset( $entry['variant'] ) ? $entry['variant'] : null,
				'label'   => $label,
			);
		}

		$snapshot = array(
			'post_id'       => $post_id,
			'title'         => get_the_title( $post_id ),
			'status'        => get_post_status( $post_id ),
			'target'        => $target,
			'edit_url'      => admin_url( "post.php?post={$post_id}&action=edit" ),
			'watch_url'     => class_exists( 'PressGo_MCP_Admin' ) ? PressGo_MCP_Admin::watch_url( $post_id ) : '',
			'business_name' => is_array( $config ) && isset( $config['business_name'] ) ? $config['business_name'] : null,
			'colors'        => is_array( $config ) && isset( $config['colors'] ) ? $config['colors'] : null,
			'fonts'         => is_array( $config ) && isset( $config['fonts'] ) ? $config['fonts'] : null,
			'globals'       => self::get_page_globals( $post_id ),
			'section_count' => count( $sections ),
			'sections'      => $sections,
		);
		if ( null === $config ) {
			$snapshot['note'] = "This page renders through {$target} but has no stored AI config yet — " .
				'build it via the AI Builder chat before editing sections.';
		}
		return array(
			'content' => array( array(
				'type' => 'text',
				'text' => wp_json_encode( $snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ),
			) ),
			'structuredContent' => $snapshot,
		);
	}

	/**
	 * Build a preview URL the screenshot service can fetch even for drafts.
	 *
	 * For drafts we attach a short-lived HMAC token (see verify_preview_token()
	 * in PressGo_MCP_Server-side hooks) that the plugin recognises in
	 * template_redirect and uses to authorise the request.
	 */
	private static function auth_preview_url( $post_id ) {
		$post = get_post( $post_id );
		if ( ! $post ) { return ''; }

		// Published pages: use the public permalink directly.
		if ( 'publish' === $post->post_status ) {
			return get_permalink( $post_id );
		}

		// Drafts: signed-token preview URL handled by PressGo_MCP_Server.
		$exp     = time() + 600;
		$token   = self::sign_preview_token( $post_id, $exp );
		$is_page = ( 'page' === $post->post_type );
		$args    = array(
			'pgmcp_preview' => $token,
			'pgmcp_exp'     => $exp,
		);
		if ( $is_page ) {
			$args['page_id'] = $post_id;
		} else {
			$args['p'] = $post_id;
			$args['post_type'] = $post->post_type;
		}
		return add_query_arg( $args, home_url( '/' ) );
	}

	public static function sign_preview_token( $post_id, $exp ) {
		$payload = (int) $post_id . '|' . (int) $exp;
		return hash_hmac( 'sha256', $payload, wp_salt( 'auth' ) );
	}

	public static function verify_preview_token( $post_id, $exp, $token ) {
		if ( ! $post_id || ! $exp || ! $token ) { return false; }
		if ( (int) $exp < time() ) { return false; }
		$expected = self::sign_preview_token( $post_id, $exp );
		return hash_equals( $expected, (string) $token );
	}

	private static function get_brain( $args, $user ) {
		$brain_path = PRESSGO_PLUGIN_DIR . 'brain.json';
		if ( ! is_readable( $brain_path ) ) {
			return new WP_Error( 'mcp_not_found', 'brain.json not bundled with this plugin install.' );
		}
		$brain = file_get_contents( $brain_path );

		// Schema lives at the plugin root or includes/prompts/ depending on install vintage.
		$schema = '';
		foreach ( array( PRESSGO_PLUGIN_DIR . 'config-schema.json', PRESSGO_PLUGIN_DIR . 'includes/prompts/config-schema.json' ) as $p ) {
			if ( is_readable( $p ) ) { $schema = file_get_contents( $p ); break; }
		}

		// Schema is the source of truth for per-field requirements; brain holds
		// design intent + variant guidance. Bundling both means Claude doesn't
		// have to make a second resources/read call to discover field names.
		$bundled = "# brain.json (design patterns + variant guidance)\n\n" . $brain;
		if ( $schema ) {
			$bundled .= "\n\n# config-schema.json (authoritative per-field schema — use these field names verbatim)\n\n" . $schema;
		}

		return array(
			'content' => array(
				array( 'type' => 'text', 'text' => $bundled ),
			),
		);
	}

	/* ─── Helpers ───────────────────────────────────────────────────── */

	public static function guard_post( $post_id, $user ) {
		if ( ! $post_id ) {
			return new WP_Error( 'mcp_bad_args', 'post_id is required.' );
		}
		$post = get_post( $post_id );
		if ( ! $post ) {
			return new WP_Error( 'mcp_not_found', "Post {$post_id} does not exist." );
		}
		if ( ! user_can( $user, 'edit_post', $post_id ) ) {
			return new WP_Error( 'mcp_forbidden', "You don't have permission to edit post {$post_id}." );
		}
		return true;
	}

	/**
	 * The builder a page renders through. Per-post meta `_pressgo_target_builder`
	 * is stamped on every apply; empty (legacy pages) means Elementor.
	 *
	 * Section tools branch on this: 'elementor' pages keep the original
	 * _elementor_data read/write path, every other target mutates the stored
	 * AI config and re-applies through PressGo_AI_Builder::apply_config_to_post.
	 */
	public static function page_target( $post_id ) {
		$target = (string) get_post_meta( $post_id, '_pressgo_target_builder', true );
		if ( '' === $target || 'elementor' === $target ) {
			return 'elementor';
		}
		return $target;
	}

	/**
	 * Decode the page's stored AI config (`_pressgo_ai_config`). Same decode
	 * contract as PressGo_AI_Builder::decode_meta_json: get_post_meta returns
	 * UNSLASHED data, so decode raw first; fall back to an unslashed parse only
	 * for legacy double-slashed rows. Returns array or null.
	 */
	public static function read_ai_config( $post_id ) {
		$raw = get_post_meta( $post_id, '_pressgo_ai_config', true );
		if ( ! is_string( $raw ) || '' === $raw ) {
			return null;
		}
		$decoded = json_decode( $raw, true );
		if ( null === $decoded ) {
			$decoded = json_decode( wp_unslash( $raw ), true );
		}
		return is_array( $decoded ) ? $decoded : null;
	}

	/** Helpful error when a config-path tool finds no stored config to edit. */
	private static function no_config_error( $post_id, $target ) {
		return new WP_Error( 'mcp_no_config',
			"Page {$post_id} renders through {$target} and section tools edit its stored AI config — " .
			"but this page has no AI config; build it via the AI Builder chat first (PressGo > AI pages), " .
			"then these tools can edit it."
		);
	}

	/** "gallery#2" -> "gallery". Mirrors PressGo_AI_Builder::base_section_type. */
	private static function base_section_type( $key ) {
		if ( is_string( $key ) && preg_match( '/^([a-z_]+)#[2-9][0-9]*$/', $key, $m ) ) {
			return $m[1];
		}
		return $key;
	}

	/**
	 * Next free instance key for $type in a config: bare name if unused,
	 * otherwise "type#2", "type#3"… — the same numbering scheme
	 * canonicalize_section_order uses, so suffixed keys round-trip cleanly.
	 */
	private static function config_instance_key( $config, $type ) {
		$order = ( isset( $config['sections'] ) && is_array( $config['sections'] ) ) ? $config['sections'] : array();
		if ( ! in_array( $type, $order, true ) && ! isset( $config[ $type ] ) ) {
			return $type;
		}
		$n = 2;
		while ( in_array( $type . '#' . $n, $order, true ) || isset( $config[ $type . '#' . $n ] ) ) {
			$n++;
		}
		return $type . '#' . $n;
	}

	/**
	 * Apply a mutated config through the FULL pipeline (normalize > validate >
	 * snapshot > render dispatch > cache purge). Returns the apply result array
	 * on success, WP_Error on failure.
	 */
	private static function apply_config( $post_id, $config ) {
		if ( ! class_exists( 'PressGo_AI_Builder' ) ) {
			return new WP_Error( 'mcp_apply_failed', 'AI Builder is not loaded on this install.' );
		}
		$res = ( new PressGo_AI_Builder() )->apply_config_to_post( $post_id, $config );
		if ( empty( $res['ok'] ) ) {
			$why = isset( $res['error'] ) ? $res['error'] : 'unknown error';
			return new WP_Error( 'mcp_apply_failed', "Config apply failed: {$why}" );
		}
		return $res;
	}

	/**
	 * Non-Elementor add_section / add_sections: append section entries to the
	 * stored config and re-apply through the full pipeline. $sections is a list
	 * of already-sanitized { type, variant, data } triples.
	 *
	 * Returns array( summary, failed[] ) shaped like the Elementor batch path,
	 * or WP_Error when nothing could be added / the apply failed.
	 */
	private static function config_add_sections( $post_id, $sections, $target ) {
		$config = self::read_ai_config( $post_id );
		if ( null === $config ) {
			return self::no_config_error( $post_id, $target );
		}
		if ( ! isset( $config['sections'] ) || ! is_array( $config['sections'] ) ) {
			$config['sections'] = array();
		}

		$built  = array();
		$failed = array();
		foreach ( $sections as $i => $sec ) {
			$type    = isset( $sec['type'] ) ? sanitize_key( $sec['type'] ) : '';
			$variant = isset( $sec['variant'] ) ? sanitize_key( $sec['variant'] ) : '';
			$data    = ( isset( $sec['data'] ) && is_array( $sec['data'] ) ) ? $sec['data'] : null;
			if ( ! in_array( $type, self::VALID_SECTION_TYPES, true ) ) {
				$failed[] = array( 'index' => $i, 'reason' => "Unknown section type: {$type}" );
				continue;
			}
			if ( ! $data ) {
				$failed[] = array( 'index' => $i, 'reason' => 'Missing or invalid data' );
				continue;
			}
			$entry = $data;
			if ( $variant ) {
				$entry['variant'] = $variant;
			}
			// Repeat types get suffixed instance keys ("gallery#2") — the same
			// numbering canonicalize_section_order produces, so the suffixed key
			// goes straight into sections[] and binds to its data key.
			$key                  = self::config_instance_key( $config, $type );
			$config[ $key ]       = $entry;
			$config['sections'][] = $key;
			$built[]              = $key . ( $variant ? "/{$variant}" : '' );
		}

		if ( ! $built ) {
			$reasons = implode( '; ', array_map( function ( $f ) { return "[{$f['index']}] {$f['reason']}"; }, $failed ) );
			return new WP_Error( 'mcp_bad_args', 'No valid sections to add. ' . $reasons );
		}

		// Snapshot prior state, then run the full apply pipeline. If the apply
		// fails the page was not modified — discard the snapshot we just pushed.
		self::push_undo( $post_id, 'add_sections (' . count( $built ) . ') [' . $target . ']' );
		$apply = self::apply_config( $post_id, $config );
		if ( is_wp_error( $apply ) ) {
			self::discard_last_undo( $post_id );
			return $apply;
		}

		$note = 'Built ' . count( $built ) . ' section(s) via the ' . $target . ' renderer: ' . implode( ', ', $built );
		if ( $failed ) {
			$note .= ' — ' . count( $failed ) . ' failed: ' . implode( '; ', array_map(
				function ( $f ) { return "[{$f['index']}] {$f['reason']}"; }, $failed
			) );
		}
		$summary = self::page_summary( $post_id, $note );
		if ( $failed ) {
			$summary['structuredContent']['failed'] = $failed;
		}
		return $summary;
	}

	/**
	 * Non-Elementor update_section: section_index maps to the config's
	 * sections[] order. Provided data keys merge over the existing entry
	 * (keep the rest); a passed variant replaces the stored one; a different
	 * type replaces the instance wholesale. Re-applies the full pipeline.
	 */
	private static function config_update_section( $post_id, $index, $type, $variant_arg, $variant_passed, $data, $target ) {
		$config = self::read_ai_config( $post_id );
		if ( null === $config ) {
			return self::no_config_error( $post_id, $target );
		}
		$order = ( isset( $config['sections'] ) && is_array( $config['sections'] ) ) ? array_values( $config['sections'] ) : array();
		if ( $index < 0 || $index >= count( $order ) ) {
			return new WP_Error( 'mcp_bad_args', "section_index {$index} is out of range (page has " . count( $order ) . ' sections).' );
		}

		$key      = $order[ $index ];
		$cur_base = self::base_section_type( $key );
		$entry    = ( isset( $config[ $key ] ) && is_array( $config[ $key ] ) ) ? $config[ $key ] : array();

		if ( $type === $cur_base ) {
			// Same type: merge provided keys over the stored entry.
			$entry = array_merge( $entry, $data );
		} else {
			// Type change: replace the instance at this position wholesale.
			unset( $config[ $key ] );
			$new_key = self::config_instance_key( $config, $type );
			$order[ $index ] = $new_key;
			$key   = $new_key;
			$entry = $data;
		}
		if ( $variant_passed && $variant_arg ) {
			$entry['variant'] = $variant_arg;
		}
		$config[ $key ]     = $entry;
		$config['sections'] = $order;

		self::push_undo( $post_id, "update_section {$index} → {$type}" . ( $variant_passed && $variant_arg ? "/{$variant_arg}" : '' ) . " [{$target}]" );
		$apply = self::apply_config( $post_id, $config );
		if ( is_wp_error( $apply ) ) {
			self::discard_last_undo( $post_id );
			return $apply;
		}

		$vlabel = '';
		if ( $variant_passed && $variant_arg ) {
			$vlabel = "/{$variant_arg}";
		} elseif ( ! empty( $entry['variant'] ) ) {
			$vlabel = '/' . $entry['variant'];
		}
		return self::page_summary( $post_id, "Updated section {$index} ({$key}{$vlabel}) via the {$target} renderer." );
	}

	/**
	 * Pause-on-edit: if the user has Elementor open on this page, return an
	 * mcp_paused WP_Error so the calling tool aborts cleanly.
	 *
	 * Why this exists: when the user opens the Elementor editor, they take
	 * over the page. If Claude keeps writing through MCP at the same time,
	 * their drag-and-drop edits get clobbered the next time the editor
	 * polls _elementor_data. WP already maintains _edit_lock for exactly
	 * this — we just check it.
	 *
	 * Bypass: pass `force: true` in args to override (e.g. user explicitly
	 * said "go ahead even though I'm in the editor"). Read-only tools
	 * (inspect_page, list_pages, screenshot_page, get_brain) should NOT call
	 * this — they need to keep working so Claude can re-orient at resume.
	 */
	public static function check_pause( $post_id, $args = array() ) {
		if ( ! empty( $args['force'] ) ) {
			return null;
		}
		if ( ! function_exists( 'wp_check_post_lock' ) ) {
			require_once ABSPATH . 'wp-admin/includes/post.php';
		}
		$locker = wp_check_post_lock( $post_id );
		if ( ! $locker ) {
			return null;
		}
		$user_obj = get_userdata( $locker );
		$name     = $user_obj ? $user_obj->display_name : "user #{$locker}";
		$msg = sprintf(
			'Page %d is open in the Elementor editor by %s right now — pausing to avoid clobbering their edits. ' .
			'Tell the user: "I see you\'re editing the page. I\'ve paused — let me know when you want me to continue." ' .
			'When they say to continue (or after ~3 minutes of inactivity in the editor), call inspect_page first ' .
			'to see what they changed, then proceed. If they explicitly say "ignore the lock and keep going", ' .
			'retry the same call with force=true.',
			$post_id, $name
		);
		return new WP_Error( 'mcp_paused', $msg, array(
			'status'         => 423, // Locked
			'locker_user_id' => $locker,
			'expires_in_sec' => apply_filters( 'wp_check_post_lock_window', 150 ),
		) );
	}

	public static function read_elementor_data( $post_id ) {
		$raw = get_post_meta( $post_id, '_elementor_data', true );
		if ( empty( $raw ) ) {
			return array();
		}
		// Match Elementor core's own pattern: decode the raw value directly.
		// Calling wp_unslash here breaks JSON whenever the payload contains
		// escaped slashes (e.g. inline SVGs, "\/" in URLs) — wp_unslash strips
		// the backslashes and leaves syntactically broken JSON.
		$decoded = json_decode( $raw, true );
		return is_array( $decoded ) ? $decoded : array();
	}

	const UNDO_STACK_KEY = '_pressgo_undo_stack';
	const UNDO_STACK_MAX = 20;

	/**
	 * Every builder-claim meta a render-target apply may touch. The first
	 * three groups mirror the claims arrays in
	 * PressGo_Render_Targets::neutralize_other_targets (which deletes the
	 * OTHER builders' metas on every apply); the last two are the dispatch
	 * stamps the apply rewrites. Undo snapshots capture the ones that exist
	 * and restore exactly that set — deleting any that didn't exist.
	 */
	private static function undo_target_meta_keys() {
		return array(
			// elementor claims
			'_elementor_edit_mode', '_elementor_data', '_elementor_element_cache', '_elementor_template_type',
			// divi claims
			'_et_pb_use_builder', '_et_pb_page_layout', '_et_pb_post_hide_nav', '_et_pb_built_for_post_type',
			// bricks claims
			'_bricks_editor_mode', '_bricks_page_content_2',
			// dispatch stamps
			'_pressgo_target_builder', '_wp_page_template',
		);
	}

	/**
	 * Snapshot the page's current state into an undo stack entry. Called by
	 * every mutating tool BEFORE it writes. Cap'd at 20 entries.
	 *
	 * Captures per-target state: the raw AI config + post_content (source of
	 * truth on non-Elementor targets) and every builder claim-meta that
	 * exists, alongside the legacy elementor_data/sections/globals fields.
	 */
	public static function push_undo( $post_id, $reason ) {
		$post         = get_post( $post_id );
		$target_metas = array();
		foreach ( self::undo_target_meta_keys() as $mk ) {
			if ( metadata_exists( 'post', $post_id, $mk ) ) {
				$target_metas[ $mk ] = get_post_meta( $post_id, $mk, true );
			}
		}
		$snapshot = array(
			'ts'             => time(),
			'reason'         => substr( (string) $reason, 0, 200 ),
			'elementor_data' => get_post_meta( $post_id, '_elementor_data', true ),
			'sections'       => get_post_meta( $post_id, '_pressgo_sections', true ),
			'globals'        => get_post_meta( $post_id, '_pressgo_globals', true ),
			'page_settings'  => get_post_meta( $post_id, '_elementor_page_settings', true ),
			'config'         => get_post_meta( $post_id, '_pressgo_ai_config', true ),
			'post_content'   => $post ? (string) $post->post_content : '',
			'target_metas'   => $target_metas,
		);
		$stack = get_post_meta( $post_id, self::UNDO_STACK_KEY, true );
		if ( ! is_array( $stack ) ) { $stack = array(); }
		$stack[] = $snapshot;
		if ( count( $stack ) > self::UNDO_STACK_MAX ) {
			$stack = array_slice( $stack, -self::UNDO_STACK_MAX );
		}
		// wp_slash: update_post_meta unslashes its value, which would eat the
		// backslashes inside captured JSON strings (\/ and \uXXXX escapes in
		// _elementor_data / _pressgo_ai_config) and make restores non-identical.
		update_post_meta( $post_id, self::UNDO_STACK_KEY, wp_slash( $stack ) );
	}

	/**
	 * Drop the most recent undo entry. Used when a tool pushed a snapshot but
	 * its apply then failed without modifying the page — keeping the entry
	 * would make the next undo a silent no-op that burns a level.
	 */
	private static function discard_last_undo( $post_id ) {
		$stack = get_post_meta( $post_id, self::UNDO_STACK_KEY, true );
		if ( ! is_array( $stack ) || empty( $stack ) ) { return; }
		array_pop( $stack );
		update_post_meta( $post_id, self::UNDO_STACK_KEY, wp_slash( $stack ) );
	}

	private static function undo_last_change( $args, $user ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		$err     = self::guard_post( $post_id, $user );
		if ( is_wp_error( $err ) ) { return $err; }
		$pause = self::check_pause( $post_id, $args );
		if ( is_wp_error( $pause ) ) { return $pause; }

		$stack = get_post_meta( $post_id, self::UNDO_STACK_KEY, true );
		if ( ! is_array( $stack ) || empty( $stack ) ) {
			return new WP_Error( 'mcp_no_undo', 'Nothing to undo on this page yet.' );
		}
		$snap = array_pop( $stack );
		update_post_meta( $post_id, self::UNDO_STACK_KEY, wp_slash( $stack ) );

		// Restore each meta key from the snapshot. Values were captured
		// unslashed (get_post_meta), so writes must wp_slash — update_post_meta
		// unslashes internally and would otherwise corrupt JSON escapes.
		if ( isset( $snap['target_metas'] ) && is_array( $snap['target_metas'] ) ) {
			// Per-target snapshot (multi-builder): restore every builder
			// claim-meta exactly — write what existed, delete what didn't.
			// This includes _elementor_data, so the legacy field below is
			// skipped to avoid resurrecting an empty '' meta on non-Elementor
			// pages.
			foreach ( self::undo_target_meta_keys() as $mk ) {
				if ( array_key_exists( $mk, $snap['target_metas'] ) ) {
					$v = $snap['target_metas'][ $mk ];
					update_post_meta( $post_id, $mk, ( is_string( $v ) || is_array( $v ) ) ? wp_slash( $v ) : $v );
				} else {
					delete_post_meta( $post_id, $mk );
				}
			}
		} elseif ( isset( $snap['elementor_data'] ) ) {
			update_post_meta( $post_id, '_elementor_data', wp_slash( $snap['elementor_data'] ) );
		}
		if ( array_key_exists( 'config', $snap ) ) {
			if ( '' === $snap['config'] || null === $snap['config'] ) {
				delete_post_meta( $post_id, '_pressgo_ai_config' );
			} else {
				update_post_meta( $post_id, '_pressgo_ai_config', wp_slash( $snap['config'] ) );
			}
		}
		if ( array_key_exists( 'post_content', $snap ) ) {
			wp_update_post( wp_slash( array(
				'ID'           => $post_id,
				'post_content' => (string) $snap['post_content'],
			) ) );
		}
		if ( isset( $snap['sections'] ) ) {
			update_post_meta( $post_id, '_pressgo_sections', wp_slash( $snap['sections'] ?: array() ) );
		}
		if ( isset( $snap['globals'] ) ) {
			update_post_meta( $post_id, '_pressgo_globals', wp_slash( $snap['globals'] ?: array() ) );
		}
		if ( isset( $snap['page_settings'] ) ) {
			update_post_meta( $post_id, '_elementor_page_settings', wp_slash( $snap['page_settings'] ?: array() ) );
		}
		// Bust caches so the next read/render is fresh.
		clean_post_cache( $post_id );
		if ( function_exists( 'rocket_clean_post' ) ) {
			rocket_clean_post( $post_id );
		}
		if ( class_exists( '\Elementor\Plugin' ) ) {
			try { $css = \Elementor\Core\Files\CSS\Post::create( $post_id ); if ( $css ) { $css->delete(); } } catch ( \Throwable $e ) {}
		}

		$reason = $snap['reason'] ?: 'previous change';
		$age = time() - (int) $snap['ts'];
		$remaining = count( $stack );
		return self::page_summary( $post_id,
			"Undid {$reason} ({$age}s ago). {$remaining} undo level(s) remaining."
		);
	}

	public static function write_elementor_data( $post_id, $elements ) {
		update_post_meta( $post_id, '_elementor_data', wp_slash( wp_json_encode( $elements ) ) );

		// Bump post_modified so caches downstream (object cache, CDN, page
		// cache plugins) see a new version. Without this WP serves stale
		// rendered HTML even though _elementor_data is correct.
		wp_update_post( array(
			'ID'                => $post_id,
			'post_modified'     => current_time( 'mysql' ),
			'post_modified_gmt' => current_time( 'mysql', 1 ),
		) );

		// Re-assert the Elementor "this is an Elementor page" markers after
		// wp_update_post fires save_post — some third-party hooks (security
		// scanners, backup plugins, even Elementor's own document validator
		// under odd conditions) can reset these to empty, which causes the
		// "Elementor randomly deactivated" symptom where the page renders as
		// raw HTML with default theme styling. Always restore the canonical
		// state.
		update_post_meta( $post_id, '_elementor_edit_mode', 'builder' );
		update_post_meta( $post_id, '_elementor_template_type', 'wp-page' );
		if ( defined( 'ELEMENTOR_VERSION' ) ) {
			update_post_meta( $post_id, '_elementor_version', ELEMENTOR_VERSION );
		}
		if ( get_post_meta( $post_id, '_wp_page_template', true ) !== 'elementor_canvas' ) {
			update_post_meta( $post_id, '_wp_page_template', 'elementor_canvas' );
		}

		// Bust the WP object cache for this post.
		clean_post_cache( $post_id );

		// Drop every Elementor-side per-post cache key.
		delete_post_meta( $post_id, '_elementor_css' );
		delete_post_meta( $post_id, '_elementor_inner_section_css' );
		delete_post_meta( $post_id, '_elementor_page_assets' );
		delete_post_meta( $post_id, '_elementor_controls_usage' );

		// Hard-delete the per-post CSS file on disk. Elementor's $css->delete()
		// sometimes leaves a stale file when use_external_file() returns false;
		// removing it directly ensures the next frontend render regenerates
		// from current _elementor_data. Element IDs (eid()) are randomized
		// each rebuild, so a stale CSS file's selectors won't match the new
		// HTML — that's the root cause of the "broken styling after
		// update_section" symptom.
		$upload_dir = wp_upload_dir();
		$css_path   = $upload_dir['basedir'] . '/elementor/css/post-' . $post_id . '.css';
		if ( file_exists( $css_path ) ) {
			@unlink( $css_path );
		}

		if ( class_exists( '\Elementor\Plugin' ) ) {
			try {
				$css = \Elementor\Core\Files\CSS\Post::create( $post_id );
				if ( $css ) { $css->delete(); }
				// Clear the global files cache — invalidates shared frontend
				// asset manifests that survive the per-post delete above.
				if ( method_exists( \Elementor\Plugin::$instance->files_manager, 'clear_cache' ) ) {
					\Elementor\Plugin::$instance->files_manager->clear_cache();
				}
			} catch ( \Throwable $e ) { /* best effort */ }
		}

		// Page-cache plugins (WP Rocket, etc.) commonly use these hooks.
		if ( function_exists( 'rocket_clean_post' ) ) {
			rocket_clean_post( $post_id );
		}
		do_action( 'clean_post_cache', $post_id, get_post( $post_id ) );
	}

	const BRAND_FOUNDATION_OPTION = 'pressgo_brand_foundation';

	/** The site's saved brand foundation (design system), or empty array. */
	public static function brand_foundation() {
		$f = get_option( self::BRAND_FOUNDATION_OPTION, array() );
		return is_array( $f ) ? $f : array();
	}

	/**
	 * Save / update the brand foundation. Merges partial colors/fonts/layout
	 * over saved tokens so the AI can build the design system incrementally.
	 */
	/**
	 * Merge brand fields into the site-wide foundation. Shared by the MCP
	 * set_brand_foundation tool AND the AI Builder's continuous-branding
	 * auto-learn — ONE brand store for both paths. Returns the saved array.
	 */
	/** True for a color the renderer/Elementor can consume: hex, rgb(a), hsl(a), transparent. */
	private static function is_color_value( $v ) {
		if ( ! is_string( $v ) ) { return false; }
		$v = trim( $v );
		if ( '' === $v ) { return false; }
		if ( 'transparent' === strtolower( $v ) ) { return true; }
		if ( preg_match( '/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $v ) ) { return true; }
		return (bool) preg_match( '/^(rgb|rgba|hsl|hsla)\(\s*[\d.,%\s\/]+\)$/i', $v );
	}

	public static function merge_brand_foundation( $args ) {
		$f = self::brand_foundation();
		if ( isset( $args['brand_name'] ) && is_string( $args['brand_name'] ) ) { $f['brand_name'] = sanitize_text_field( $args['brand_name'] ); }
		if ( isset( $args['industry'] ) && is_string( $args['industry'] ) )     { $f['industry']   = sanitize_text_field( $args['industry'] ); }
		if ( isset( $args['logo_url'] ) && is_string( $args['logo_url'] ) )     { $f['logo_url']   = esc_url_raw( $args['logo_url'] ); }
		if ( isset( $args['favicon_url'] ) && is_string( $args['favicon_url'] ) ) { $f['favicon_url'] = esc_url_raw( $args['favicon_url'] ); }
		if ( isset( $args['logo_url_id'] ) )     { $f['logo_url_id']     = absint( $args['logo_url_id'] ); }
		if ( isset( $args['favicon_url_id'] ) )  { $f['favicon_url_id']  = absint( $args['favicon_url_id'] ); }
		if ( isset( $args['voice'] ) && is_string( $args['voice'] ) )           { $f['voice']      = sanitize_textarea_field( $args['voice'] ); }
		foreach ( array( 'colors', 'fonts', 'layout' ) as $k ) {
			if ( isset( $args[ $k ] ) && is_array( $args[ $k ] ) ) {
				$cur   = ( isset( $f[ $k ] ) && is_array( $f[ $k ] ) ) ? $f[ $k ] : array();
				$clean = array();
				foreach ( $args[ $k ] as $kk => $vv ) {
					if ( is_array( $vv ) ) { continue; }
					$key = sanitize_key( (string) $kk );
					if ( 'colors' === $k ) {
						// Reject garbage so a bad save (e.g. an <input type=color> coercing an
						// rgba token to #000000, or an empty value) can't poison a color role.
						$val = trim( (string) $vv );
						if ( ! self::is_color_value( $val ) ) { continue; }
						$clean[ $key ] = $val;
					} else {
						$clean[ $key ] = is_numeric( $vv ) ? ( $vv + 0 ) : sanitize_text_field( (string) $vv );
					}
				}
				$f[ $k ] = array_merge( $cur, $clean );
			}
		}
		$f['updated'] = time();
		update_option( self::BRAND_FOUNDATION_OPTION, $f );
		// A favicon is only "applied" when WordPress knows it as the site icon.
		if ( ! empty( $f['favicon_url_id'] ) && get_post( (int) $f['favicon_url_id'] ) ) {
			update_option( 'site_icon', (int) $f['favicon_url_id'] );
		}
		return $f;
	}

	private static function set_brand_foundation( $args, $user ) {
		$f = self::merge_brand_foundation( $args );
		return array(
			'content' => array( array( 'type' => 'text',
				'text' => "Brand foundation saved. New pages now default to this palette, fonts, and layout, and every future chat starts from this design system." ) ),
			'structuredContent' => array( 'brand_foundation' => $f ),
		);
	}

	private static function get_brand_foundation( $args, $user ) {
		$f = self::brand_foundation();
		return array(
			'content' => array( array( 'type' => 'text',
				'text' => $f ? wp_json_encode( $f, JSON_PRETTY_PRINT ) : 'No brand foundation set yet. Run a short discovery interview, then call set_brand_foundation to lock in the design system.' ) ),
			'structuredContent' => array( 'brand_foundation' => $f ),
		);
	}

	/** Hardcoded base globals (validator defaults), overlaid with the brand foundation. */
	private static function default_globals() {
		$base = array(
			'colors' => array(
				'primary'    => '#0043B3',
				'dark_bg'    => '#0a0f1e',
				'light_bg'   => '#f7f8fc',
				'white'      => '#ffffff',
				'text_dark'  => '#0a0f1e',
				'text_muted' => '#6b7280',
				'accent'     => '#00B418',
			),
			'fonts'  => array( 'heading' => 'Inter', 'body' => 'Inter' ),
			'layout' => array( 'boxed_width' => 1200, 'section_padding' => 100, 'card_radius' => 16, 'button_radius' => 10 ),
		);
		$f = self::brand_foundation();
		foreach ( array( 'colors', 'fonts', 'layout' ) as $k ) {
			if ( ! empty( $f[ $k ] ) && is_array( $f[ $k ] ) ) {
				$base[ $k ] = array_merge( $base[ $k ], $f[ $k ] );
			}
		}
		return $base;
	}

	public static function get_page_globals( $post_id ) {
		$saved = get_post_meta( $post_id, '_pressgo_globals', true );
		if ( is_array( $saved ) && isset( $saved['colors'], $saved['fonts'], $saved['layout'] ) ) {
			return $saved;
		}
		// No page-specific globals yet → start from the site's brand foundation.
		return self::default_globals();
	}

	public static function set_page_globals( $post_id, $globals ) {
		update_post_meta( $post_id, '_pressgo_globals', $globals );
	}

	private static function append_section_record( $post_id, $type, $variant, $data ) {
		$records = get_post_meta( $post_id, '_pressgo_sections', true );
		if ( ! is_array( $records ) ) { $records = array(); }
		$records[] = array( 'type' => $type, 'variant' => $variant ?: null, 'data' => $data );
		update_post_meta( $post_id, '_pressgo_sections', $records );
	}

	private static function replace_section_record( $post_id, $index, $type, $variant, $data ) {
		$records = get_post_meta( $post_id, '_pressgo_sections', true );
		if ( ! is_array( $records ) ) { $records = array(); }
		// Pad with placeholders if needed (e.g. user manually added Elementor sections).
		while ( count( $records ) <= $index ) {
			$records[] = array( 'type' => null, 'variant' => null, 'data' => null );
		}
		$records[ $index ] = array( 'type' => $type, 'variant' => $variant ?: null, 'data' => $data );
		update_post_meta( $post_id, '_pressgo_sections', $records );
	}

	/**
	 * Re-render every section we have a record for, replacing the matching
	 * entry in _elementor_data with a freshly-built version. Sections without
	 * a record (manually authored) are left untouched.
	 */
	private static function rerender_all_sections( $post_id, $globals ) {
		$records = get_post_meta( $post_id, '_pressgo_sections', true );
		if ( ! is_array( $records ) ) { return; }
		$elements = self::read_elementor_data( $post_id );
		$generator = new PressGo_Generator();
		foreach ( $records as $i => $rec ) {
			if ( empty( $rec['type'] ) || empty( $rec['data'] ) ) {
				continue;
			}
			$section_data = $rec['data'];
			if ( ! empty( $rec['variant'] ) ) {
				$section_data['variant'] = $rec['variant'];
			}
			$partial = array_merge( $globals, array(
				'sections' => array( $rec['type'] ),
				$rec['type'] => $section_data,
			) );
			$validated = PressGo_Config_Validator::validate( $partial );
			if ( is_wp_error( $validated ) ) { continue; }
			$built = $generator->generate( $validated );
			if ( ! empty( $built[0] ) && isset( $elements[ $i ] ) ) {
				$elements[ $i ] = $built[0];
			}
		}
		self::write_elementor_data( $post_id, $elements );
	}

	private static function stamp_pressgo_meta( $post_id, $config ) {
		update_post_meta( $post_id, '_pressgo_built', '1' );
		if ( ! empty( $config['colors'] ) || ! empty( $config['fonts'] ) || ! empty( $config['layout'] ) ) {
			self::set_page_globals( $post_id, array(
				'colors' => isset( $config['colors'] ) ? $config['colors'] : self::get_page_globals( 0 )['colors'],
				'fonts'  => isset( $config['fonts'] )  ? $config['fonts']  : self::get_page_globals( 0 )['fonts'],
				'layout' => isset( $config['layout'] ) ? $config['layout'] : self::get_page_globals( 0 )['layout'],
			) );
		}
		// Build a section-record list so update_section / set_globals can re-render.
		$records = array();
		$sections = isset( $config['sections'] ) ? $config['sections'] : array();
		foreach ( $sections as $type ) {
			if ( ! isset( $config[ $type ] ) ) { continue; }
			$data    = $config[ $type ];
			$variant = isset( $data['variant'] ) ? $data['variant'] : null;
			$records[] = array( 'type' => $type, 'variant' => $variant, 'data' => $data );
		}
		update_post_meta( $post_id, '_pressgo_sections', $records );
	}

	private static function page_summary( $post_id, $note = '' ) {
		$target = self::page_target( $post_id );
		if ( 'elementor' === $target ) {
			$edit_url      = admin_url( "post.php?post={$post_id}&action=elementor" );
			$section_count = count( self::read_elementor_data( $post_id ) );
		} else {
			$edit_url = admin_url( "post.php?post={$post_id}&action=edit" );
			$cfg      = self::read_ai_config( $post_id );
			$section_count = ( is_array( $cfg ) && isset( $cfg['sections'] ) && is_array( $cfg['sections'] ) )
				? count( $cfg['sections'] )
				: 0;
		}
		$summary = array(
			'post_id'         => (int) $post_id,
			'title'           => get_the_title( $post_id ),
			'status'          => get_post_status( $post_id ),
			'target'          => $target,
			'edit_url'        => $edit_url,
			'preview_url'     => add_query_arg( 'preview', 'true', get_permalink( $post_id ) ),
			'watch_url'       => class_exists( 'PressGo_MCP_Admin' ) ? PressGo_MCP_Admin::watch_url( $post_id ) : '',
			'section_count'   => $section_count,
		);
		$text = ( $note ? $note . "\n\n" : '' ) . wp_json_encode( $summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
		return array(
			'content' => array(
				array( 'type' => 'text', 'text' => $text ),
			),
			'structuredContent' => $summary,
		);
	}
}
