<?php
/**
 * Divi-specific tools — the moat.
 *
 * Theme builder, modules, presets, dynamic content, library, cache.
 * Bypasses the WP REST API exposure restrictions Divi puts on its CPTs.
 */

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

class PCD_PC_Tools_Divi {

	const TB_HEADER       = 'et_header_layout';
	const TB_FOOTER       = 'et_footer_layout';
	const TB_BODY         = 'et_body_layout';
	const TB_TEMPLATE     = 'et_template';
	const TB_THEME_BUILDER = 'et_theme_builder'; // singleton post holding the visible template list

	const DIVI5_CONTROLLER = '\\ET\\Builder\\VisualBuilder\\REST\\OutsideVb\\OutsideVbController';

	/**
	 * Detect whether Divi 5's OutsideVb REST controller is loaded.
	 * When yes, we delegate template operations to it for full fidelity with the
	 * Theme Builder UI (including draft/live state, append-to-list, rollback).
	 */
	private static function has_divi5_controller(): bool {
		return class_exists( self::DIVI5_CONTROLLER );
	}

	private static function require_divi5_controller() {
		if ( self::has_divi5_controller() ) {
			return true;
		}
		return new WP_Error(
			'divi5_controller_unavailable',
			'Divi 5 is active, but its native OutsideVb controller is unavailable. PressConduit will not fall back to Divi 4 storage.'
		);
	}

	private static function validate_content_generation( string $content ) {
		if ( '' === trim( $content ) ) {
			return true;
		}

		$engine = PCD_PC_Builder::divi_generation();
		if ( 'divi5' === $engine && preg_match( '/\[\/?et_pb_[a-z0-9_]+/i', $content ) ) {
			return new WP_Error( 'divi4_content_rejected', 'Divi 5 is active. Divi 4 shortcode content is not accepted on this site.' );
		}
		if ( 'divi4' === $engine && false !== strpos( $content, '<!-- wp:divi/' ) ) {
			return new WP_Error( 'divi5_content_rejected', 'Divi 4 is active. Divi 5 block content is not accepted on this site.' );
		}
		return true;
	}

	/**
	 * Build a WP_REST_Request and invoke a Divi 5 controller method directly.
	 * Bypasses HTTP routing + nonce machinery — we're already authenticated.
	 */
	private static function invoke_divi5( string $method, array $params ) {
		$request = new WP_REST_Request( 'POST' );
		foreach ( $params as $k => $v ) {
			$request->set_param( $k, $v );
		}
		$controller = self::DIVI5_CONTROLLER;
		$result     = call_user_func( [ $controller, $method ], $request );

		if ( $result instanceof WP_REST_Response ) {
			$data = $result->get_data();
			if ( is_array( $data ) && isset( $data['success'] ) && ! $data['success'] ) {
				return new WP_Error( $data['data']['code'] ?? 'divi5_error', $data['data']['message'] ?? 'Divi 5 controller returned failure.', [ 'status' => $result->get_status() ] );
			}
			return is_array( $data ) && isset( $data['data'] ) ? $data['data'] : $data;
		}
		if ( is_wp_error( $result ) ) {
			return $result;
		}
		return $result;
	}


	/**
	 * Find (or create) the singleton et_theme_builder post for the live state.
	 * Mirrors Divi's et_theme_builder_get_theme_builder_post_id().
	 */
	private static function get_theme_builder_post_id( bool $create = true ): int {
		// Prefer Divi's own helper if loaded — keeps us in lockstep with edge cases.
		if ( function_exists( 'et_theme_builder_get_theme_builder_post_id' ) ) {
			$id = (int) et_theme_builder_get_theme_builder_post_id( true, $create );
			return $id;
		}

		$query = new WP_Query( [
			'post_type'      => self::TB_THEME_BUILDER,
			'post_status'    => 'publish',
			'fields'         => 'ids',
			'posts_per_page' => 1,
			'meta_key'       => '_et_library_theme_builder', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- targeted lookup of an ET theme-builder flag; tiny dataset.
			'meta_compare'   => 'NOT EXISTS',
		] );
		if ( ! empty( $query->posts ) ) {
			return (int) $query->posts[0];
		}
		if ( ! $create ) {
			return 0;
		}
		$id = wp_insert_post( [
			'post_type'   => self::TB_THEME_BUILDER,
			'post_status' => 'publish',
			'post_title'  => 'Theme Builder',
		] );
		return is_wp_error( $id ) ? 0 : (int) $id;
	}

	/**
	 * Register a template ID in the theme_builder post's _et_template meta list.
	 * This is the step the UI iterates on — without it, templates are invisible.
	 */
	private static function register_template_in_builder( int $template_id ): void {
		$builder_id = self::get_theme_builder_post_id( true );
		if ( ! $builder_id ) {
			return;
		}

		// Refuse to duplicate-register.
		$existing = get_post_meta( $builder_id, '_et_template', false );
		if ( is_array( $existing ) && in_array( $template_id, array_map( 'intval', $existing ), true ) ) {
			return;
		}

		add_post_meta( $builder_id, '_et_template', $template_id );

		// Clear stale backup so the next read sees the new list.
		delete_option( 'et_tb_templates_backup_' . $builder_id );
	}

	/**
	 * Unregister — call when a template is deleted so the UI doesn't try to render
	 * a now-trashed post.
	 */
	private static function unregister_template_from_builder( int $template_id ): void {
		$builder_id = self::get_theme_builder_post_id( false );
		if ( ! $builder_id ) {
			return;
		}
		delete_post_meta( $builder_id, '_et_template', $template_id );
		delete_option( 'et_tb_templates_backup_' . $builder_id );
	}

	public static function register(): void {
		$engine = PCD_PC_Builder::divi_generation();
		$is_d5  = 'divi5' === $engine;

		PCD_PC_Tools::register(
			'divi_status',
			'Report the active Divi generation and the capabilities available for that generation.',
			[ 'type' => 'object', 'properties' => new stdClass() ],
			[ __CLASS__, 'status' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_migration_guide',
			'Return the step-by-step routine for faithfully migrating a Divi 4 page (or whole site) to NATIVE Divi 5 without changing how it looks. CALL THIS FIRST whenever asked to convert/migrate Divi 4 → Divi 5, "make it native Divi 5", or rebuild shortcode pages as blocks. A migration reproduces the original 1:1 — it is not a redesign.',
			[ 'type' => 'object', 'properties' => new stdClass() ],
			[ __CLASS__, 'migration_guide' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_template_list',
			'List Divi theme builder templates. Returns enabled templates by default. Set include_all=true to see disabled/orphaned ones too.',
			[
				'type' => 'object',
				'properties' => [
					'include_all' => [ 'type' => 'boolean', 'default' => false ],
				],
			],
			[ __CLASS__, 'template_list' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_template_create',
			'Create a new Divi Theme Builder template with full meta wiring (the way Divi\'s own UI does it). Set is_default=true to make this the default sitewide template. Provide header_id/body_id/footer_id to assign existing layouts.',
			[
				'type' => 'object',
				'required' => [ 'title' ],
				'properties' => [
					'title'        => [ 'type' => 'string' ],
					'is_default'   => [ 'type' => 'boolean', 'default' => false ],
					'enabled'      => [ 'type' => 'boolean', 'default' => true ],
					'header_id'    => [ 'type' => 'integer' ],
					'body_id'      => [ 'type' => 'integer' ],
					'footer_id'    => [ 'type' => 'integer' ],
					'use_on'       => [ 'type' => 'array', 'items' => [ 'type' => 'string' ], 'description' => 'Divi condition strings, e.g. ["singular:post","page"]' ],
					'exclude_from' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
				],
			],
			[ __CLASS__, 'template_create' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_template_delete',
			$is_d5
				? 'Trash a Divi 5 Theme Builder template through the native Divi 5 controller. Pass force=true to permanently delete it.'
				: 'Trash a Divi 4 Theme Builder template. Pass force=true to permanently delete it.',
			[
				'type' => 'object',
				'required' => [ 'id' ],
				'properties' => [
					'id'      => [ 'type' => 'integer' ],
					'force'   => [ 'type' => 'boolean', 'default' => false ],
					'confirm' => [ 'type' => 'boolean' ],
				],
			],
			[ __CLASS__, 'template_delete' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi', 'destructive' => true ]
		);

		PCD_PC_Tools::register(
			'divi_template_update',
			'Update an existing Theme Builder template — layouts (header/body/footer), is_default, enabled flag. Conditions go through divi_template_assign.',
			[
				'type' => 'object',
				'required' => [ 'id' ],
				'properties' => [
					'id'         => [ 'type' => 'integer' ],
					'title'      => [ 'type' => 'string' ],
					'is_default' => [ 'type' => 'boolean' ],
					'enabled'    => [ 'type' => 'boolean' ],
					'header_id'  => [ 'type' => 'integer' ],
					'body_id'    => [ 'type' => 'integer' ],
					'footer_id'  => [ 'type' => 'integer' ],
				],
			],
			[ __CLASS__, 'template_update' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_template_assign',
			'Assign display conditions (use_on / exclude_from) to a Theme Builder template — controls which pages it applies to.',
			[
				'type' => 'object',
				'required' => [ 'id' ],
				'properties' => [
					'id'           => [ 'type' => 'integer' ],
					'use_on'       => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
					'exclude_from' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
				],
			],
			[ __CLASS__, 'template_assign' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_layout_list',
			'List Divi layouts of a given type. Type: header | footer | body | template (default body).',
			[
				'type' => 'object',
				'properties' => [
					'type' => [ 'type' => 'string', 'enum' => [ 'header', 'footer', 'body', 'template' ], 'default' => 'body' ],
				],
			],
			[ __CLASS__, 'layout_list' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_layout_get',
			$is_d5
				? 'Read a Divi 5 layout by ID, including its native block content.'
				: 'Read a Divi 4 layout by ID, including its shortcode content.',
			[
				'type' => 'object',
				'required' => [ 'id' ],
				'properties' => [ 'id' => [ 'type' => 'integer' ] ],
			],
			[ __CLASS__, 'layout_get' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_layout_repair',
			'Repair a Divi layout post that was created without the builder-enable meta. Sets _et_pb_use_builder, _et_pb_built_for_post_type, and the layout_type taxonomy term so the Visual Builder can open the layout. Use this on any header/footer/body layout where the builder shows "An unknown error has occurred".',
			[
				'type' => 'object',
				'required' => [ 'id' ],
				'properties' => [
					'id' => [ 'type' => 'integer' ],
				],
			],
			[ __CLASS__, 'layout_repair' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_layout_save',
			$is_d5
				? 'Create or update a Divi 5 layout. Pass native Divi 5 block content.'
				: 'Create or update a Divi 4 layout. Pass Divi 4 shortcode content.',
			[
				'type' => 'object',
				'required' => [ 'type', 'title', 'content' ],
				'properties' => [
					'type'    => [ 'type' => 'string', 'enum' => [ 'header', 'footer', 'body', 'template' ] ],
					'id'      => [ 'type' => 'integer', 'description' => 'Omit to create new, provide to update.' ],
					'title'   => [ 'type' => 'string' ],
					'content' => [
						'type'        => 'string',
						'description' => $is_d5 ? 'Native Divi 5 block content.' : 'Divi 4 shortcode content.',
					],
				],
			],
			[ __CLASS__, 'layout_save' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_global_template_get',
			'Get the current global theme builder template assignment (which header/footer applies sitewide).',
			[ 'type' => 'object', 'properties' => new stdClass() ],
			[ __CLASS__, 'global_template_get' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_global_template_set',
			'Assign global header/footer/body layouts. Pass header_id, footer_id, body_id (any combination). Snapshots prior assignment.',
			[
				'type' => 'object',
				'properties' => [
					'header_id' => [ 'type' => 'integer' ],
					'footer_id' => [ 'type' => 'integer' ],
					'body_id'   => [ 'type' => 'integer' ],
				],
			],
			[ __CLASS__, 'global_template_set' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_clear_static_css',
			'Clear the Divi static CSS cache. Use this after global color/font changes or theme builder edits.',
			[ 'type' => 'object', 'properties' => new stdClass() ],
			[ __CLASS__, 'clear_static_css' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_global_colors_get',
			'Get the Divi global color palette.',
			[ 'type' => 'object', 'properties' => new stdClass() ],
			[ __CLASS__, 'global_colors_get' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_enable_builder',
			'Enable the Divi visual builder on a post/page (sets _et_pb_use_builder=on).',
			[
				'type' => 'object',
				'required' => [ 'post_id' ],
				'properties' => [ 'post_id' => [ 'type' => 'integer' ] ],
			],
			[ __CLASS__, 'enable_builder' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_set_post_layout',
			$is_d5
				? 'Apply native Divi 5 block content to a post or page. Pass source_layout_post_id or layout_content. Use divi_layout_skeleton and divi5_docs before authoring; content must use valid Section > Row > Column > module structure.'
				: 'Apply Divi 4 shortcode content to a post or page. Pass source_layout_post_id or layout_content. Also enables the Divi 4 builder on the target.',
			[
				'type' => 'object',
				'required' => [ 'post_id' ],
				'properties' => [
					'post_id'               => [ 'type' => 'integer' ],
					'source_layout_post_id' => [ 'type' => 'integer', 'description' => 'Copy content from this existing layout post.' ],
					'layout_content'        => [
						'type'        => 'string',
						'description' => $is_d5 ? 'Native Divi 5 block content.' : 'Divi 4 shortcode content.',
					],
				],
			],
			[ __CLASS__, 'set_post_layout' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi' ]
		);

		// ---- CHUNKED LAYOUT UPLOAD ----
		// Large Divi 5 native layouts (74KB+, 100+ modules) exceed what fits
		// in a single tool-call argument. These three tools stream a layout in
		// chunks into a server-side buffer, then commit it atomically through
		// the same set_post_layout pipeline.

		PCD_PC_Tools::register(
			'divi_layout_buffer_start',
			'Start (or reset) a chunked-upload buffer for a post. Use when a Divi layout is too large to pass in one divi_set_post_layout call (roughly >30KB). Then call divi_layout_buffer_append repeatedly, and divi_layout_buffer_commit to apply.',
			[
				'type' => 'object',
				'required' => [ 'post_id' ],
				'properties' => [ 'post_id' => [ 'type' => 'integer' ] ],
			],
			[ __CLASS__, 'layout_buffer_start' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_layout_buffer_append',
			'Append a chunk of layout markup to a post\'s upload buffer. Call repeatedly to stream a large layout in ~10-30KB pieces (e.g. one section per call). Returns the running total size.',
			[
				'type' => 'object',
				'required' => [ 'post_id', 'chunk' ],
				'properties' => [
					'post_id' => [ 'type' => 'integer' ],
					'chunk'   => [
						'type'        => 'string',
						'description' => $is_d5 ? 'Native Divi 5 block markup.' : 'Divi 4 shortcode markup.',
					],
				],
			],
			[ __CLASS__, 'layout_buffer_append' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_layout_buffer_commit',
			'Apply the buffered layout through the active Divi generation pipeline. Clears the buffer on success.',
			[
				'type' => 'object',
				'required' => [ 'post_id' ],
				'properties' => [ 'post_id' => [ 'type' => 'integer' ] ],
			],
			[ __CLASS__, 'layout_buffer_commit' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_options_get',
			'Read Divi theme options (allowlisted: fonts, colors, content width, static CSS, etc).',
			[
				'type' => 'object',
				'properties' => [
					'keys' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ], 'description' => 'Specific keys to read. Omit for all allowlisted.' ],
				],
			],
			[ __CLASS__, 'options_get' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_options_set',
			'Set a single Divi theme option (must be in the allowlist). Snapshot the prior value for undo.',
			[
				'type' => 'object',
				'required' => [ 'key', 'value' ],
				'properties' => [
					'key'   => [ 'type' => 'string' ],
					'value' => [ 'type' => 'string' ],
				],
			],
			[ __CLASS__, 'options_set' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_layout_export',
			'Export a Divi layout (a post built with the visual builder) as portable JSON. Use this to backup, version, or move to another site.',
			[
				'type' => 'object',
				'required' => [ 'post_id' ],
				'properties' => [ 'post_id' => [ 'type' => 'integer' ] ],
			],
			[ __CLASS__, 'layout_export' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_create_page',
			$is_d5
				? 'Create a page using only the Divi 5 native block engine. Use divi_layout_skeleton and divi5_docs before supplying content.'
				: 'Create a page using only the Divi 4 shortcode engine.',
			[
				'type' => 'object',
				'required' => [ 'title' ],
				'properties' => [
					'title'       => [ 'type' => 'string' ],
					'content'     => [
						'type'        => 'string',
						'description' => $is_d5 ? 'Native Divi 5 block content.' : 'Divi 4 shortcode content.',
					],
					'status'      => [ 'type' => 'string', 'enum' => [ 'draft', 'publish', 'private', 'pending' ], 'default' => 'draft' ],
					'slug'        => [ 'type' => 'string' ],
					'parent_id'   => [ 'type' => 'integer' ],
					'page_layout' => [ 'type' => 'string', 'enum' => [ 'et_full_width_page', 'et_no_sidebar', 'et_right_sidebar', 'et_left_sidebar' ], 'default' => 'et_no_sidebar' ],
					'meta'        => [ 'type' => 'object', 'description' => 'Additional post meta to set after creation (featured_image_id supported as a convenience).' ],
				],
			],
			[ __CLASS__, 'create_page' ],
			[ 'cap' => 'edit_pages', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_create_post',
			$is_d5
				? 'Create a blog post using only the Divi 5 native block engine. Use divi_layout_skeleton and divi5_docs before supplying content.'
				: 'Create a blog post using only the Divi 4 shortcode engine.',
			[
				'type' => 'object',
				'required' => [ 'title' ],
				'properties' => [
					'title'    => [ 'type' => 'string' ],
					'content'  => [ 'type' => 'string' ],
					'status'   => [ 'type' => 'string', 'enum' => [ 'draft', 'publish', 'private', 'pending' ], 'default' => 'draft' ],
					'slug'     => [ 'type' => 'string' ],
					'excerpt'  => [ 'type' => 'string' ],
					'categories' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ] ],
					'tags'       => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
					'featured_image_id' => [ 'type' => 'integer' ],
				],
			],
			[ __CLASS__, 'create_post' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_global_colors_set',
			'Update the Divi global color palette. Pass an associative array of {slug: {color, active}} entries. Snapshots the prior palette and clears Divi\'s static CSS cache after.',
			[
				'type' => 'object',
				'required' => [ 'colors' ],
				'properties' => [
					'colors' => [
						'type' => 'object',
						'description' => 'Map of color slug → {color: "#RRGGBB", active: true|false}',
					],
					'replace' => [
						'type' => 'boolean',
						'default' => false,
						'description' => 'If true, replace the entire palette. If false (default), merge with existing.',
					],
				],
			],
			[ __CLASS__, 'global_colors_set' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		// ---- MODULE INTROSPECTION ----

		PCD_PC_Tools::register(
			'divi_module_list',
			$is_d5
				? 'List the Divi 5 block modules installed on this site.'
				: 'List the Divi 4 shortcode modules installed on this site.',
			[
				'type' => 'object',
				'properties' => [
					'search' => [
						'type' => 'string',
						'description' => 'Optional substring filter against name and title.',
					],
				],
			],
			[ __CLASS__, 'module_list' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi_introspection' ]
		);

		PCD_PC_Tools::register(
			'divi_module_schema',
			$is_d5
				? 'Return the Divi 5 block attribute schema for one active module.'
				: 'Return the Divi 4 field schema for one active shortcode module.',
			[
				'type' => 'object',
				'required' => [ 'name' ],
				'properties' => array_merge( [
						'name' => [
							'type' => 'string',
							'description' => $is_d5 ? 'Divi 5 block name, e.g. "divi/heading" or "heading".' : 'Divi 4 shortcode slug, e.g. "et_pb_text" or "text".',
						],
				], $is_d5 ? [] : [
						'compact' => [
							'type' => 'boolean',
							'default' => true,
							'description' => 'Trim fields to their useful schema values. Pass false for raw get_fields() output.',
						],
				] ),
			],
			[ __CLASS__, 'module_schema' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi_introspection' ]
		);

		if ( $is_d5 ) {
			PCD_PC_Tools::register(
			'divi_asset_catalog',
			'Look up Divi 5\'s bundled asset libraries — icons, section dividers, background masks, background patterns. Use when you need to reference one of these by name in module attributes (e.g. the icon on a blurb, the divider on a section, the mask on a column background). Names returned here are guaranteed valid for those attribute fields.',
			[
				'type' => 'object',
				'properties' => [
					'type' => [
						'type' => 'string',
						'enum' => [ 'icons', 'dividers', 'masks', 'patterns', 'fonts', 'all' ],
						'default' => 'all',
					],
					'search' => [
						'type' => 'string',
						'description' => 'Optional substring filter against name (and icon searchTerms).',
					],
					'limit' => [
						'type' => 'integer',
						'default' => 100,
						'minimum' => 1,
						'maximum' => 2000,
						'description' => 'Cap results — useful when querying icons (~2000 total).',
					],
				],
			],
			[ __CLASS__, 'asset_catalog' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi_introspection' ]
		);

			PCD_PC_Tools::register(
			'divi_module_catalog',
			'Return the bundled Divi 5 module catalog — every module on the platform with its full attribute path tree (e.g. `decoration.layout.rowGap` on `divi/row`) and default values. Sourced from the Divi 5 theme files at build time, so it covers every module without needing live examples on the site. Use when you want comprehensive reference (e.g. listing every available module) or when divi_module_example reports no usable live example.',
			[
				'type' => 'object',
				'properties' => [
					'name'   => [ 'type' => 'string', 'description' => 'Optional. If set, return just this one module\'s entry. Accepts "divi/row" or bare "row".' ],
					'fields' => [
						'type' => 'string',
						'enum' => [ 'summary', 'full' ],
						'default' => 'summary',
						'description' => 'summary: name/title/category only. full: include attributes + defaults.',
					],
				],
			],
				[ __CLASS__, 'module_catalog' ],
				[ 'cap' => 'edit_posts', 'category' => 'divi_introspection' ]
			);
		}

		PCD_PC_Tools::register(
			'divi_module_example',
			$is_d5
				? 'Find a real Divi 5 block example by scanning this site and return its native block markup.'
				: 'Find a real Divi 4 shortcode example by scanning this site and return its shortcode markup.',
			[
				'type' => 'object',
				'required' => [ 'name' ],
				'properties' => [
						'name' => [
							'type' => 'string',
							'description' => $is_d5 ? 'Divi 5 block name or bare block slug.' : 'Divi 4 shortcode slug or bare module slug.',
					],
					'max_examples' => [
						'type' => 'integer',
						'default' => 1,
						'minimum' => 1,
						'maximum' => 5,
						'description' => 'Number of distinct examples to return.',
					],
				],
			],
			[ __CLASS__, 'module_example' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi_introspection' ]
		);

		if ( $is_d5 ) {
			PCD_PC_Tools::register(
			'divi_layout_skeleton',
			'Return a ready-to-fill, guaranteed-valid Divi 5 page skeleton (Section > Row > Column > module) for a chosen column layout. **On a Divi 5 site, call this FIRST when building a page from scratch.** Divi 5 only renders content nested inside the full Section→Row→Column structure WITH its required structural attributes (columnStructure, flexColumnStructure, column type, flexType on a 24-unit grid). A module placed loosely, or a row/column missing those attributes, renders BLANK — this is the #1 cause of empty Divi 5 pages. Copy the returned `skeleton`, replace the placeholder heading/text with your real modules, and repeat rows/sections as needed. `layout` options: 1col, 2col, 3col, 4col, sidebar-left, sidebar-right, split-3-2.',
			[
				'type' => 'object',
				'properties' => [
					'layout' => [
						'type'        => 'string',
						'enum'        => [ '1col', '2col', '3col', '4col', 'sidebar-left', 'sidebar-right', 'split-3-2' ],
						'default'     => '1col',
						'description' => 'Column layout for the row in the returned skeleton.',
					],
					'column_row_gap' => [
						'type'        => 'string',
						'default'     => '30px',
						'description' => 'Vertical gap between stacked modules INSIDE each column (the column is a flex container; this is its rowGap). Default "30px" matches Divi 5\'s own built-in default for a fresh row, so the skeleton behaves like one added in the Visual Builder. Set "0px" if you plan to control spacing via per-module margins instead — but pick one strategy, or rowGap and per-module margins compound and double-space.',
					],
				],
			],
			[ __CLASS__, 'layout_skeleton' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi_introspection' ]
		);

			PCD_PC_Tools::register(
			'divi5_docs',
			'Return a curated catalog of Divi 5 developer documentation, or fetch one catalog page through the relay. The catalog contains only Divi 5 authoring and API material.',
			[
				'type' => 'object',
				'properties' => [
					'topic' => [
						'type'        => 'string',
						'enum'        => [ 'must_read_first', 'block_format', 'module_development', 'community_playbooks', 'navigation_and_groups', 'api_reference', 'github_examples', 'changelog' ],
						'description' => 'Optional category filter. Omit to get the full catalog.',
					],
					'read' => [
						'type'        => 'string',
						'description' => 'Optional. A URL from the catalog (or any URL on an allowed Divi 5 doc domain). When provided, the relay fetches that page, converts it to markdown, caches it for 24h, and returns the content inline as { url, title, content, cached, fetched_at }. Use this to consume doc content directly without needing your own web-fetch capability — and to share a cached copy with every paired site instead of re-fetching from each.',
					],
				],
			],
			[ __CLASS__, 'divi5_docs' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi_introspection' ]
		);

		PCD_PC_Tools::register(
			'divi_layout_outline',
			'Map the Divi 5 block structure of a post: every section/row/column/module with its tree path (e.g. "3.1.0"), admin label, CSS classes, and a text snippet. Use this FIRST to find the path for divi_capture. Cheap — returns the map, never the markup.',
			[
				'type'       => 'object',
				'required'   => [ 'post_id' ],
				'properties' => [
					'post_id'   => [ 'type' => 'integer' ],
					'max_depth' => [ 'type' => 'integer', 'default' => 0, 'description' => '0 = unlimited. 2 = sections+rows only (cheap overview).' ],
				],
			],
			[ __CLASS__, 'layout_outline' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi_introspection' ]
		);

		PCD_PC_Tools::register(
			'divi_capture',
			'Capture a Divi 5 subtree (section, row, group, or module) from a post as portable block markup. Point at it with path (from divi_layout_outline), admin_label, or block+nth. Returns the markup plus a portability lint: theme-CSS class dependencies, inline styles, hard-coded colors (vs design-variable/global-color references), site-local preset reliance, and code-module escape hatches. portable=true only with zero errors.',
			[
				'type'       => 'object',
				'required'   => [ 'post_id' ],
				'properties' => [
					'post_id'     => [ 'type' => 'integer' ],
					'path'        => [ 'type' => 'string', 'description' => 'Dot-index path from divi_layout_outline, e.g. "3.1.0". Most precise pointer.' ],
					'admin_label' => [ 'type' => 'string', 'description' => 'Case-insensitive substring match on module admin labels, e.g. "PRICING HERO". Must match exactly one block — on multiple matches the match list is returned instead of markup.' ],
					'block'       => [ 'type' => 'string', 'description' => 'Block name, e.g. "divi/group" or bare "group".' ],
					'nth'         => [ 'type' => 'integer', 'default' => 1, 'description' => '1-based occurrence when using block, in document (depth-first) order.' ],
				],
			],
			[ __CLASS__, 'capture' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi_introspection' ]
		);

		PCD_PC_Tools::register(
			'divi_variables_get',
			'List the Divi 5 Design Variables on this site (numbers, strings/text, images, links, fonts, gradients — gvid-* tokens), with each variable\'s id, label, value, and the var(--gvid-xxxx) reference syntax. Color tokens live in the separate global-colors system (divi_global_colors_get).',
			[ 'type' => 'object', 'properties' => new stdClass() ],
			[ __CLASS__, 'variables_get' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_variables_set',
			'Create or update Divi 5 Design Variables. Pass variables as { "numbers": [ {"label":"Spacing - Medium","value":"32px"} ], "strings": [...], "images": [...], "links": [...], "fonts": [...], "gradients": [...] }. Items without an id get a fresh gvid; pass an existing id to update it. merge=true (default) keeps existing variables; merge=false replaces the whole set. Returns each saved variable with its var(--gvid-xxxx) reference. Colors go through divi_global_colors_set instead.',
			[
				'type'       => 'object',
				'required'   => [ 'variables' ],
				'properties' => [
					'variables' => [ 'type' => 'object', 'description' => 'Keys: numbers | strings | images | links | fonts | gradients. Values: arrays of { id?, label, value, order?, status? }.' ],
					'merge'     => [ 'type' => 'boolean', 'default' => true ],
				],
			],
			[ __CLASS__, 'variables_set' ],
			[ 'cap' => 'edit_theme_options', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_specimen_list',
			'List the built-in library of verified, portable Divi 5 section recipes (hero, pricing cards, FAQ, contact form, ...). Each entry is a known-good, token-ready pattern to copy and adapt — when building a common page section, PREFER adapting a specimen over composing from docs or guessing.',
			[ 'type' => 'object', 'properties' => new stdClass() ],
			[ __CLASS__, 'specimen_list' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_specimen_get',
			'Fetch one built-in Divi 5 specimen by name: full block markup, required color tokens (create them first with divi_global_colors_set), placeholder content/media that MUST be replaced, and annotations marking which attributes are brand-variable vs structural. Copy the markup, swap tokens/content/media, keep the structural attributes intact.',
			[
				'type'       => 'object',
				'required'   => [ 'name' ],
				'properties' => [ 'name' => [ 'type' => 'string', 'description' => 'Specimen name from divi_specimen_list, e.g. "hero".' ] ],
			],
			[ __CLASS__, 'specimen_get' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi' ]
		);
		}

		// Scissors — registered for BOTH generations. The handlers
		// (layout_outline / capture) dispatch to the Divi 4 shortcode path via
		// PCD_PC_Builder::is_divi4(); on Divi 5 these re-register over the
		// gen-specific copies above with identical behavior. Kept outside the
		// $is_d5 block so Divi 4 sites expose them too.
		PCD_PC_Tools::register(
			'divi_layout_outline',
			$is_d5
				? 'Map the Divi 5 block structure of a post: every section/row/column/module with its tree path (e.g. "3.1.0"), admin label, CSS classes, and a text snippet. Use this FIRST to find the path for divi_capture. Cheap — returns the map, never the markup.'
				: 'Map the Divi 4 shortcode structure of a post: every section/row/column/module with its tree path (e.g. "3.1.0"), admin label (admin_label), CSS class (module_class), and a text snippet. Use this FIRST to find the path for divi_capture. Cheap — returns the map, never the markup.',
			[
				'type'       => 'object',
				'required'   => [ 'post_id' ],
				'properties' => [
					'post_id'   => [ 'type' => 'integer' ],
					'max_depth' => [ 'type' => 'integer', 'default' => 0, 'description' => '0 = unlimited. 2 = sections+rows only (cheap overview).' ],
				],
			],
			[ __CLASS__, 'layout_outline' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi_introspection' ]
		);

		PCD_PC_Tools::register(
			'divi_capture',
			$is_d5
				? 'Capture a Divi 5 subtree (section, row, group, or module) from a post as portable block markup. Point at it with path (from divi_layout_outline), admin_label, or block+nth. Returns the markup plus a portability lint: theme-CSS class dependencies, inline styles, hard-coded colors (vs design-variable/global-color references), site-local preset reliance, and code-module escape hatches. portable=true only with zero errors.'
				: 'Capture a Divi 4 subtree (section, row, column, or module) from a post as byte-exact shortcode markup that round-trips perfectly. Point at it with path (from divi_layout_outline), admin_label, or block (et_pb_*)+nth. Returns the markup plus a portability lint (code modules, custom CSS classes). portable=true only with zero errors.',
			[
				'type'       => 'object',
				'required'   => [ 'post_id' ],
				'properties' => [
					'post_id'     => [ 'type' => 'integer' ],
					'path'        => [ 'type' => 'string', 'description' => 'Dot-index path from divi_layout_outline, e.g. "3.1.0". Most precise pointer.' ],
					'admin_label' => [ 'type' => 'string', 'description' => 'Case-insensitive substring match on module admin labels. Must match exactly one — on multiple matches the match list is returned instead of markup.' ],
					'block'       => [ 'type' => 'string', 'description' => 'Block/module name. Divi 5: e.g. "divi/group". Divi 4: e.g. "et_pb_pricing_tables".' ],
					'nth'         => [ 'type' => 'integer', 'default' => 1, 'description' => '1-based occurrence when using block, in document (depth-first) order.' ],
				],
			],
			[ __CLASS__, 'capture' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi_introspection' ]
		);

		// Specimen library — both generations. specimen_index() filters by the
		// site's active generation, so a D4 site sees only d4- specimens and a
		// D5 site sees only the Divi 5 set.
		PCD_PC_Tools::register(
			'divi_specimen_list',
			$is_d5
				? 'List the built-in library of verified, portable Divi 5 section recipes (hero, pricing cards, FAQ, contact form, ...). Each entry is a known-good, token-ready pattern to copy and adapt — when building a common page section, PREFER adapting a specimen over composing from docs or guessing.'
				: 'List the built-in library of verified, portable Divi 4 section recipes (pricing, feature grid, testimonials, ...). Each is a real, builder-made, byte-exact section to copy and adapt — when building a common page section, PREFER adapting a specimen over composing from scratch.',
			[ 'type' => 'object', 'properties' => new stdClass() ],
			[ __CLASS__, 'specimen_list' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi' ]
		);

		PCD_PC_Tools::register(
			'divi_specimen_get',
			$is_d5
				? 'Fetch one built-in Divi 5 specimen by name: full block markup, required color tokens (create them first with divi_global_colors_set), placeholder content/media that MUST be replaced, and annotations marking which attributes are brand-variable vs structural. Copy the markup, swap tokens/content/media, keep the structural attributes intact.'
				: 'Fetch one built-in Divi 4 specimen by name: full shortcode markup, the literal brand colors/fonts to swap (Divi 4 has no var() tokens), placeholder content/media that MUST be replaced, and structural notes. Copy the markup into a page, swap content/media/colors, keep the structure intact.',
			[
				'type'       => 'object',
				'required'   => [ 'name' ],
				'properties' => [ 'name' => [ 'type' => 'string', 'description' => 'Specimen name from divi_specimen_list, e.g. "d4-pricing".' ] ],
			],
			[ __CLASS__, 'specimen_get' ],
			[ 'cap' => 'edit_posts', 'category' => 'divi' ]
		);
	}

	// ------------------------------------------------------------------
	// Handlers
	// ------------------------------------------------------------------

	public static function migration_guide(): array {
		$routine = <<<'GUIDE'
DIVI 4 → DIVI 5 FAITHFUL MIGRATION ROUTINE

GOAL: the Divi 5 page must look IDENTICAL to the Divi 4 original. This is a migration, NOT a redesign. Never "improve", clean up, or substitute nicer styling — transcribe every original value 1:1. Only deviate if the user explicitly asks.

PRE-FLIGHT (once per site):
1. Confirm Divi 5 is active (divi_status). The page-open bundle that makes API-built pages open natively requires a current PressConduit build — if pcd_pc_check_update reports update_available, run pcd_pc_self_update first.
2. divi_global_colors_get → build a gcid-* → #hex map. Resolve EVERY color token through it.
3. Safety: never touch a client/production site without explicit OK. Prefer creating NEW draft pages so the live original stays as the untouched "before".

PER PAGE (one at a time — convert, verify, then next):
a. READ THE ORIGINAL EXACTLY (wp_post_get, include_meta=true). Capture every module and every attribute: background color/image/blend, font color+size+weight+alignment+text-transform, spacing/padding, border width/color/radius, button bg/text/border/radius, exact link URLs, and the EXACT text strings (don't paraphrase). D4 font string segment like |700||on||||| → the "on" in the 4th slot means UPPERCASE → D5 decoration.font.font.desktop.value.style:["uppercase"].
b. PULL THE REAL MODULE BEFORE AUTHORING — ALWAYS. For each module type, call divi_module_example (or divi_specimen_get for contact forms) and copy its exact attribute STRUCTURE. Divi 5 SILENTLY DROPS any module whose attributes fail its schema (it vanishes from the page). Verified paths: section bg color = module.decoration.background.desktop.value.color (just "color", NO enableColor key); button color/size/weight = button.decoration.font.font.desktop.value.{color,size,weight,letterSpacing,style}, bg = button.decoration.background.desktop.value.color, border = button.decoration.border.desktop.value.{styles.all.width,radius}, padding = module.decoration.spacing.desktop.value.padding, text/link = button.innerContent.desktop.value.{text,linkUrl}. Contact form = native divi/contact-form parent + divi/contact-field children. Structure rule: only Section>Row>Column>module renders; rows need columnStructure, columns need type + flexType (1_1=24_24,1_2=12_24,1_3=8_24,2_3=16_24,3_5=14_24,2_5=10_24). Use divi_layout_skeleton if unsure.
c. APPLY via divi_create_page (new) or divi_set_post_layout (existing). On D5 these route through the OutsideVb controller and the current build auto-applies the page-open bundle. Contact-form child fields survive this path.
d. VERIFY against the original — MANDATORY. divi_layout_outline → confirm structure + wrapped_in_placeholder:true + all modules present (esp. no buttons/text dropped). To see the RENDERED page you must publish first (draft previews are not publicly fetchable); set status=publish, fetch wp_page_rendered_html with ?nonitro=1, grep for the exact text/colors/text-transform/background-image, compare to the original's rendered page, fix mismatches, then set back to draft (or leave published if it's the deliverable). If a module is missing from the render, its markup was malformed → re-pull that module and fix.
e. GOTCHAS: NitroPack lazy-loads section background images → also set a solid base color so text stays readable. D4 and D5 render with tiny inherent differences (font metrics/spacing) — get values exact, expect to eyeball 1-2 tweaks per page.
f. CSS GRID (D5-native): where a D4 layout faked a card grid with stacked rows / equal-height column hacks / specialty sections, D5 has a native CSS-grid container (grid lives on the container via pseudo-classes; child modules are grid-agnostic, so it composes with the Loop Builder). Prefer it ONLY when it reproduces the original look more faithfully — the goal is still an identical result, not a nicer one. If plain Section>Row>Column already matches the D4 original 1:1, keep it; do not re-architect a working layout onto grid for its own sake.

SITE-LEVEL: Theme Builder header/footer/templates are separate layouts — migrate them separately. Global colors/presets carry across; confirm they exist on the target.

DONE = side by side the D5 page reads the same as the D4 original (layout, colors, fonts, sizes, text, images), it is genuine native blocks (wp:divi/*, not shortcodes, not compat-wrapped), and it opens directly in the Divi 5 builder.
GUIDE;
		return [ 'routine' => $routine ];
	}

	public static function status( array $args ): array {
		$theme    = wp_get_theme();
		$template = wp_get_theme( get_template() );
		$is_divi  = in_array( 'Divi', [ $theme->get( 'Name' ), $template->get( 'Name' ) ], true )
			|| in_array( 'Extra', [ $theme->get( 'Name' ), $template->get( 'Name' ) ], true );

		return [
			'divi_active'           => $is_divi,
			'generation'            => PCD_PC_Builder::divi_generation(),
			'theme_name'            => $theme->get( 'Name' ),
			'theme_version'         => $theme->get( 'Version' ),
			'has_theme_builder_cpt' => post_type_exists( self::TB_HEADER ),
			'et_core_loaded'        => defined( 'ET_CORE_VERSION' ),
			'static_css_class'      => class_exists( 'ET_Core_PageResource' ),
		];
	}

	public static function template_list( array $args ): array {
		if ( ! post_type_exists( self::TB_TEMPLATE ) ) {
			return [ 'available' => false, 'reason' => 'et_template post type not registered. Is Divi active?' ];
		}

		// Divi 5 path — delegate to the official controller, which respects live/draft state.
		if ( PCD_PC_Builder::is_divi5() ) {
			$ready = self::require_divi5_controller();
			if ( is_wp_error( $ready ) ) {
				return $ready;
			}
			$live  = ! empty( $args['live'] ) ? true : true; // default to live
			if ( isset( $args['live'] ) ) {
				$live = (bool) $args['live'];
			}
			$result = self::invoke_divi5( 'list_templates', [ 'live' => $live ] );
			if ( is_wp_error( $result ) ) {
				return [ 'available' => true, 'error' => $result->get_error_message() ];
			}
			return [
				'available'         => true,
				'source'            => 'divi5_controller',
				'theme_builder_id'  => $result['themeBuilderId'] ?? 0,
				'live'              => $live,
				'templates'         => $result['templates'] ?? [],
				'returned'          => is_array( $result['templates'] ?? null ) ? count( $result['templates'] ) : 0,
			];
		}

		// Divi 4 fallback — query and filter manually.
		$include_all = ! empty( $args['include_all'] );

		$templates = get_posts( [
			'post_type'      => self::TB_TEMPLATE,
			'post_status'    => 'publish',
			'posts_per_page' => -1,
		] );

		$rows = [];
		foreach ( $templates as $t ) {
			$is_default  = '1' === get_post_meta( $t->ID, '_et_default', true );
			$is_enabled  = '1' === get_post_meta( $t->ID, '_et_enabled', true );
			// Divi's frontend explicitly excludes templates with this meta — it's the
			// real "is this template live?" signal. UI-visible templates are ones
			// WITHOUT it.
			$marked_unused = metadata_exists( 'post', $t->ID, '_et_theme_builder_marked_as_unused' );
			$ui_visible    = ! $marked_unused;

			if ( ! $include_all && ! $ui_visible ) {
				continue;
			}

			$rows[] = [
				'id'             => $t->ID,
				'title'          => $t->post_title,
				'is_default'     => $is_default,
				'enabled'        => $is_enabled,
				'marked_unused'  => $marked_unused,
				'header_id'      => (int) get_post_meta( $t->ID, '_et_header_layout_id', true ),
				'body_id'        => (int) get_post_meta( $t->ID, '_et_body_layout_id', true ),
				'footer_id'      => (int) get_post_meta( $t->ID, '_et_footer_layout_id', true ),
				'use_on'         => get_post_meta( $t->ID, '_et_use_on', false ),
				'exclude_from'   => get_post_meta( $t->ID, '_et_exclude_from', false ),
				'ui_visible'     => $ui_visible,
			];
		}

		// Default template first, then by ID desc (most recent first).
		usort( $rows, function ( $a, $b ) {
			if ( $a['is_default'] !== $b['is_default'] ) {
				return $a['is_default'] ? -1 : 1;
			}
			return $b['id'] - $a['id'];
		} );

		return [
			'available'        => true,
			'templates'        => $rows,
			'total_in_db'      => count( $templates ),
			'returned'         => count( $rows ),
			'include_all'      => $include_all,
		];
	}

	/**
	 * Create a Theme Builder template.
	 *
	 * Divi 5 path: chain the official REST controller (create → update → assign),
	 * which guarantees the template appears in the Theme Builder UI.
	 * Divi 4 fallback: full meta wiring against et_template + et_theme_builder.
	 */
	public static function template_create( array $args ) {
		if ( ! post_type_exists( self::TB_TEMPLATE ) ) {
			return new WP_Error( 'no_cpt', 'et_template post type not registered. Is Divi active?' );
		}

		// Divi 5 path — delegate to OutsideVbController.
		if ( PCD_PC_Builder::is_divi5() ) {
			$ready = self::require_divi5_controller();
			if ( is_wp_error( $ready ) ) {
				return $ready;
			}
			return self::template_create_via_divi5( $args );
		}

		$title      = (string) ( $args['title'] ?? '' );
		$is_default = ! empty( $args['is_default'] );
		$enabled    = isset( $args['enabled'] ) ? (bool) $args['enabled'] : true;
		$header_id  = (int) ( $args['header_id'] ?? 0 );
		$body_id    = (int) ( $args['body_id'] ?? 0 );
		$footer_id  = (int) ( $args['footer_id'] ?? 0 );
		$use_on     = is_array( $args['use_on'] ?? null ) ? array_map( 'sanitize_text_field', $args['use_on'] ) : [];
		$exclude    = is_array( $args['exclude_from'] ?? null ) ? array_map( 'sanitize_text_field', $args['exclude_from'] ) : [];

		// Validate layout IDs reference real, published layout posts of the right type.
		$validate = function ( int $id, string $expected_type ) {
			if ( ! $id ) {
				return 0;
			}
			$post = get_post( $id );
			if ( ! $post || $post->post_type !== $expected_type || 'publish' !== $post->post_status ) {
				return new WP_Error( 'bad_layout_id', "Post #{$id} is not a published {$expected_type}." );
			}
			return $id;
		};

		$h = $validate( $header_id, self::TB_HEADER );
		if ( is_wp_error( $h ) ) return $h;
		$b = $validate( $body_id, self::TB_BODY );
		if ( is_wp_error( $b ) ) return $b;
		$f = $validate( $footer_id, self::TB_FOOTER );
		if ( is_wp_error( $f ) ) return $f;

		// If marking as default, demote any other default first (Divi only allows one).
		if ( $is_default ) {
			$existing_defaults = get_posts( [
				'post_type'      => self::TB_TEMPLATE,
				'post_status'    => 'publish',
				'meta_key'       => '_et_default', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- targeted lookup of the ET default-template flag; tiny dataset.
				'meta_value'     => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- targeted lookup of the ET default-template flag; tiny dataset.
				'posts_per_page' => -1,
				'fields'         => 'ids',
			] );
			foreach ( $existing_defaults as $eid ) {
				PCD_PC_Snapshot::capture( 'meta', "{$eid}:_et_default", [
					'post_id' => $eid,
					'key'     => '_et_default',
					'value'   => '1',
				], "Existing default template #{$eid}" );
				update_post_meta( $eid, '_et_default', '0' );
			}
		}

		$post_id = wp_insert_post( [
			'post_type'   => self::TB_TEMPLATE,
			'post_status' => 'publish',
			'post_title'  => $title ?: 'New Template',
		], true );
		if ( is_wp_error( $post_id ) ) {
			return $post_id;
		}

		$meta = [
			'_et_autogenerated_title'   => '0',
			'_et_default'               => $is_default ? '1' : '0',
			'_et_enabled'               => $enabled ? '1' : '0',
			'_et_header_layout_id'      => $h,
			'_et_header_layout_enabled' => $h ? '1' : '0',
			'_et_body_layout_id'        => $b,
			'_et_body_layout_enabled'   => $b ? '1' : '0',
			'_et_footer_layout_id'      => $f,
			'_et_footer_layout_enabled' => $f ? '1' : '0',
		];
		foreach ( $meta as $k => $v ) {
			update_post_meta( $post_id, $k, $v );
		}

		// Conditions — Divi stores each one as a separate meta row.
		foreach ( $use_on as $cond ) {
			add_post_meta( $post_id, '_et_use_on', $cond );
		}
		foreach ( $exclude as $cond ) {
			add_post_meta( $post_id, '_et_exclude_from', $cond );
		}

		// Register in the et_theme_builder post's template list — this is the
		// missing step that made earlier templates invisible to the UI.
		self::register_template_in_builder( $post_id );

		return [
			'id'         => $post_id,
			'title'      => get_the_title( $post_id ),
			'is_default' => $is_default,
			'enabled'    => $enabled,
			'header_id'  => $h,
			'body_id'    => $b,
			'footer_id'  => $f,
			'edit_url'   => admin_url( 'admin.php?page=et_theme_builder' ),
		];
	}

	/**
	 * Divi 5 path: chain create → update (for layouts/default/enabled) → assign (for conditions).
	 * All three controller methods are invoked directly so we don't need nonces, and so the
	 * controller's append_template_id + rollback logic handles UI registration for us.
	 */
	private static function template_create_via_divi5( array $args ) {
		$title    = (string) ( $args['title'] ?? '' );
		$live     = true; // We always operate on the live state from the API.
		$create   = self::invoke_divi5( 'create_template', [ 'live' => $live, 'title' => $title ] );

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

		$template_id = isset( $create['id'] ) ? (int) $create['id'] : 0;
		if ( ! $template_id ) {
			return new WP_Error( 'create_failed', 'Divi 5 controller did not return a template id.' );
		}

		// Build the update payload only if the caller asked for layout/default/enabled changes.
		$is_default = isset( $args['is_default'] ) ? (bool) $args['is_default'] : false;
		$enabled    = isset( $args['enabled'] ) ? (bool) $args['enabled'] : true;
		$header_id  = (int) ( $args['header_id'] ?? 0 );
		$body_id    = (int) ( $args['body_id'] ?? 0 );
		$footer_id  = (int) ( $args['footer_id'] ?? 0 );

		$needs_update = $is_default || ! $enabled || $header_id || $body_id || $footer_id;
		if ( $needs_update ) {
			$update = self::invoke_divi5( 'update_template', [
				'live'        => $live,
				'template_id' => $template_id,
				'template'    => [
					'default' => $is_default ? '1' : '0',
					'enabled' => $enabled ? '1' : '0',
					'layouts' => [
						'header' => [ 'id' => $header_id, 'enabled' => true ],
						'body'   => [ 'id' => $body_id,   'enabled' => true ],
						'footer' => [ 'id' => $footer_id, 'enabled' => true ],
					],
				],
			] );
			if ( is_wp_error( $update ) ) {
				return $update;
			}
		}

		// Conditions via assign_template if provided.
		$use_on  = is_array( $args['use_on'] ?? null ) ? array_map( 'sanitize_text_field', $args['use_on'] ) : null;
		$exclude = is_array( $args['exclude_from'] ?? null ) ? array_map( 'sanitize_text_field', $args['exclude_from'] ) : null;

		if ( null !== $use_on || null !== $exclude ) {
			$assign_params = [ 'live' => $live, 'template_id' => $template_id ];
			if ( null !== $use_on ) {
				$assign_params['use_on'] = $use_on;
			}
			if ( null !== $exclude ) {
				$assign_params['exclude_from'] = $exclude;
			}
			$assign = self::invoke_divi5( 'assign_template', $assign_params );
			if ( is_wp_error( $assign ) ) {
				return $assign;
			}
		}

		// Return the latest template state.
		$final = self::invoke_divi5( 'list_templates', [ 'live' => true ] );
		$final_template = null;
		if ( ! is_wp_error( $final ) && isset( $final['templates'] ) && is_array( $final['templates'] ) ) {
			foreach ( $final['templates'] as $t ) {
				if ( (int) ( $t['id'] ?? 0 ) === $template_id ) {
					$final_template = $t;
					break;
				}
			}
		}

		return [
			'source'           => 'divi5_controller',
			'id'               => $template_id,
			'title'            => $title,
			'theme_builder_id' => $create['themeBuilderId'] ?? null,
			'is_default'       => $is_default,
			'enabled'          => $enabled,
			'header_id'        => $header_id,
			'body_id'          => $body_id,
			'footer_id'        => $footer_id,
			'template'         => $final_template,
			'edit_url'         => admin_url( 'admin.php?page=et_theme_builder' ),
		];
	}

	public static function template_update( array $args ) {
		$id = (int) ( $args['id'] ?? 0 );
		if ( ! $id ) {
			return new WP_Error( 'no_id', 'Template id required.' );
		}
		$post = get_post( $id );
		if ( ! $post || $post->post_type !== self::TB_TEMPLATE ) {
			return new WP_Error( 'not_found', "Post #{$id} is not an et_template." );
		}

		if ( PCD_PC_Builder::is_divi4() ) {
			return self::template_update_divi4( $id, $args );
		}
		$ready = self::require_divi5_controller();
		if ( is_wp_error( $ready ) ) {
			return $ready;
		}

		// Build only the fields the caller supplied.
		$payload = [];
		if ( isset( $args['title'] ) )      $payload['title']   = (string) $args['title'];
		if ( isset( $args['is_default'] ) ) $payload['default'] = ! empty( $args['is_default'] ) ? '1' : '0';
		if ( isset( $args['enabled'] ) )    $payload['enabled'] = ! empty( $args['enabled'] ) ? '1' : '0';

		$layouts = [];
		if ( isset( $args['header_id'] ) ) {
			$layouts['header'] = [ 'id' => (int) $args['header_id'], 'enabled' => true ];
		}
		if ( isset( $args['body_id'] ) ) {
			$layouts['body'] = [ 'id' => (int) $args['body_id'], 'enabled' => true ];
		}
		if ( isset( $args['footer_id'] ) ) {
			$layouts['footer'] = [ 'id' => (int) $args['footer_id'], 'enabled' => true ];
		}
		if ( $layouts ) {
			$payload['layouts'] = $layouts;
		}

		if ( ! $payload ) {
			return new WP_Error( 'no_changes', 'No update fields supplied.' );
		}

		// Snapshot before the change.
		PCD_PC_Snapshot::capture( 'post', (string) $id, [
			'ID'           => $id,
			'post_title'   => $post->post_title,
			'post_content' => $post->post_content,
			'post_status'  => $post->post_status,
		], "Template #{$id} before update" );

		$result = self::invoke_divi5( 'update_template', [
			'live'        => true,
			'template_id' => $id,
			'template'    => $payload,
		] );
		if ( is_wp_error( $result ) ) {
			return $result;
		}
		return [ 'id' => $id, 'updated' => true, 'template' => $result['template'] ?? null ];
	}

	public static function template_assign( array $args ) {
		$id = (int) ( $args['id'] ?? 0 );
		if ( ! $id ) {
			return new WP_Error( 'no_id', 'Template id required.' );
		}

		if ( PCD_PC_Builder::is_divi4() ) {
			return self::template_assign_divi4( $id, $args );
		}
		$ready = self::require_divi5_controller();
		if ( is_wp_error( $ready ) ) {
			return $ready;
		}

		$params = [ 'live' => true, 'template_id' => $id ];
		if ( isset( $args['use_on'] ) && is_array( $args['use_on'] ) ) {
			$params['use_on'] = array_map( 'sanitize_text_field', $args['use_on'] );
		}
		if ( isset( $args['exclude_from'] ) && is_array( $args['exclude_from'] ) ) {
			$params['exclude_from'] = array_map( 'sanitize_text_field', $args['exclude_from'] );
		}

		$result = self::invoke_divi5( 'assign_template', $params );
		if ( is_wp_error( $result ) ) {
			return $result;
		}
		return [ 'id' => $id, 'assigned' => true, 'template' => $result['template'] ?? null ];
	}

	public static function template_delete( array $args ) {
		$id = (int) ( $args['id'] ?? 0 );
		if ( ! $id ) {
			return new WP_Error( 'no_id', 'Template id required.' );
		}
		$post = get_post( $id );
		if ( ! $post || $post->post_type !== self::TB_TEMPLATE ) {
			return new WP_Error( 'not_found', "Post #{$id} is not an et_template." );
		}

		// Snapshot the meta + content before deleting so a restore can put it back.
		PCD_PC_Snapshot::capture( 'post', (string) $id, [
			'ID'           => $id,
			'post_title'   => $post->post_title,
			'post_content' => $post->post_content,
			'post_status'  => $post->post_status,
		], "Template #{$id} before delete" );

		// Divi 5 path — delegate to the official controller, which handles detach + rollback.
		if ( PCD_PC_Builder::is_divi5() ) {
			$ready = self::require_divi5_controller();
			if ( is_wp_error( $ready ) ) {
				return $ready;
			}
			$result = self::invoke_divi5( 'delete_template', [
				'live'        => true,
				'template_id' => $id,
			] );
			if ( is_wp_error( $result ) ) {
				return $result;
			}
			return [ 'id' => $id, 'action' => 'trashed', 'source' => 'divi5_controller' ];
		}

		// Divi 4 fallback.
		self::unregister_template_from_builder( $id );

		$force  = ! empty( $args['force'] );
		$result = $force ? wp_delete_post( $id, true ) : wp_trash_post( $id );
		if ( ! $result ) {
			return new WP_Error( 'delete_failed', 'Could not delete template.' );
		}
		return [ 'id' => $id, 'action' => $force ? 'deleted' : 'trashed' ];
	}

	public static function layout_list( array $args ): array {
		$type_map = [
			'header'   => self::TB_HEADER,
			'footer'   => self::TB_FOOTER,
			'body'     => self::TB_BODY,
			'template' => self::TB_TEMPLATE,
		];
		$type = $args['type'] ?? 'body';
		$cpt  = $type_map[ $type ] ?? self::TB_BODY;

		if ( ! post_type_exists( $cpt ) ) {
			return [ 'available' => false, 'reason' => "{$cpt} post type not registered. Is Divi active?" ];
		}

		$posts = get_posts( [
			'post_type'      => $cpt,
			'post_status'    => [ 'publish', 'draft' ],
			'posts_per_page' => -1,
		] );

		return [
			'available' => true,
			'type'      => $type,
			'layouts'   => array_map( function ( $p ) {
				return [ 'id' => $p->ID, 'title' => $p->post_title, 'status' => $p->post_status ];
			}, $posts ),
		];
	}

	public static function layout_get( array $args ) {
		$post = get_post( (int) $args['id'] );
		if ( ! $post ) {
			return new WP_Error( 'not_found', "Layout not found: {$args['id']}" );
		}
		if ( ! in_array( $post->post_type, [ self::TB_HEADER, self::TB_FOOTER, self::TB_BODY, self::TB_TEMPLATE ], true ) ) {
			return new WP_Error( 'wrong_type', "Post #{$post->ID} is not a Divi layout (type: {$post->post_type})." );
		}
		return [
			'id'      => $post->ID,
			'title'   => $post->post_title,
			'type'    => $post->post_type,
			'status'  => $post->post_status,
			'content' => $post->post_content,
		];
	}

	public static function layout_repair( array $args ) {
		$id = (int) ( $args['id'] ?? 0 );
		$post = $id ? get_post( $id ) : null;
		if ( ! $post ) {
			return new WP_Error( 'not_found', "Post #{$id} not found." );
		}
		$valid_types = [ self::TB_HEADER, self::TB_FOOTER, self::TB_BODY ];
		if ( ! in_array( $post->post_type, $valid_types, true ) ) {
			return new WP_Error( 'wrong_type', "Post #{$id} is post_type '{$post->post_type}' — not a Divi layout." );
		}

		$applied = [];

		// 1. Layout taxonomy term.
		if ( taxonomy_exists( 'layout_type' ) ) {
			$terms = wp_get_object_terms( $id, 'layout_type', [ 'fields' => 'slugs' ] );
			if ( is_wp_error( $terms ) || ! in_array( 'layout', $terms, true ) ) {
				wp_set_object_terms( $id, 'layout', 'layout_type', true );
				$applied[] = 'layout_type:layout term added';
			}
		}

		// 2. Builder meta — prefer Divi's helper, fall back to manual.
		$pre_use = get_post_meta( $id, '_et_pb_use_builder', true );
		$pre_for = get_post_meta( $id, '_et_pb_built_for_post_type', true );
		if ( function_exists( 'et_builder_enable_for_post' ) ) {
			et_builder_enable_for_post( $id );
		} else {
			update_post_meta( $id, '_et_pb_use_builder', 'on' );
			update_post_meta( $id, '_et_pb_built_for_post_type', $post->post_type );
		}
		if ( 'on' !== $pre_use ) {
			$applied[] = '_et_pb_use_builder=on';
		}
		if ( $post->post_type !== $pre_for ) {
			$applied[] = "_et_pb_built_for_post_type={$post->post_type}";
		}

		return [
			'id'         => $id,
			'post_type'  => $post->post_type,
			'title'      => $post->post_title,
			'applied'    => $applied,
			'edit_url'   => admin_url( "post.php?post={$id}&action=edit" ),
			'builder_url'=> admin_url( "post.php?post={$id}&action=edit&et_fb=1" ),
		];
	}

	public static function layout_save( array $args ) {
		$content_ok = self::validate_content_generation( (string) ( $args['content'] ?? '' ) );
		if ( is_wp_error( $content_ok ) ) {
			return $content_ok;
		}

		$type_map = [
			'header'   => self::TB_HEADER,
			'footer'   => self::TB_FOOTER,
			'body'     => self::TB_BODY,
			'template' => self::TB_TEMPLATE,
		];
		$cpt = $type_map[ $args['type'] ] ?? null;
		if ( ! $cpt ) {
			return new WP_Error( 'bad_type', "Unknown layout type: {$args['type']}" );
		}
		if ( ! post_type_exists( $cpt ) ) {
			return new WP_Error( 'no_cpt', "{$cpt} post type not registered. Is Divi active?" );
		}

		// Update path.
		if ( ! empty( $args['id'] ) ) {
			$id   = (int) $args['id'];
			$post = get_post( $id );
			if ( ! $post || $post->post_type !== $cpt ) {
				return new WP_Error( 'not_found', "Layout #{$id} not found or wrong type." );
			}
			$snapshot_id = PCD_PC_Snapshot::capture( 'post', (string) $id, [
				'ID'           => $id,
				'post_title'   => $post->post_title,
				'post_content' => $post->post_content,
				'post_status'  => $post->post_status,
			], "Divi {$args['type']} #{$id} before save" );

			wp_update_post( [
				'ID'           => $id,
				'post_title'   => wp_slash( (string) $args['title'] ),
				'post_content' => wp_slash( (string) $args['content'] ),
			] );

			self::flush_static_css();
			return [ 'id' => $id, 'type' => $args['type'], 'snapshot_id' => $snapshot_id, 'action' => 'updated' ];
		}

		// Create path — use Divi's own helper when available so all the meta
		// the Visual Builder needs is wired up (_et_pb_use_builder, layout_type
		// taxonomy term, etc.). Without those, the layout post exists but the
		// builder shows "An unknown error has occurred" when you try to edit it.
		if ( function_exists( 'et_theme_builder_insert_layout' ) ) {
			$id = et_theme_builder_insert_layout( [
				'post_type'    => $cpt,
				'post_title'   => $args['title'],
				'post_content' => wp_slash( (string) $args['content'] ),
			] );
		} else {
			$id = wp_insert_post( [
				'post_type'    => $cpt,
				'post_status'  => 'publish',
				'post_title'   => wp_slash( (string) $args['title'] ),
				'post_content' => wp_slash( (string) $args['content'] ),
			], true );
			if ( ! is_wp_error( $id ) ) {
				// Replicate the minimum that et_theme_builder_insert_layout does.
				if ( taxonomy_exists( 'layout_type' ) ) {
					wp_set_object_terms( $id, 'layout', 'layout_type', true );
				}
				if ( function_exists( 'et_builder_enable_for_post' ) ) {
					et_builder_enable_for_post( $id );
				} else {
					update_post_meta( $id, '_et_pb_use_builder', 'on' );
					update_post_meta( $id, '_et_pb_built_for_post_type', $cpt );
				}
			}
		}

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

		self::flush_static_css();
		return [ 'id' => $id, 'type' => $args['type'], 'action' => 'created' ];
	}

	public static function global_template_get( array $args ): array {
		return [
			'et_theme_builder_default_template' => get_option( 'et_theme_builder_default_template', null ),
		];
	}

	public static function global_template_set( array $args ) {
		$prior = get_option( 'et_theme_builder_default_template', null );

		// Find or create the default template.
		$tmpl_id = self::find_or_create_default_template();
		if ( is_wp_error( $tmpl_id ) ) {
			return $tmpl_id;
		}

		$snapshot_id = PCD_PC_Snapshot::capture( 'option', 'et_theme_builder_default_template', $prior, 'Default template before update' );

		$assignments = [];
		foreach ( [ 'header_id', 'body_id', 'footer_id' ] as $f ) {
			if ( isset( $args[ $f ] ) ) {
				$meta_key = '_et_' . str_replace( '_id', '', $f ) . '_layout_id';
				$prior_meta = get_post_meta( $tmpl_id, $meta_key, true );
				PCD_PC_Snapshot::capture( 'meta', "{$tmpl_id}:{$meta_key}", [
					'post_id' => $tmpl_id,
					'key'     => $meta_key,
					'value'   => $prior_meta,
				], "Template meta {$meta_key}" );
				update_post_meta( $tmpl_id, $meta_key, (int) $args[ $f ] );
				$assignments[ $f ] = (int) $args[ $f ];
			}
		}

		// Persist the assignment in the option Divi reads.
		$option_value = is_array( $prior ) ? $prior : [];
		$option_value['default_template_id'] = $tmpl_id;
		update_option( 'et_theme_builder_default_template', $option_value );

		return [
			'template_id' => $tmpl_id,
			'assigned'    => $assignments,
			'snapshot_id' => $snapshot_id,
		];
	}

	private static function find_or_create_default_template() {
		$existing = get_posts( [
			'post_type'      => self::TB_TEMPLATE,
			'meta_key'       => '_et_default', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- targeted lookup of the ET default-template flag; tiny dataset.
			'meta_value'     => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- targeted lookup of the ET default-template flag; tiny dataset.
			'posts_per_page' => 1,
			'fields'         => 'ids',
		] );
		if ( ! empty( $existing[0] ) ) {
			return (int) $existing[0];
		}

		$id = wp_insert_post( [
			'post_type'   => self::TB_TEMPLATE,
			'post_status' => 'publish',
			'post_title'  => 'Default Template',
		], true );
		if ( is_wp_error( $id ) ) {
			return $id;
		}
		update_post_meta( $id, '_et_default', '1' );
		return (int) $id;
	}

	public static function clear_static_css( array $args ): array {
		if ( class_exists( 'ET_Core_PageResource' ) ) {
			\ET_Core_PageResource::remove_static_resources( 'all', 'all' );
			return [ 'cleared' => true, 'method' => 'ET_Core_PageResource' ];
		}
		// Manual fallback.
		$cache = WP_CONTENT_DIR . '/et-cache';
		$count = 0;
		if ( is_dir( $cache ) ) {
			foreach ( glob( $cache . '/*' ) as $f ) {
				if ( is_file( $f ) ) {
					wp_delete_file( $f );
					$count++;
				}
			}
		}
		return [ 'cleared' => $count > 0, 'files_removed' => $count, 'method' => 'manual' ];
	}

	/**
	 * Invalidate Divi's compiled static CSS after a programmatic layout write.
	 *
	 * Divi compiles per-page/per-layout CSS into wp-content/et-cache and serves
	 * the cached file until it is removed. Without this, content/layout changes
	 * made through the API appear to "not take" — the page keeps rendering with
	 * the old (or missing) static CSS, which also makes a freshly-created page
	 * look blank/unstyled until the cache is rebuilt. The Visual Builder clears
	 * this on every save; we mirror that on every write so edits show up
	 * immediately. Fire-and-forget — never block or fail a write on it.
	 */
	private static function flush_static_css(): void {
		try {
			self::clear_static_css( [] );
		} catch ( \Throwable $e ) {
			// Cache flush is best-effort; the write itself already succeeded.
		}
	}

	public static function global_colors_get( array $args ): array {
		return [
			'et_global_colors'  => get_option( 'et_global_colors', null ),
			'et_divi_settings'  => get_option( 'et_divi', null ),
		];
	}

	/**
	 * Turn the Divi builder ON for a post so the block editor shows
	 * "This Layout Is Built With Divi" and the Visual Builder opens the layout
	 * (instead of the "Use The Divi Builder" prompt). Prefers Divi's own enable
	 * routine — which wires the same housekeeping meta on both Divi 4 and Divi 5
	 * — then asserts the canonical flags directly so it still works if that
	 * function is unavailable. Idempotent.
	 */
	private static function enable_divi_builder_meta( int $post_id ): void {
		$post      = get_post( $post_id );
		$post_type = $post ? $post->post_type : 'page';
		if ( function_exists( 'et_builder_enable_for_post' ) ) {
			et_builder_enable_for_post( $post_id, false );
		}
		update_post_meta( $post_id, '_et_pb_use_builder', 'on' );
		update_post_meta( $post_id, '_et_pb_built_for_post_type', $post_type );

		// Divi 5 ONLY. A page authored via the API is native blocks, but without
		// the FULL set of state a real Visual Builder save writes, WordPress opens
		// it in the block-editor fallback ("block not supported") and forces a
		// lossy "Use The Divi Builder" import that strips module attributes.
		// Reproducing the whole bundle — use_divi_5 flag + off-canvas registration
		// + the page-settings defaults — makes the page open straight into the
		// Divi 5 editor. Idempotent; must NOT run on Divi 4, which has none of these.
		if ( 'divi5' === PCD_PC_Builder::divi_generation() ) {
			update_post_meta( $post_id, '_et_pb_use_divi_5', 'on' );

			if ( ! get_post_meta( $post_id, '_divi_off_canvas_data', true ) ) {
				update_post_meta( $post_id, '_divi_off_canvas_data', [
					'activeCanvasId' => wp_generate_uuid4(),
					'mainCanvasName' => 'Main Canvas',
				] );
			}

			// Page-settings defaults a VB save stamps. Set only when absent so a
			// later real save / re-run never clobbers genuine values.
			$d5_page_defaults = [
				'_et_pb_post_hide_nav'                 => 'default',
				'_et_pb_side_nav'                      => 'off',
				'_et_pb_content_area_background_color'  => '',
				'_et_pb_enable_shortcode_tracking'     => 'off',
				'_et_pb_page_z_index'                  => 'auto',
				'_et_pb_section_background_color'       => '',
				'_et_pb_custom_css'                    => '',
				'_et_pb_page_gutter_width_is_default'  => '1',
				'_et_pb_post_settings_excerpt'         => '',
				'_et_pb_first_image'                   => '',
				'_et_pb_truncate_post'                 => '',
				'_et_pb_truncate_post_date'            => '',
			];
			foreach ( $d5_page_defaults as $mk => $mv ) {
				if ( ! metadata_exists( 'post', $post_id, $mk ) ) {
					update_post_meta( $post_id, $mk, $mv );
				}
			}
			if ( ! get_post_meta( $post_id, '_et_pb_post_settings_title', true ) ) {
				update_post_meta( $post_id, '_et_pb_post_settings_title', get_the_title( $post_id ) );
			}
		}
	}

	/**
	 * Divi 5 keeps native blocks in post_content, but a page only opens directly
	 * in the Divi 5 editor (instead of the block-editor "block not supported"
	 * fallback) when that content is wrapped in a divi/placeholder block — the
	 * same wrapper a Visual Builder save produces. Idempotent; Divi 5 only.
	 */
	private static function wrap_in_divi5_placeholder( int $post_id ): void {
		if ( 'divi5' !== PCD_PC_Builder::divi_generation() ) {
			return;
		}
		$post = get_post( $post_id );
		if ( ! $post ) {
			return;
		}
		$content = (string) $post->post_content;
		if ( '' === trim( $content ) || false !== strpos( $content, '<!-- wp:divi/placeholder' ) ) {
			return;
		}
		$wrapped = "<!-- wp:divi/placeholder -->\n" . $content . "\n<!-- /wp:divi/placeholder -->";
		wp_update_post( [ 'ID' => $post_id, 'post_content' => wp_slash( $wrapped ) ] );
	}

	public static function enable_builder( array $args ): array {
		$post_id = (int) $args['post_id'];
		$prior   = get_post_meta( $post_id, '_et_pb_use_builder', true );
		PCD_PC_Snapshot::capture( 'meta', "{$post_id}:_et_pb_use_builder", [
			'post_id' => $post_id,
			'key'     => '_et_pb_use_builder',
			'value'   => $prior,
		], "Builder toggle on #{$post_id}" );
		self::enable_divi_builder_meta( $post_id );
		return [ 'post_id' => $post_id, 'enabled' => true ];
	}

	// ------------------------------------------------------------------
	// Divi 5 OutsideVbController wrappers — set_post_layout, options, export
	// ------------------------------------------------------------------

	// ------------------------------------------------------------------
	// Chunked layout upload
	// ------------------------------------------------------------------

	const LAYOUT_BUFFER_PREFIX = 'pcd_pc_layout_buf_';
	const LAYOUT_BUFFER_TTL    = 3600; // 1 hour

	public static function layout_buffer_start( array $args ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		if ( ! $post_id ) {
			return new WP_Error( 'no_post', 'post_id is required.' );
		}
		if ( ! get_post( $post_id ) ) {
			return new WP_Error( 'no_post', "Post #{$post_id} not found." );
		}
		$key = self::LAYOUT_BUFFER_PREFIX . $post_id;
		delete_transient( $key );
		set_transient( $key, '', self::LAYOUT_BUFFER_TTL );
		return [
			'post_id'        => $post_id,
			'buffer_started' => true,
			'ttl_seconds'    => self::LAYOUT_BUFFER_TTL,
		];
	}

	public static function layout_buffer_append( array $args ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		$chunk   = (string) ( $args['chunk'] ?? '' );
		if ( ! $post_id ) {
			return new WP_Error( 'no_post', 'post_id is required.' );
		}
		if ( '' === $chunk ) {
			return new WP_Error( 'empty_chunk', 'chunk is empty.' );
		}
		$key     = self::LAYOUT_BUFFER_PREFIX . $post_id;
		$current = get_transient( $key );
		if ( false === $current ) {
			return new WP_Error( 'no_buffer', 'No buffer started for this post_id — call divi_layout_buffer_start first.' );
		}
		$next = $current . $chunk;
		set_transient( $key, $next, self::LAYOUT_BUFFER_TTL );
		return [
			'post_id'    => $post_id,
			'chunk_size' => strlen( $chunk ),
			'total_size' => strlen( $next ),
		];
	}

	public static function layout_buffer_commit( array $args ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		if ( ! $post_id ) {
			return new WP_Error( 'no_post', 'post_id is required.' );
		}
		$key     = self::LAYOUT_BUFFER_PREFIX . $post_id;
		$content = get_transient( $key );
		if ( false === $content || '' === $content ) {
			return new WP_Error( 'empty_buffer', 'Buffer is empty — append content before committing.' );
		}

		$result = self::set_post_layout( [
			'post_id'        => $post_id,
			'layout_content' => $content,
		] );
		if ( is_wp_error( $result ) ) {
			return $result; // leave buffer intact so the caller can retry
		}

		// Count modules (D5 blocks or D4 shortcodes) for the response.
		$module_count = preg_match_all( '/<!-- wp:divi\/[\w-]+/', $content )
			+ preg_match_all( '/\[et_pb_[a-z_]+/i', $content );

		// Clear the buffer only if the apply succeeded.
		if ( ! empty( $result['applied'] ) || ! empty( $result['updated'] ) ) {
			delete_transient( $key );
		}

		return array_merge( (array) $result, [
			'total_size' => strlen( $content ),
			'modules'    => $module_count,
		] );
	}

	public static function set_post_layout( array $args ) {
		$post_id   = (int) ( $args['post_id'] ?? 0 );
		$source_id = (int) ( $args['source_layout_post_id'] ?? 0 );
		$content   = $args['layout_content'] ?? null;

		if ( ! get_post( $post_id ) ) {
			return new WP_Error( 'no_post', "Post #{$post_id} not found." );
		}

		$resolved_content = null;
		if ( $source_id ) {
			$source_post = get_post( $source_id );
			if ( ! $source_post ) {
				return new WP_Error( 'no_source', "Source #{$source_id} not found." );
			}
			$resolved_content = (string) $source_post->post_content;
		} elseif ( null !== $content ) {
			$resolved_content = (string) $content;
		} else {
			return new WP_Error( 'no_source', 'Provide source_layout_post_id or layout_content.' );
		}

		$content_ok = self::validate_content_generation( $resolved_content );
		if ( is_wp_error( $content_ok ) ) {
			return $content_ok;
		}

		// Snapshot prior content + builder flag for undo.
		$post = get_post( $post_id );
		PCD_PC_Snapshot::capture( 'post', (string) $post_id, [
			'ID'           => $post_id,
			'post_content' => $post->post_content,
		], "Post #{$post_id} content before set_post_layout" );

		if ( PCD_PC_Builder::is_divi5() ) {
			$ready = self::require_divi5_controller();
			if ( is_wp_error( $ready ) ) {
				return $ready;
			}
			$params = [ 'post_id' => $post_id ];
			if ( $source_id ) {
				$params['source_layout_post_id'] = $source_id;
			} elseif ( null !== $content ) {
				$params['layout_content'] = (string) $content;
			} else {
				return new WP_Error( 'no_source', 'Provide source_layout_post_id or layout_content.' );
			}
			$result = self::invoke_divi5( 'set_post_layout', $params );
			if ( is_wp_error( $result ) ) {
				return $result;
			}
			// The D5 controller stores the layout but does NOT flip the builder
			// toggle. Without this the page shows "Use The Divi Builder" and the
			// Visual Builder opens empty even though content exists. Enable it.
			self::enable_divi_builder_meta( $post_id );
			self::wrap_in_divi5_placeholder( $post_id );
			self::flush_static_css();
			return [ 'post_id' => $post_id, 'applied' => true, 'source' => 'divi5_controller' ];
		}

		// Divi 4 fallback — straight content write + builder flag.
		wp_update_post( [ 'ID' => $post_id, 'post_content' => wp_slash( $resolved_content ) ] );
		self::enable_divi_builder_meta( $post_id );
		self::flush_static_css();
		return [ 'post_id' => $post_id, 'applied' => true, 'source' => 'fallback' ];
	}

	public static function options_get( array $args ) {
		if ( PCD_PC_Builder::is_divi5() ) {
			$ready = self::require_divi5_controller();
			if ( is_wp_error( $ready ) ) {
				return $ready;
			}
			$params = [];
			if ( isset( $args['keys'] ) && is_array( $args['keys'] ) ) {
				$params['keys'] = array_map( 'sanitize_text_field', $args['keys'] );
			}
			return self::invoke_divi5( 'get_theme_options', $params );
		}

		// Divi 4 fallback — same allowlist Divi 5 uses, via et_get_option.
		if ( ! function_exists( 'et_get_option' ) ) {
			return new WP_Error( 'no_divi', 'Divi is not active.' );
		}
		$keys = ( isset( $args['keys'] ) && is_array( $args['keys'] ) )
			? array_intersect( array_map( 'sanitize_text_field', $args['keys'] ), self::THEME_OPTION_ALLOWLIST )
			: self::THEME_OPTION_ALLOWLIST;

		$out = [];
		foreach ( $keys as $k ) {
			$out[ $k ] = et_get_option( $k, '', '', false );
		}
		return [ 'options' => $out, 'source' => 'divi4_fallback' ];
	}

	public static function options_set( array $args ) {
		$key   = (string) ( $args['key'] ?? '' );
		$value = (string) ( $args['value'] ?? '' );
		if ( ! $key ) return new WP_Error( 'no_key', 'key is required.' );

		if ( PCD_PC_Builder::is_divi5() ) {
			$ready = self::require_divi5_controller();
			if ( is_wp_error( $ready ) ) {
				return $ready;
			}
			$prior = self::invoke_divi5( 'get_theme_options', [ 'keys' => [ $key ] ] );
			if ( ! is_wp_error( $prior ) && isset( $prior['options'][ $key ] ) ) {
				PCD_PC_Snapshot::capture( 'option', "divi_theme_option:{$key}", $prior['options'][ $key ], "Divi option {$key} before update" );
			}
			return self::invoke_divi5( 'update_theme_option', [ 'key' => $key, 'value' => $value ] );
		}

		// Divi 4 fallback.
		if ( ! function_exists( 'et_update_option' ) ) {
			return new WP_Error( 'no_divi', 'Divi is not active.' );
		}
		if ( ! in_array( $key, self::THEME_OPTION_ALLOWLIST, true ) ) {
			return new WP_Error( 'not_allowed', "Option not allowlisted: {$key}" );
		}
		$prior = et_get_option( $key, '', '', false );
		PCD_PC_Snapshot::capture( 'option', "divi_theme_option:{$key}", $prior, "Divi option {$key} before update (Divi 4)" );
		et_update_option( $key, $value );
		$stored = et_get_option( $key, '', '', false );
		return [ 'key' => $key, 'value' => $stored, 'source' => 'divi4_fallback' ];
	}

	public static function layout_export( array $args ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		if ( ! get_post( $post_id ) ) {
			return new WP_Error( 'no_post', "Post #{$post_id} not found." );
		}
		if ( PCD_PC_Builder::is_divi5() ) {
			$ready = self::require_divi5_controller();
			if ( is_wp_error( $ready ) ) {
				return $ready;
			}
			return self::invoke_divi5( 'export_layout', [ 'post_id' => $post_id ] );
		}

		// Divi 4 fallback — produce the same structural export Divi's portability does:
		// post_content + meta + thumbnail URL. The full ET_Core_Portability JSON is private,
		// so we mirror its essentials.
		$post = get_post( $post_id );
		if ( ! $post ) return new WP_Error( 'no_post', 'Post not found.' );

		$meta_raw = get_post_meta( $post_id );
		$meta = [];
		foreach ( $meta_raw as $k => $v ) {
			$meta[ $k ] = is_array( $v ) && count( $v ) === 1 ? maybe_unserialize( $v[0] ) : array_map( 'maybe_unserialize', $v );
		}

		$thumb_id = get_post_thumbnail_id( $post_id );
		$thumb_url = $thumb_id ? wp_get_attachment_url( $thumb_id ) : null;

		// Best-effort: extract any image IDs referenced in Divi shortcode for portability.
		$image_refs = [];
		if ( preg_match_all( '/(?:src|image|url)="(https?:\/\/[^"]+\.(?:png|jpg|jpeg|gif|webp|svg))"/i', $post->post_content, $m ) ) {
			$image_refs = array_values( array_unique( $m[1] ) );
		}

		return [
			'source'          => 'divi4_fallback',
			'context'         => 'et_builder',
			'export_version'  => 'divi_conduit_v0.7',
			'data' => [
				$post_id => [
					'ID'             => $post->ID,
					'post_title'     => $post->post_title,
					'post_content'   => $post->post_content,
					'post_type'      => $post->post_type,
					'post_status'    => $post->post_status,
					'post_excerpt'   => $post->post_excerpt,
					'menu_order'     => $post->menu_order,
					'meta'           => $meta,
					'thumbnail_url'  => $thumb_url,
					'image_refs'     => $image_refs,
				],
			],
		];
	}

	// ------------------------------------------------------------------
	// Divi 4 fallbacks for template_update + template_assign
	// ------------------------------------------------------------------

	const THEME_OPTION_ALLOWLIST = [
		'divi_blog_style',
		'divi_disable_translations',
		'heading_font',
		'body_font',
		'heading_font_weight',
		'body_font_weight',
		'body_font_height',
		'body_font_size',
		'body_header_size',
		'content_width',
		'accent_color',
		'et_pb_static_css_file',
		'et_pb_css_in_footer',
		'gutter_width',
		'vertical_nav',
		'header_style',
		'color_schemes',
	];

	/**
	 * Build the template payload shape that et_theme_builder_store_template() expects,
	 * starting from the current template and merging in caller updates.
	 */
	private static function build_template_payload( int $id, array $overrides ): array {
		$current = function_exists( 'et_theme_builder_get_template' )
			? (array) et_theme_builder_get_template( $id )
			: [];

		$layouts = $current['layouts'] ?? [
			'header' => [ 'id' => 0, 'enabled' => true ],
			'body'   => [ 'id' => 0, 'enabled' => true ],
			'footer' => [ 'id' => 0, 'enabled' => true ],
		];

		// Apply layout overrides.
		if ( isset( $overrides['header_id'] ) ) $layouts['header']['id'] = (int) $overrides['header_id'];
		if ( isset( $overrides['body_id'] ) )   $layouts['body']['id']   = (int) $overrides['body_id'];
		if ( isset( $overrides['footer_id'] ) ) $layouts['footer']['id'] = (int) $overrides['footer_id'];

		// use_on / exclude_from overrides.
		$use_on  = $current['use_on']       ?? [];
		$exclude = $current['exclude_from'] ?? [];
		if ( isset( $overrides['use_on'] ) && is_array( $overrides['use_on'] ) ) {
			$use_on = array_map( 'sanitize_text_field', $overrides['use_on'] );
		}
		if ( isset( $overrides['exclude_from'] ) && is_array( $overrides['exclude_from'] ) ) {
			$exclude = array_map( 'sanitize_text_field', $overrides['exclude_from'] );
		}

		return [
			'id'                  => $id,
			'title'               => $overrides['title']   ?? ( $current['title'] ?? '' ),
			'autogenerated_title' => '0',
			'default'             => isset( $overrides['is_default'] ) ? ( $overrides['is_default'] ? '1' : '0' ) : ( ! empty( $current['default'] ) ? '1' : '0' ),
			'enabled'             => isset( $overrides['enabled'] )    ? ( $overrides['enabled'] ? '1' : '0' )    : ( ! empty( $current['enabled'] ) ? '1' : '0' ),
			'layouts'             => $layouts,
			'use_on'              => $use_on,
			'exclude_from'        => $exclude,
		];
	}

	private static function template_update_divi4( int $id, array $args ) {
		if ( ! function_exists( 'et_theme_builder_store_template' ) || ! function_exists( 'et_theme_builder_get_theme_builder_post_id' ) ) {
			return new WP_Error( 'no_divi', 'Divi theme builder functions not available.' );
		}

		// Snapshot prior state.
		$post = get_post( $id );
		PCD_PC_Snapshot::capture( 'post', (string) $id, [
			'ID'           => $id,
			'post_title'   => $post->post_title,
			'post_content' => $post->post_content,
			'post_status'  => $post->post_status,
		], "Template #{$id} before update (Divi 4)" );

		$theme_builder_id = (int) et_theme_builder_get_theme_builder_post_id( true, true );
		$payload          = self::build_template_payload( $id, $args );
		$result           = et_theme_builder_store_template( $theme_builder_id, $payload, true );

		if ( ! $result ) {
			return new WP_Error( 'update_failed', 'et_theme_builder_store_template returned false.' );
		}

		return [
			'id'       => (int) $result,
			'updated'  => true,
			'source'   => 'divi4_fallback',
		];
	}

	private static function template_assign_divi4( int $id, array $args ) {
		if ( ! function_exists( 'et_theme_builder_store_template' ) || ! function_exists( 'et_theme_builder_get_theme_builder_post_id' ) ) {
			return new WP_Error( 'no_divi', 'Divi theme builder functions not available.' );
		}

		PCD_PC_Snapshot::capture( 'meta', "{$id}:_et_use_on", [
			'post_id' => $id,
			'key'     => '_et_use_on',
			'value'   => get_post_meta( $id, '_et_use_on', false ),
		], "Template #{$id} conditions before assign (Divi 4)" );

		$theme_builder_id = (int) et_theme_builder_get_theme_builder_post_id( true, true );
		$payload          = self::build_template_payload( $id, $args );
		$result           = et_theme_builder_store_template( $theme_builder_id, $payload, true );

		if ( ! $result ) {
			return new WP_Error( 'assign_failed', 'et_theme_builder_store_template returned false.' );
		}

		return [
			'id'        => (int) $result,
			'assigned'  => true,
			'source'    => 'divi4_fallback',
		];
	}

	public static function create_page( array $args ) {
		return self::create_divi_post( 'page', $args );
	}

	public static function create_post( array $args ) {
		$args['page_layout'] = null; // not used for posts
		return self::create_divi_post( 'post', $args );
	}

	private static function create_divi_post( string $post_type, array $args ) {
		$title = trim( (string) ( $args['title'] ?? '' ) );
		if ( '' === $title ) {
			return new WP_Error( 'no_title', 'title is required.' );
		}

		// On Divi 5 sites the layout content has to go through the OutsideVb
		// controller, not wp_insert_post's post_content — otherwise the page
		// is created but Divi 5's storage layer doesn't know about the
		// content and subsequent edits via the visual builder / set_post_layout
		// see an empty layout. Defer non-empty content to after insert when D5
		// is active.
		$content       = (string) ( $args['content'] ?? '' );
		$content_ok    = self::validate_content_generation( $content );
		if ( is_wp_error( $content_ok ) ) {
			return $content_ok;
		}
		if ( PCD_PC_Builder::is_divi5() ) {
			$ready = self::require_divi5_controller();
			if ( is_wp_error( $ready ) ) {
				return $ready;
			}
		}
		$defer_content = PCD_PC_Builder::is_divi5() && '' !== trim( $content );

		// wp_insert_post unslashes input — slash text fields so backslashes
		// in Divi 5 block markup (e.g. <) survive intact.
		$insert = [
			'post_type'    => $post_type,
			'post_status'  => (string) ( $args['status'] ?? 'draft' ),
			'post_title'   => wp_slash( $title ),
			'post_content' => $defer_content ? '' : wp_slash( $content ),
			'post_author'  => get_current_user_id(),
		];
		if ( ! empty( $args['slug'] ) )      $insert['post_name']    = sanitize_title( $args['slug'] );
		if ( ! empty( $args['excerpt'] ) )   $insert['post_excerpt'] = wp_slash( (string) $args['excerpt'] );
		if ( ! empty( $args['parent_id'] ) ) $insert['post_parent']  = (int) $args['parent_id'];

		$post_id = wp_insert_post( $insert, true );
		if ( is_wp_error( $post_id ) ) {
			return $post_id;
		}

		// Enable Divi builder (use_builder flag, built_for_post_type, and Divi's
		// own housekeeping meta) so the page opens in the builder rather than the
		// "Use The Divi Builder" prompt.
		self::enable_divi_builder_meta( $post_id );

		if ( 'page' === $post_type && ! empty( $args['page_layout'] ) ) {
			update_post_meta( $post_id, '_et_pb_page_layout', sanitize_text_field( $args['page_layout'] ) );
		}

		// Convenience: featured image.
		if ( ! empty( $args['featured_image_id'] ) ) {
			set_post_thumbnail( $post_id, (int) $args['featured_image_id'] );
		}

		// Convenience: categories + tags for posts.
		if ( 'post' === $post_type ) {
			if ( ! empty( $args['categories'] ) && is_array( $args['categories'] ) ) {
				wp_set_post_categories( $post_id, array_map( 'intval', $args['categories'] ) );
			}
			if ( ! empty( $args['tags'] ) && is_array( $args['tags'] ) ) {
				wp_set_post_tags( $post_id, array_map( 'sanitize_text_field', $args['tags'] ) );
			}
		}

		// Generic meta passthrough.
		if ( ! empty( $args['meta'] ) && is_array( $args['meta'] ) ) {
			foreach ( $args['meta'] as $k => $v ) {
				update_post_meta( $post_id, sanitize_key( $k ), $v );
			}
		}

		// On Divi 5, route native block content through set_post_layout after the
		// post and builder housekeeping exist.
		$content_applied = null;
		if ( $defer_content ) {
			$layout_result = self::set_post_layout( [
				'post_id'        => $post_id,
				'layout_content' => $content,
			] );
			if ( is_wp_error( $layout_result ) ) {
				$content_applied = [
					'applied' => false,
					'error'   => $layout_result->get_error_message(),
					'note'    => 'Page was created but applying the layout failed. You can retry with divi_set_post_layout.',
				];
			} else {
				$content_applied = [ 'applied' => true, 'source' => 'divi5_controller' ];
			}
		}

		// Make sure the new page's CSS is (re)generated on first view rather than
		// served stale/missing from Divi's static cache — otherwise a freshly
		// created page can render blank/unstyled. Harmless if set_post_layout
		// (D5 deferred-content path) already flushed.
		self::flush_static_css();

		// Snapshot record so undo can restore the pre-creation (i.e. nonexistent) state.
		PCD_PC_Snapshot::capture( 'post', (string) $post_id, [
			'ID'           => $post_id,
			'post_title'   => $title,
			'post_status'  => 'trash',
		], "Snapshot for newly-created {$post_type} #{$post_id} — restore to trash" );

		$preview = get_preview_post_link( $post_id );
		if ( ! $preview ) {
			$preview = add_query_arg( 'preview', 'true', get_permalink( $post_id ) );
		}

		$response = [
			'id'              => $post_id,
			'type'            => $post_type,
			'status'          => get_post_status( $post_id ),
			'edit_url'        => admin_url( "post.php?post={$post_id}&action=edit" ),
			'builder_url'     => admin_url( "post.php?post={$post_id}&action=edit&et_fb=1" ),
			'preview_url'     => $preview,
			'permalink'       => get_permalink( $post_id ),
			'builder_enabled' => true,
		];
		if ( $content_applied ) {
			$response['content_applied'] = $content_applied;
		}
		return $response;
	}

	public static function global_colors_set( array $args ) {
		$colors = $args['colors'] ?? null;
		if ( ! is_array( $colors ) || empty( $colors ) ) {
			return new WP_Error( 'no_colors', 'colors must be a non-empty object.' );
		}

		// Divi 5 keeps the live palette in et_divi → et_global_data → global_colors,
		// not the legacy et_global_colors option — route through GlobalData there.
		$gd = self::global_data_class();
		if ( $gd ) {
			return self::global_colors_set_d5( $gd, $colors, ! empty( $args['replace'] ) );
		}

		$prior = get_option( 'et_global_colors', [] );
		if ( ! is_array( $prior ) ) $prior = [];

		PCD_PC_Snapshot::capture( 'option', 'et_global_colors', $prior, 'et_global_colors before update' );

		// Normalize each entry — accept either string ("#abc123") or {color, active}.
		$normalized = [];
		foreach ( $colors as $slug => $entry ) {
			$slug = sanitize_key( (string) $slug );
			if ( '' === $slug ) continue;
			if ( is_string( $entry ) ) {
				$normalized[ $slug ] = [
					'color'  => sanitize_hex_color( $entry ) ?: $entry,
					'active' => 'yes',
				];
			} elseif ( is_array( $entry ) ) {
				$normalized[ $slug ] = [
					'color'  => isset( $entry['color'] ) ? ( sanitize_hex_color( $entry['color'] ) ?: (string) $entry['color'] ) : '',
					'active' => ! empty( $entry['active'] ) && 'no' !== $entry['active'] ? 'yes' : 'no',
				];
			}
		}

		$next = ! empty( $args['replace'] ) ? $normalized : array_merge( $prior, $normalized );
		update_option( 'et_global_colors', $next );

		// Clear Divi static CSS cache so the new palette takes effect.
		if ( class_exists( 'ET_Core_PageResource' ) ) {
			\ET_Core_PageResource::remove_static_resources( 'all', 'all' );
		}

		return [
			'updated'      => true,
			'replaced'     => ! empty( $args['replace'] ),
			'palette_size' => count( $next ),
			'changes'      => array_keys( $normalized ),
		];
	}

	/**
	 * Divi 5 path for global_colors_set. Entries live in the D5 shape
	 * {color, folder, label, lastUpdated, status, usedInPosts}; ids must start
	 * with gcid- (readable ids are valid — Divi ships gcid-primary-color).
	 * Persists via GlobalData::set_global_colors(), which also clears caches.
	 */
	private static function global_colors_set_d5( string $gd, array $colors, bool $replace ) {
		$existing = $gd::get_global_colors();
		if ( ! is_array( $existing ) ) {
			$existing = [];
		}

		PCD_PC_Snapshot::capture( 'option', 'et_divi', get_option( 'et_divi', [] ), 'et_divi before global color update' );

		$now        = gmdate( 'Y-m-d\TH:i:s.000\Z' );
		$normalized = [];
		foreach ( $colors as $slug => $entry ) {
			$slug = sanitize_key( (string) $slug );
			if ( '' === $slug ) {
				continue;
			}
			if ( strpos( $slug, 'gcid-' ) !== 0 ) {
				$slug = 'gcid-' . $slug;
			}
			if ( is_string( $entry ) ) {
				$entry = [ 'color' => $entry ];
			}
			if ( ! is_array( $entry ) || empty( $entry['color'] ) ) {
				continue;
			}
			$prior_entry         = isset( $existing[ $slug ] ) && is_array( $existing[ $slug ] ) ? $existing[ $slug ] : [];
			$active              = ! isset( $entry['active'] ) || ( ! empty( $entry['active'] ) && 'no' !== $entry['active'] );
			$normalized[ $slug ] = [
				'color'       => (string) $entry['color'],
				'folder'      => $prior_entry['folder'] ?? '',
				'label'       => isset( $entry['label'] ) ? (string) $entry['label'] : ( $prior_entry['label'] ?? $slug ),
				'lastUpdated' => $now,
				'status'      => $active ? 'active' : 'inactive',
				'usedInPosts' => $prior_entry['usedInPosts'] ?? [],
			];
		}
		if ( empty( $normalized ) ) {
			return new WP_Error( 'no_colors', 'No valid color entries found in input.' );
		}

		$next = $replace ? $normalized : array_merge( $existing, $normalized );
		$gd::set_global_colors( $next );

		$refs = [];
		foreach ( array_keys( $normalized ) as $id ) {
			$refs[] = [ 'id' => $id, 'reference' => "var(--{$id})" ];
		}

		return [
			'updated'      => true,
			'engine'       => 'divi5',
			'replaced'     => $replace,
			'palette_size' => count( $next ),
			'changes'      => $refs,
			'note'         => 'Reference each color in module attributes as var(--gcid-xxxx). The scissors lint treats gcid-/var(-- values as travel-safe.',
		];
	}

	// ------------------------------------------------------------------
	// Module introspection
	// ------------------------------------------------------------------

	public static function module_list( array $args ): array {
		$engine = PCD_PC_Builder::divi_generation();
		$search = isset( $args['search'] ) ? strtolower( (string) $args['search'] ) : '';

		if ( 'divi5' === $engine ) {
			return [
				'engine' => 'divi5',
				'result' => self::module_list_d5( $search ),
			];
		}

		return [
			'engine' => 'divi4',
			'result' => self::module_list_d4( $search ),
		];
	}

	private static function module_list_d5( string $search ): array {
		if ( ! class_exists( 'WP_Block_Type_Registry' ) ) {
			return [ 'available' => false, 'reason' => 'WP_Block_Type_Registry unavailable.', 'modules' => [] ];
		}
		$all  = WP_Block_Type_Registry::get_instance()->get_all_registered();
		$rows = [];
		// Divi 5 modules are exactly the blocks registered under a Divi module
		// category — that captures third-party modules (pcd/…, pac/…) in their
		// own namespace too, while excluding core WordPress/Gutenberg blocks.
		$divi_categories = [ 'module', 'structure', 'fullwidth-module', 'child-module' ];
		foreach ( $all as $name => $block ) {
			$is_divi = ( 0 === strpos( $name, 'divi/' ) )
				|| in_array( $block->category ?? '', $divi_categories, true );
			if ( ! $is_divi ) {
				continue;
			}
			$title       = $block->title ?? '';
			$description = $block->description ?? '';
			if ( $search !== '' && strpos( strtolower( $name . ' ' . $title . ' ' . $description ), $search ) === false ) {
				continue;
			}
			$rows[] = [
				'name'        => $name,
				'title'       => $title,
				'description' => $description,
				'category'    => $block->category ?? '',
			];
		}
		usort( $rows, function ( $a, $b ) { return strcmp( $a['name'], $b['name'] ); } );
		return [ 'available' => true, 'count' => count( $rows ), 'modules' => $rows ];
	}

	/**
	 * Force Divi 4 to register every module class on this request.
	 *
	 * Divi 4.10+ uses the Dynamic Module Framework: on a normal front-end /
	 * REST request, module classes are lazy-loaded only when their shortcode
	 * actually runs (see register_lazy_shortcodes() / load_modules() in
	 * class-et-builder-module-shortcode-manager.php). So on an introspection
	 * request like the relay, ET_Builder_Element::get_modules() returns empty
	 * and divi_module_list / divi_module_schema fail.
	 *
	 * et_builder_should_load_all_module_data() (conditions.php) gates eager
	 * loading and exposes the `et_builder_should_load_all_module_data` filter.
	 * We force it true, then run et_builder_add_main_elements() (framework.php),
	 * which loads the structure elements and inits the shortcode manager — that
	 * instantiates every module class, and each ET_Builder_Element constructor
	 * registers itself into the get_modules() buckets.
	 *
	 * Idempotent: no-ops once modules are present (e.g. admin / builder requests
	 * where Divi already loaded them), so existing behaviour is unchanged.
	 *
	 * Grounded in Divi 4.27.6 source — not inferred.
	 */
	private static function ensure_d4_modules_loaded(): void {
		if ( ! class_exists( 'ET_Builder_Element' ) || ! method_exists( 'ET_Builder_Element', 'get_modules' ) ) {
			return;
		}
		$existing = \ET_Builder_Element::get_modules( 'page' );
		if ( is_array( $existing ) && ! empty( $existing ) ) {
			return;
		}
		add_filter( 'et_builder_should_load_all_module_data', '__return_true', 99 );
		if ( function_exists( 'et_builder_init_global_settings' ) ) {
			et_builder_init_global_settings();
		}
		if ( function_exists( 'et_builder_add_main_elements' ) ) {
			et_builder_add_main_elements();
		}
	}

	private static function module_list_d4( string $search ): array {
		if ( ! class_exists( 'ET_Builder_Element' ) ) {
			return [ 'available' => false, 'reason' => 'ET_Builder_Element not loaded. Divi 4 not active.', 'modules' => [] ];
		}
		self::ensure_d4_modules_loaded();
		// ET_Builder_Element registers modules per-post-type. The 'page' bucket is
		// the most representative; falls back to '' (all post types).
		$modules = [];
		if ( method_exists( 'ET_Builder_Element', 'get_modules' ) ) {
			$modules = \ET_Builder_Element::get_modules( 'page' );
			if ( ! is_array( $modules ) || ! $modules ) {
				$modules = \ET_Builder_Element::get_modules( '' );
			}
		}
		if ( ! is_array( $modules ) || ! $modules ) {
			return [ 'available' => false, 'reason' => 'No Divi 4 modules registered yet. Builder may not be initialized on this request.', 'modules' => [] ];
		}

		$rows = [];
		foreach ( $modules as $slug => $module ) {
			$slug = (string) $slug;
			$name = is_object( $module ) && property_exists( $module, 'name' ) ? (string) $module->name : '';
			if ( $search !== '' && strpos( strtolower( $slug . ' ' . $name ), $search ) === false ) {
				continue;
			}
			$rows[] = [
				'slug' => $slug,
				'name' => $name,
				'type' => is_object( $module ) ? get_class( $module ) : 'unknown',
			];
		}
		usort( $rows, function ( $a, $b ) { return strcmp( $a['slug'], $b['slug'] ); } );
		return [ 'available' => true, 'count' => count( $rows ), 'modules' => $rows ];
	}

	public static function module_schema( array $args ) {
		$name   = (string) ( $args['name'] ?? '' );
		$engine = PCD_PC_Builder::divi_generation();
		if ( ! $name ) {
			return new WP_Error( 'missing_name', 'name is required.' );
		}
		$requested_engine = self::detect_engine( $name );
		if ( $requested_engine && $requested_engine !== $engine ) {
			return new WP_Error( 'wrong_divi_generation', "This site exposes {$engine} only; '{$name}' belongs to {$requested_engine}." );
		}

		if ( $engine === 'divi5' ) {
			// Only prepend the core divi/ namespace to a bare, unnamespaced name
			// (e.g. "heading"). A name that already carries a vendor namespace
			// (pcd/…, pac/…, divi/…) is used as registered.
			if ( strpos( $name, '/' ) === false ) {
				$name = 'divi/' . $name;
			}

			$response = [
				'engine' => 'divi5',
				'name'   => $name,
			];

			// Primary source: live block registry (definitive, version-matched).
			if ( class_exists( 'WP_Block_Type_Registry' ) ) {
				$block = WP_Block_Type_Registry::get_instance()->get_registered( $name );
				if ( $block ) {
					$response['title']       = $block->title ?? '';
					$response['description'] = $block->description ?? '';
					$response['category']    = $block->category ?? '';
					$response['attributes']  = $block->attributes ?? new stdClass();
					$response['supports']    = $block->supports ?? new stdClass();
					$response['parent']      = $block->parent ?? null;
					$response['ancestor']    = $block->ancestor ?? null;
					$response['source']      = 'live_registry';
				}
			}

			// Fallback / enrichment: bundled catalog. Gives the AI the
			// canonical attribute path tree + default values + D4→D5
			// conversion outline even when the block registry doesn't fully
			// advertise them.
			$catalog_entry = self::catalog_entry( $name );
			if ( $catalog_entry ) {
				if ( ! isset( $response['title'] ) || '' === $response['title'] ) {
					$response['title'] = $catalog_entry['title'] ?? '';
				}
				if ( ! isset( $response['category'] ) || '' === $response['category'] ) {
					$response['category'] = $catalog_entry['category'] ?? '';
				}
				$response['nestable']             = ! empty( $catalog_entry['nestable'] );
				$response['child_module_name']    = $catalog_entry['childModuleName'] ?? null;
				$response['bundled_attributes']   = $catalog_entry['attributes'] ?? new stdClass();
				$response['bundled_defaults']     = $catalog_entry['defaults'] ?? new stdClass();
				if ( ! isset( $response['source'] ) ) {
					$response['source'] = 'bundled_catalog';
				}
			}

			if ( ! isset( $response['source'] ) ) {
				return new WP_Error( 'not_found', "No schema available for '{$name}' — not in the block registry and not in the bundled catalog." );
			}

			// Hard-won field-path tips that aren't obvious from the raw schema.
			// Verified working on live Divi 5 sites.
			$response['divi5_tips'] = [
				'official_docs'         => 'Call the divi5_docs tool for a curated catalog of Divi 5 developer documentation (Elegant Themes\' official docs + an AI-oriented community docs site at 16wells.github.io/divi-docs + GitHub examples + per-version changelogs). FETCH the `must_read_first` URLs with your web-fetch capability before writing Divi 5 markup — they cover the block-comment format and attribute structure the plugin cannot bundle inline.',
				'structure'             => 'Divi 5 ONLY renders content nested as Section > Row > Column > module. A module placed at the top level, or inside a section without a row+column, or in a row/column missing its structural attributes, renders BLANK — this is the #1 cause of empty Divi 5 pages. Before building a page from scratch, call divi_layout_skeleton to get a valid, ready-to-fill skeleton, then drop your modules into the columns.',
				'column_grid'           => 'Rows declare their columns via module.advanced.columnStructure (e.g. "1_1", "1_2,1_2", "1_3,1_3,1_3", "3_5,2_5") plus module.advanced.flexColumnStructure ("single-column" for one column, "offset-columns_1" for multi). EACH column must carry module.advanced.type (its fraction, e.g. "1_2") and flexType on a 24-unit grid: 1_1=24_24, 1_2=12_24, 1_3=8_24, 1_4=6_24, 2_3=16_24, 3_4=18_24, 3_5=14_24, 2_5=10_24. Omit these and the columns collapse/blank. Nested rows inside a column use divi/row-inner + divi/column-inner.',
				'editability_principle' => 'These pages are edited by people who do not know Divi. Put EVERY piece of content a client might change — headings, body text, button labels, images — in its own NATIVE module (divi/heading, divi/text, divi/button, divi/image) so it stays editable in the Visual Builder. Never bury client-editable content inside a divi/code module — that locks it out of the VB.',
				'effects_principle'     => 'For visual effects Divi has no native module for (flip cards, custom animations, etc.): build the content as native modules, add a CSS class to them via module.advanced.htmlAttributes.desktop.value.class, and put the effect in custom CSS (divi_options_set divi_custom_css — read existing first, append, never overwrite). A flip card = a native group with native heading/text/button inside + a CSS class the flip animation targets. The client edits the words natively; the effect still works.',
				'css_class'             => 'Custom CSS class goes at module.advanced.htmlAttributes.desktop.value.class (e.g. {"module":{"advanced":{"htmlAttributes":{"desktop":{"value":{"class":"my-class"}}}}}}). Do NOT use module.decoration.attributes — it is registered but empty, and the renderer silently drops anything placed there.',
				'css_id'                => 'Custom HTML id goes alongside class at module.advanced.htmlAttributes.desktop.value.id.',
			];

			return $response;
		}

		// Divi 4
		$slug = strpos( $name, 'et_pb_' ) === 0 ? $name : 'et_pb_' . $name;
		if ( ! class_exists( 'ET_Builder_Element' ) ) {
			return new WP_Error( 'no_d4', 'Divi 4 builder not loaded.' );
		}
		self::ensure_d4_modules_loaded();
		$modules = method_exists( 'ET_Builder_Element', 'get_modules' ) ? \ET_Builder_Element::get_modules( 'page' ) : [];
		if ( ! isset( $modules[ $slug ] ) ) {
			$modules = method_exists( 'ET_Builder_Element', 'get_modules' ) ? \ET_Builder_Element::get_modules( '' ) : [];
		}
		$module = $modules[ $slug ] ?? null;
		if ( ! $module ) {
			return new WP_Error( 'not_found', "No Divi 4 module registered with slug '{$slug}'." );
		}
		$fields = method_exists( $module, 'get_fields' ) ? (array) $module->get_fields() : [];
		$compact = ! isset( $args['compact'] ) || ! empty( $args['compact'] );
		if ( $compact ) {
			$fields = self::compact_d4_fields( $fields );
		}
		return [
			'engine'      => 'divi4',
			'slug'        => $slug,
			'name'        => is_object( $module ) && property_exists( $module, 'name' ) ? $module->name : '',
			'class'       => is_object( $module ) ? get_class( $module ) : '',
			'field_count' => count( $fields ),
			'fields'      => $fields,
		];
	}

	private static function compact_d4_fields( array $fields ): array {
		$out = [];
		foreach ( $fields as $key => $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}
			$type = $field['type'] ?? '';
			if ( $type === 'skip' || ! empty( $field['hidden'] ) ) {
				continue;
			}
			$slim = [
				'type'    => $type,
				'label'   => $field['label'] ?? '',
				'default' => $field['default'] ?? null,
			];
			if ( ! empty( $field['options'] ) && is_array( $field['options'] ) ) {
				$slim['options'] = $field['options'];
			}
			if ( ! empty( $field['description'] ) ) {
				$slim['description'] = $field['description'];
			}
			if ( ! empty( $field['option_category'] ) ) {
				$slim['option_category'] = $field['option_category'];
			}
			if ( ! empty( $field['toggle_slug'] ) ) {
				$slim['toggle'] = $field['toggle_slug'];
			}
			if ( ! empty( $field['tab_slug'] ) ) {
				$slim['tab'] = $field['tab_slug'];
			}
			$out[ $key ] = $slim;
		}
		return $out;
	}

	public static function asset_catalog( array $args ) {
		$catalog = self::catalog();
		$type    = $args['type'] ?? 'all';
		$search  = strtolower( (string) ( $args['search'] ?? '' ) );
		$limit   = max( 1, min( 2000, (int) ( $args['limit'] ?? 100 ) ) );

		$filter = function ( array $items, $haystack_keys ) use ( $search, $limit ) {
			if ( $search === '' ) return array_slice( $items, 0, $limit );
			$out = [];
			foreach ( $items as $item ) {
				$hay = '';
				foreach ( $haystack_keys as $k ) {
					$v = $item[ $k ] ?? '';
					$hay .= ' ' . ( is_array( $v ) ? implode( ' ', array_map( 'strval', $v ) ) : (string) $v );
				}
				if ( false !== strpos( strtolower( $hay ), $search ) ) {
					$out[] = $item;
					if ( count( $out ) >= $limit ) break;
				}
			}
			return $out;
		};

		$icons    = $catalog['icons']      ?? [];
		$dividers = $catalog['dividers']   ?? [];
		$masks    = $catalog['masks']      ?? [];
		$patterns = $catalog['patterns']   ?? [];
		$fonts    = $catalog['googleFonts'] ?? [];

		if ( $type === 'icons' ) {
			return [
				'type'  => 'icons',
				'total' => count( $icons ),
				'items' => $filter( $icons, [ 'name', 'searchTerms', 'styles' ] ),
			];
		}
		if ( $type === 'dividers' ) {
			return [ 'type' => 'dividers', 'total' => count( $dividers ), 'items' => $filter( $dividers, [ 'name' ] ) ];
		}
		if ( $type === 'masks' ) {
			return [ 'type' => 'masks', 'total' => count( $masks ), 'items' => $filter( $masks, [ 'name' ] ) ];
		}
		if ( $type === 'patterns' ) {
			return [ 'type' => 'patterns', 'total' => count( $patterns ), 'items' => $filter( $patterns, [ 'name' ] ) ];
		}
		if ( $type === 'fonts' ) {
			return [ 'type' => 'fonts', 'total' => count( $fonts ), 'items' => $filter( $fonts, [ 'family', 'category' ] ) ];
		}

		// all — return counts + first N of each so the AI knows what's available without flooding context.
		$preview_limit = min( 25, $limit );
		return [
			'type' => 'all',
			'icons'    => [ 'total' => count( $icons ),    'sample' => array_slice( $icons,    0, $preview_limit ) ],
			'fonts'    => [ 'total' => count( $fonts ),    'sample' => array_slice( $fonts,    0, $preview_limit ) ],
			'dividers' => [ 'total' => count( $dividers ), 'items'  => $dividers ],
			'masks'    => [ 'total' => count( $masks ),    'items'  => $masks ],
			'patterns' => [ 'total' => count( $patterns ), 'items'  => $patterns ],
			'note'     => 'icons and fonts returned as small samples (large catalogs). Call divi_asset_catalog with type:"icons" or type:"fonts" plus a search term (e.g. "arrow", "Roboto") for filtered results.',
		];
	}

	public static function module_catalog( array $args ) {
		$catalog = self::catalog();
		$modules = $catalog['modules'] ?? [];
		$fields  = $args['fields'] ?? 'summary';

		// Single-module lookup.
			if ( ! empty( $args['name'] ) ) {
				$name = (string) $args['name'];
				if ( strpos( $name, '/' ) === false ) $name = 'divi/' . $name;
				$entry = $modules[ $name ] ?? null;
				if ( ! $entry ) {
					return new WP_Error( 'not_found', "No catalog entry for '{$name}'. Call divi_module_catalog without `name` to list every module." );
				}
				$public_entry = self::divi5_catalog_entry_for_output( $entry );
				return $fields === 'full' ? $public_entry : [
					'name'     => $entry['name'],
					'title'    => $entry['title'],
					'category' => $entry['category'],
					'nestable' => ! empty( $entry['nestable'] ),
				];
			}

		// Full list.
			$rows = [];
			foreach ( $modules as $entry ) {
				$rows[] = $fields === 'full' ? self::divi5_catalog_entry_for_output( $entry ) : [
					'name'     => $entry['name'],
					'title'    => $entry['title'],
					'category' => $entry['category'],
					'nestable' => ! empty( $entry['nestable'] ),
				];
			}
		return [
			'generated_at' => $catalog['generated_at'] ?? null,
			'module_count' => $catalog['module_count'] ?? count( $rows ),
			'modules'      => $rows,
		];
	}

	public static function module_example( array $args ) {
		global $wpdb;
		$name   = (string) ( $args['name'] ?? '' );
		$engine = PCD_PC_Builder::divi_generation();
		$max    = max( 1, min( 5, (int) ( $args['max_examples'] ?? 1 ) ) );
		if ( ! $name ) {
			return new WP_Error( 'missing_name', 'name is required.' );
		}
		$requested_engine = self::detect_engine( $name );
		if ( $requested_engine && $requested_engine !== $engine ) {
			return new WP_Error( 'wrong_divi_generation', "This site exposes {$engine} only; '{$name}' belongs to {$requested_engine}." );
		}

		if ( $engine === 'divi5' ) {
			$block_name = strpos( $name, '/' ) !== false ? $name : 'divi/' . $name;
			$like = '%<!-- wp:' . $wpdb->esc_like( $block_name ) . '%';
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- content search across posts; one-off lookup, values use prepare(), not cacheable.
			$rows = $wpdb->get_results( $wpdb->prepare(
				"SELECT ID, post_title, post_type, post_content FROM {$wpdb->posts}
				 WHERE post_status IN ('publish','draft','private','inherit')
				 AND post_content LIKE %s
				 ORDER BY post_modified DESC
				 LIMIT 25",
				$like
			) );

			// Collect ALL matching blocks across all posts, then sort by how
			// fully-configured each one is. Returning the first occurrence is
			// often a blank scaffold; the AI needs a real-world example with
			// attributes set (rowGap, padding, margins, etc.) to crib from.
			$candidates = [];
			foreach ( $rows as $row ) {
				$blocks = parse_blocks( $row->post_content );
				self::collect_blocks_in_tree( $block_name, $blocks, $candidates, $row, 50 );
				if ( count( $candidates ) >= 50 ) break;
			}
			usort( $candidates, function ( $a, $b ) {
				return self::score_block_attrs( $b['block'] ) - self::score_block_attrs( $a['block'] );
			} );

			$examples = [];
			foreach ( $candidates as $c ) {
				$examples[] = [
					'post_id'            => (int) $c['row']->ID,
					'post_title'         => $c['row']->post_title,
					'post_type'          => $c['row']->post_type,
					'attribute_richness' => self::score_block_attrs( $c['block'] ),
					'markup'             => serialize_block( $c['block'] ),
				];
				if ( count( $examples ) >= $max ) break;
			}

			// Tell the AI when there's nothing useful to copy. First stop: the
			// built-in specimen library (verified, portable section recipes).
			// Only when no specimen covers the module do we fall back to asking
			// the user to configure one in the Visual Builder.
			$note = null;
			$best = isset( $examples[0] ) ? (int) $examples[0]['attribute_richness'] : 0;
			if ( count( $examples ) === 0 || $best < 3 ) {
				$specimens = self::specimens_containing( $block_name );
				if ( $specimens ) {
					$names = implode( ', ', $specimens );
					$note  = "No rich `{$block_name}` example on this site — but the built-in specimen library has verified, portable recipe(s) using it: {$names}. Call divi_specimen_get with one of those names and adapt its markup (swap token colors, content, and placeholder media) instead of composing from scratch.";
				} elseif ( count( $examples ) === 0 ) {
					$note = "No `{$block_name}` blocks found on this site and no library specimen covers it. To get a verified example: ask the user to open any page in the Divi 5 Visual Builder, add the module, configure the settings you need (padding, margins, column gap, etc.), save, then call this tool again.";
				} else {
					$note = "The richest `{$block_name}` example on this site only has {$best} attribute leaves — essentially an empty scaffold — and no library specimen covers it. Ask the user to configure a real instance of this module in the Visual Builder (any settings: padding, margins, gap, typography), save, then re-call this tool to copy the resulting attribute paths.";
				}
			}

			$response = [
				'engine'           => 'divi5',
				'name'             => $block_name,
				'count'            => count( $examples ),
				'examples'         => $examples,
				'searched_posts'   => count( $rows ),
				'candidates_found' => count( $candidates ),
			];
			if ( $note ) {
				$response['note'] = $note;
			}

			// Always include the bundled-catalog reference if available — even
			// when live examples exist, the AI may need to see canonical
			// attribute paths that aren't set in the live example. This is
			// the answer to "AI can see every aspect of Divi 5" without
			// requiring the user to configure each module first.
			$catalog_entry = self::catalog_entry( $block_name );
			if ( $catalog_entry ) {
					$response['bundled_reference'] = [
						'title'              => $catalog_entry['title'] ?? '',
						'category'           => $catalog_entry['category'] ?? '',
						'nestable'           => ! empty( $catalog_entry['nestable'] ),
					'child_module_name'  => $catalog_entry['childModuleName'] ?? null,
					'attribute_paths'    => $catalog_entry['attributes'] ?? new stdClass(),
					'defaults'           => $catalog_entry['defaults'] ?? new stdClass(),
					'synthesized_markup' => self::synthesize_block_markup( $block_name, $catalog_entry ),
				];
			}
			return $response;
		}

		// Divi 4 — regex-walk balanced shortcodes
		$slug = strpos( $name, 'et_pb_' ) === 0 ? $name : 'et_pb_' . $name;
		$like = '%[' . $wpdb->esc_like( $slug ) . '%';
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- content search across posts; one-off lookup, values use prepare(), not cacheable.
		$rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT ID, post_title, post_type, post_content FROM {$wpdb->posts}
			 WHERE post_status IN ('publish','draft','private','inherit')
			 AND post_content LIKE %s
			 ORDER BY post_modified DESC
			 LIMIT 25",
			$like
		) );

		$examples = [];
		foreach ( $rows as $row ) {
			$markup = self::extract_d4_shortcode( $slug, (string) $row->post_content );
			if ( $markup === null ) {
				continue;
			}
			$examples[] = [
				'post_id'    => (int) $row->ID,
				'post_title' => $row->post_title,
				'post_type'  => $row->post_type,
				'markup'     => $markup,
			];
			if ( count( $examples ) >= $max ) {
				break;
			}
		}
		return [
			'engine'   => 'divi4',
			'slug'     => $slug,
			'count'    => count( $examples ),
			'examples' => $examples,
			'searched' => count( $rows ),
		];
	}

	private static function detect_engine( string $name ): ?string {
		if ( strpos( $name, 'et_pb_' ) === 0 ) {
			return 'divi4';
		}
		// Any namespaced block name (divi/…, pcd/…, pac/…, etc.) is a Divi 5
		// block. Third-party modules use their own vendor namespace, so don't
		// assume the divi/ prefix.
		if ( strpos( $name, '/' ) !== false ) {
			return 'divi5';
		}
		return null;
	}

	/**
	 * Lazy-loaded Divi 5 module catalog (assets/divi5-catalog.json). Holds
	 * the canonical attribute path tree + default values for every Divi 5
	 * module, extracted from the Divi theme source at build time. Used as
	 * fallback/enrichment by divi_module_schema and divi_module_example so
	 * the AI has full coverage even on sites with no useful live examples.
	 */
	private static $catalog_cache = null;

	private static function catalog(): array {
		if ( null !== self::$catalog_cache ) {
			return self::$catalog_cache;
		}
		$path = PCD_PC_DIR . 'assets/divi5-catalog.json';
		if ( ! file_exists( $path ) ) {
			self::$catalog_cache = [];
			return self::$catalog_cache;
		}
		$json = @file_get_contents( $path );
		if ( ! $json ) {
			self::$catalog_cache = [];
			return self::$catalog_cache;
		}
		$data = json_decode( $json, true );
		self::$catalog_cache = is_array( $data ) ? $data : [];
		return self::$catalog_cache;
	}

		private static function catalog_entry( string $name ): ?array {
			$catalog = self::catalog();
			$modules = $catalog['modules'] ?? [];
			return is_array( $modules[ $name ] ?? null ) ? $modules[ $name ] : null;
		}

		private static function divi5_catalog_entry_for_output( array $entry ): array {
			unset( $entry['d4Shortcode'], $entry['conversionOutline'] );
			return $entry;
		}

	/**
	 * Synthesize a minimal valid block-markup example for a Divi 5 module
	 * using the bundled catalog's default attribute values. Gives the AI
	 * something to start from when the live site has no usable example.
	 */
	private static function synthesize_block_markup( string $name, array $entry ): string {
		// Structural modules CANNOT be synthesized from defaults — they need
		// instance-level structural attributes (columnStructure, column
		// type/flexType) that never appear in module defaults, so a
		// defaults-merge produces an un-renderable row/column. Return a
		// verified-valid skeleton instead. A column is only valid inside a row,
		// so for both row and column we show the row-in-context skeleton.
		if ( 'divi/section' === $name ) {
			return self::build_section( self::build_row( [ '1_1' ], '<!-- place your modules here -->' ) );
		}
		if ( 'divi/row' === $name || 'divi/column' === $name ) {
			return self::build_row( [ '1_1' ], '<!-- place your modules here -->' );
		}

		$defaults = $entry['defaults'] ?? [];
		$render   = is_array( $defaults['render'] ?? null ) ? $defaults['render'] : [];
		$style    = is_array( $defaults['printedStyle'] ?? null ) ? $defaults['printedStyle'] : [];

		// Merge render + style defaults so the AI sees both kinds of attributes.
		$attrs = self::deep_merge( $render, $style );

		$attrs_json = empty( $attrs ) ? '' : ' ' . wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES );
		$has_children = ! empty( $entry['nestable'] ) || ! empty( $entry['childModuleName'] );

		if ( $has_children ) {
			return "<!-- wp:{$name}{$attrs_json} -->\n<!-- /wp:{$name} -->";
		}
		return "<!-- wp:{$name}{$attrs_json} /-->";
	}

	private static function deep_merge( $a, $b ) {
		if ( ! is_array( $a ) || ! is_array( $b ) ) return is_array( $b ) ? $b : $a;
		$out = $a;
		foreach ( $b as $k => $v ) {
			$out[ $k ] = isset( $out[ $k ] ) ? self::deep_merge( $out[ $k ], $v ) : $v;
		}
		return $out;
	}

	// ------------------------------------------------------------------
	// Divi 5 structure blueprint
	//
	// Divi 5 only renders content nested as Section > Row > Column > module,
	// and rows/columns require instance-level structural attributes
	// (columnStructure, flexColumnStructure, column type, flexType on a
	// 24-unit grid) that never appear in a module's defaults — so the AI
	// cannot synthesize a valid row/column from the schema alone. These
	// builders emit the exact verified-valid markup so the AI starts from a
	// renderable skeleton instead of guessing. Markup shape verified against
	// live Divi 5.6 layouts.
	// ------------------------------------------------------------------

	/** Map a Divi columnStructure fraction to its flexType value on the 24-unit grid. */
	private static function flex_for( string $fraction ): string {
		$map = [
			'1_1' => '24_24', '4_4' => '24_24',
			'1_2' => '12_24',
			'1_3' => '8_24',  '2_3' => '16_24',
			'1_4' => '6_24',  '3_4' => '18_24',
			'2_5' => '10_24', '3_5' => '14_24',
			'1_6' => '4_24',  '5_6' => '20_24',
		];
		return $map[ $fraction ] ?? '24_24';
	}

	private static function block_open( string $name, array $attrs ): string {
		return '<!-- wp:' . $name . ' ' . wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES ) . ' -->';
	}
	private static function block_void( string $name, array $attrs ): string {
		return '<!-- wp:' . $name . ' ' . wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES ) . ' /-->';
	}
	private static function block_close( string $name ): string {
		return '<!-- /wp:' . $name . ' -->';
	}

	/**
	 * Build one valid <divi/row> with $cols columns, each holding $inner markup.
	 *
	 * $col_row_gap controls the vertical gap between stacked modules INSIDE
	 * each column (the column is a flex container with flexDirection: column,
	 * so its rowGap is the spacing between its child modules). Divi 5's own
	 * default is "0px" — which forces every module to manage its own
	 * margin/bottom-spacing or modules collapse together. We default to
	 * "30px" (matching Divi 5's built-in default for a fresh VB row) so a
	 * skeleton + naive module-drop produces nicely-spaced content
	 * out of the box; pass "0px" to opt back into per-module-margin control.
	 */
	private static function build_row( array $cols, string $inner, string $col_row_gap = '30px' ): string {
		$structure   = implode( ',', $cols );
		$flex_struct = count( $cols ) === 1 ? 'single-column' : 'offset-columns_1';
		$row_attrs = [
			'module' => [
				'advanced' => [
					'columnStructure'     => [ 'desktop' => [ 'value' => $structure ] ],
					'flexColumnStructure' => [ 'desktop' => [ 'value' => $flex_struct ] ],
				],
				'decoration' => [
					'layout' => [
						'desktop' => [ 'value' => [ 'display' => 'flex', 'flexWrap' => 'nowrap', 'alignItems' => 'stretch', 'columnGap' => '32px' ] ],
						'tablet'  => [ 'value' => [ 'display' => 'flex', 'flexWrap' => 'wrap', 'rowGap' => '32px' ] ],
						'phone'   => [ 'value' => [ 'display' => 'flex', 'flexWrap' => 'wrap', 'rowGap' => '24px' ] ],
					],
				],
			],
			'builderVersion' => '5.5.2',
			'modulePreset'   => [ 'default' ],
		];
		$out = self::block_open( 'divi/row', $row_attrs ) . "\n";
		foreach ( $cols as $frac ) {
			$flex = self::flex_for( $frac );
			$col_attrs = [
				'module' => [
					'advanced' => [
						'type'     => [ 'desktop' => [ 'value' => $frac ] ],
						'flexType' => [
							'desktop'   => [ 'value' => $flex ],
							'tablet'    => [ 'value' => $flex ],
							'phone'     => [ 'value' => '24_24' ],
							'phoneWide' => [ 'value' => '24_24' ],
						],
					],
					'decoration' => [
						'sizing' => [
							'desktop' => [ 'value' => [ 'flexType' => $flex ] ],
							'tablet'  => [ 'value' => [ 'flexType' => $flex ] ],
							'phone'   => [ 'value' => [ 'flexType' => '24_24' ] ],
						],
						'layout' => [ 'desktop' => [ 'value' => [ 'display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start', 'justifyContent' => 'start', 'alignContent' => 'stretch', 'flexWrap' => 'wrap', 'rowGap' => $col_row_gap ] ] ],
					],
				],
				'builderVersion' => '5.5.2',
				'modulePreset'   => [ 'default' ],
			];
			$out .= self::block_open( 'divi/column', $col_attrs ) . "\n";
			$out .= $inner . "\n";
			$out .= self::block_close( 'divi/column' ) . "\n";
		}
		$out .= self::block_close( 'divi/row' );
		return $out;
	}

	/** Wrap row markup in a valid <divi/section>. */
	private static function build_section( string $inner_rows ): string {
		$section_attrs = [
			'module'         => [ 'meta' => [ 'adminLabel' => [ 'desktop' => [ 'value' => 'Section' ] ] ] ],
			'builderVersion' => '5.5.2',
			'modulePreset'   => [ 'default' ],
		];
		return self::block_open( 'divi/section', $section_attrs ) . "\n" . $inner_rows . "\n" . self::block_close( 'divi/section' );
	}

	/** A heading + text placeholder so columns aren't empty and module placement is shown. */
	private static function placeholder_modules(): string {
		$heading = [
			'module'         => [ 'meta' => [ 'adminLabel' => [ 'desktop' => [ 'value' => 'Heading' ] ] ] ],
			'title'          => [
				'innerContent' => [ 'desktop' => [ 'value' => 'Your heading here' ] ],
				'decoration'   => [ 'font' => [ 'font' => [ 'desktop' => [ 'value' => [ 'headingLevel' => 'h2' ] ] ] ] ],
			],
			'builderVersion' => '5.5.2',
			'modulePreset'   => [ 'default' ],
		];
		$text = [
			'module'         => [ 'meta' => [ 'adminLabel' => [ 'desktop' => [ 'value' => 'Text' ] ] ] ],
			'content'        => [ 'innerContent' => [ 'desktop' => [ 'value' => '<p>Your text here.</p>' ] ] ],
			'builderVersion' => '5.5.2',
			'modulePreset'   => [ 'default' ],
		];
		return self::block_void( 'divi/heading', $heading ) . "\n" . self::block_void( 'divi/text', $text );
	}

	/** Named column layouts → array of columnStructure fractions per row. */
	private static function skeleton_layouts(): array {
		return [
			'1col'          => [ '1_1' ],
			'2col'          => [ '1_2', '1_2' ],
			'3col'          => [ '1_3', '1_3', '1_3' ],
			'4col'          => [ '1_4', '1_4', '1_4', '1_4' ],
			'sidebar-left'  => [ '1_4', '3_4' ],
			'sidebar-right' => [ '3_4', '1_4' ],
			'split-3-2'     => [ '3_5', '2_5' ],
		];
	}

	public static function layout_skeleton( array $args ) {
		$layouts     = self::skeleton_layouts();
		$layout      = (string) ( $args['layout'] ?? '1col' );
		$cols        = $layouts[ $layout ] ?? $layouts['1col'];
		$col_row_gap = (string) ( $args['column_row_gap'] ?? '30px' );

		$markup = self::build_section( self::build_row( $cols, self::placeholder_modules(), $col_row_gap ) );

		return [
			'layout'            => $layout,
			'available_layouts' => array_keys( $layouts ),
			'column_row_gap'    => $col_row_gap,
			'rule'              => 'Divi 5 ONLY renders content nested as Section > Row > Column > module. Replace the placeholder divi/heading + divi/text with your real modules. Add more rows inside the section, or more sections, by repeating the same structure. Keep the structural attributes (columnStructure, flexColumnStructure, column type, flexType) — they are mandatory or columns render blank.',
			'column_grid'       => 'Columns use a 24-unit flex grid. flexType per fraction: 1_1=24_24, 1_2=12_24, 1_3=8_24, 1_4=6_24, 2_3=16_24, 3_4=18_24, 3_5=14_24, 2_5=10_24. The row\'s columnStructure lists the fractions (e.g. "1_2,1_2"); each column repeats its own fraction in module.advanced.type.',
			'spacing_rule'      => 'The column is a flex container with flexDirection: column, so the column\'s rowGap is the vertical gap between stacked modules INSIDE it. Default is "30px" — matches Divi 5\'s own built-in default for a fresh row in the VB, so modules drop in and space themselves naturally. Set per-module margins ONLY if column_row_gap is "0px", or they\'ll fight (rowGap + module margin both apply). Pick one strategy and stick with it within a column.',
			'skeleton'          => $markup,
		];
	}

	/**
	 * Curated catalog of Divi 5 developer documentation URLs. The plugin
	 * cannot bundle the docs themselves (they live and update on the web),
	 * but it can point the AI at exactly the right pages so it consults the
	 * source of truth instead of guessing. Returns ~30 vetted URLs grouped
	 * by topic, with a `must_read_first` set the AI should fetch BEFORE
	 * writing any Divi 5 markup. Pass `topic` to return one category only.
	 */
	public static function divi5_docs( array $args ) {
		// Read mode: when `read` is passed, fetch the actual page content
		// from the Conduit relay (which caches it for 24h) and return the
		// cleaned markdown inline — saves the AI a web-fetch round-trip and
		// works for AI clients without their own web-fetch capability.
		if ( ! empty( $args['read'] ) ) {
			return self::fetch_divi5_doc_via_relay( (string) $args['read'] );
		}

		$catalog = [
			'must_read_first' => [
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/rest-api-content/', 'about' => 'Programmatic Divi 5 content creation via REST — covers the block-comment format, attribute encoding, and end-to-end page emission. The single most practical guide for an AI building pages.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/known-limitations/', 'about' => 'Common mistakes LLMs make about Divi 5 — direct anti-pattern list. Read to skip failure modes other AIs have already hit.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/internals/json-attribute-map/', 'about' => 'Maps CSS properties to the deeply-nested JSON attribute paths Divi 5 uses (where padding/margin/font-size live in the attribute tree).' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/explanations/module/attribute-format', 'about' => 'Official canonical attribute format: desktop/tablet/phone breakpoint structure, hover/sticky states, value-object nesting.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/design-system-setup/', 'about' => 'Set up global typography/colors/variables FIRST — prevents the most common AI mistake of styling per-element instead of using design tokens.' ],
			],
			'block_format' => [
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/rest-api-content/', 'about' => 'Block-comment format covered in the context of programmatic REST content creation — the most practical block-format reference.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/internals/content-encoding/', 'about' => 'Unicode/HTML escaping rules inside block JSON attributes.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/internals/json-attribute-map/', 'about' => 'CSS-to-JSON attribute path mapping.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/internals/ssr-rendering/', 'about' => 'Server-side rendering vs hydration boundaries.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/explanations/module/attribute-format', 'about' => 'Official attribute format reference.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/explanations/module/responsive-content/responsive-content-server-side', 'about' => 'Server-side responsive content rendering.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/explanations/module/responsive-content/responsive-content-on-visual-builder', 'about' => 'Visual Builder responsive behavior.' ],
				[ 'url' => 'https://help.elegantthemes.com/en/articles/11892376-understanding-flex-column-structure-changes-in-divi-5', 'about' => 'Flex column structure semantics — explains `flexColumnStructure`.' ],
			],
			'module_development' => [
				[ 'url' => 'https://dev.elegantthemes.com/docs/intro', 'about' => 'Root of the official Divi 5 developer documentation.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/explanations/module/anatomy-of-module', 'about' => 'File-by-file breakdown of a Divi 5 module.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/explanations/module/module-metadata/introduction', 'about' => '`module.json` overview.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/explanations/module/module-metadata/attributes/', 'about' => 'Attributes tree: elements, structure, styling, settings.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/tutorials/module/beginner/create-simple-quick-module', 'about' => 'Create a Divi 5 module in 10 minutes — best end-to-end first tutorial.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/category/style-components-and-functions', 'about' => 'Index of style component pages (background, spacing, sizing, font, border, shadow, transform, etc.).' ],
			],
			'community_playbooks' => [
				[ 'url' => 'https://16wells.github.io/divi-docs/', 'about' => 'Root of the community AI-oriented Divi 5 docs site.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/rest-api-content/', 'about' => 'End-to-end programmatic page creation via REST — covers block format, attribute encoding, AI emission patterns.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/known-limitations/', 'about' => 'LLM anti-patterns and common Divi 5 mistakes.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/design-system-setup/', 'about' => 'Set up global typography/colors/variables BEFORE per-element styling.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/responsive-design/', 'about' => 'Breakpoints + device-specific overrides.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/css-in-divi/', 'about' => 'When to use Design tab vs Custom CSS vs free-form vs child theme.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/rest-api-content/', 'about' => 'Programmatic content creation via REST including block format.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/theme-builder-ops/', 'about' => 'Theme-builder (global headers/footers/templates) programmatic patterns.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/playbooks/troubleshooting-tree/', 'about' => 'Systematic diagnostic framework when emission fails.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/recipes/', 'about' => 'Pre-built "how to build X" recipes (hero, pricing, FAQ, etc.).' ],
			],
			'navigation_and_groups' => [
				[ 'url' => 'https://help.elegantthemes.com/en/articles/11170855-the-group-module-in-divi-5', 'about' => 'Official reference for divi/group — a flexible container that wraps any combination of modules inside a column. Used for cards, mega-menu panels, and any reusable content cluster.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/modules/group/', 'about' => 'Community technical reference for divi/group — attribute paths, layout modes, common patterns.' ],
				[ 'url' => 'https://www.elegantthemes.com/blog/divi-resources/everything-you-need-to-know-about-divi-5-module-groups', 'about' => 'Deep-dive on module groups: what they are, when to use them, how they differ from rows and sections.' ],
				[ 'url' => 'https://www.elegantthemes.com/blog/divi-resources/how-to-create-a-reusable-card-module-group-in-divi-5', 'about' => 'Concrete tutorial: build a reusable card pattern with divi/group. Same pattern works for mega-menu panels and any repeated UI block.' ],
				[ 'url' => 'https://help.elegantthemes.com/en/articles/13714244-the-dropdown-module-in-divi-5', 'about' => 'Official reference for divi/dropdown — a container whose contents stay hidden until a trigger is hovered/clicked. The building block for dropdown menus and mega menus.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/modules/dropdown/', 'about' => 'Community technical reference for divi/dropdown — attribute paths, trigger behavior (hover/click), position options (above/below/left/right).' ],
				[ 'url' => 'https://help.elegantthemes.com/en/articles/10353582-the-menu-module-in-divi-5', 'about' => 'Official reference for the classic divi/menu module — driven by a WordPress menu. Simpler than building from link+dropdown but less customizable.' ],
				[ 'url' => 'https://www.elegantthemes.com/blog/divi-resources/everything-you-need-to-know-about-divi-5s-new-menu-features', 'about' => 'Overview of Divi 5\'s menu architecture: classic Menu module vs Link + Dropdown modular approach, Loop Builder + Interactions integration.' ],
				[ 'url' => 'https://www.elegantthemes.com/blog/divi-resources/how-to-build-custom-menus-with-divi-5s-link-dropdown-modules', 'about' => 'Canonical tutorial: build a custom menu using divi/link + divi/dropdown — the modular Divi 5 way. Covers submenus, mobile menu, native styling.' ],
				[ 'url' => 'https://www.elegantthemes.com/blog/divi-resources/how-to-build-mega-menus-with-divi-5s-dropdown-module', 'about' => 'Build a mega menu via divi/dropdown — multi-column panels, embedded modules, native interactions, no plugins.' ],
				[ 'url' => 'https://www.elegantthemes.com/blog/divi-resources/how-to-create-mega-menus-with-divi-5s-canvases', 'about' => 'Alternative mega-menu approach using Divi 5 Canvases — more flexible for full-screen overlays and complex layouts.' ],
				[ 'url' => 'https://www.elegantthemes.com/blog/divi-resources/part-6-building-a-custom-header-and-navigation-in-divi-5', 'about' => 'Step-by-step "Part 6" tutorial: build a custom header + navigation from scratch using Theme Builder — logo, menu, CTA button.' ],
				[ 'url' => 'https://www.elegantthemes.com/blog/divi-resources/how-to-build-a-simple-navigation-menu-with-divi-5-including-html-structure', 'about' => 'Build a navigation menu from basic modules (image logo, text/link items, button CTA) with proper semantic HTML output.' ],
				[ 'url' => 'https://www.elegantthemes.com/blog/divi-resources/how-to-create-a-unique-header-menu-layout-with-divi-flexbox-layout-system', 'about' => 'Use Divi 5 flexbox controls to build asymmetric/non-standard header layouts.' ],
				[ 'url' => 'https://help.elegantthemes.com/en/articles/14014566-editable-theme-builder-areas-in-divi-5', 'about' => 'How Theme Builder areas (header/body/footer) work in Divi 5 — required reading before building a global header.' ],
			],
			'migration_d4_to_d5' => [
				[ 'url' => 'https://dev.elegantthemes.com/docs/introducing-divi-5/intentional-changes', 'about' => 'Deliberate breaks from Divi 4 — patterns that won\'t carry over.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/explanations/module/conversion-outline/introduction', 'about' => '`conversion-outline.json` — how Divi 4 attribute names map to Divi 5 attribute paths.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/tutorials/module/intermediate/converting-module/conceptual-changes', 'about' => 'Conceptual differences between D4 and D5 modules.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/tutorials/module/intermediate/converting-module/converting-static-module', 'about' => 'Convert a static module D4→D5 end-to-end.' ],
			],
			'api_reference' => [
				[ 'url' => 'https://dev.elegantthemes.com/docs/hooks', 'about' => 'Official PHP/JS hooks reference for Divi 5.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/code-snippets/selector-functions', 'about' => 'Selector functions to read VB state (Divi 5 stores).' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/code-snippets/dispatch-functions', 'about' => 'Dispatch functions to mutate VB state.' ],
				[ 'url' => 'https://16wells.github.io/divi-docs/api/', 'about' => 'Community API index: hooks, REST, JS API, Block JSON, Module Lifecycle, WP-CLI.' ],
			],
			'github_examples' => [
				[ 'url' => 'https://github.com/elegantthemes/d5-extension-example-modules', 'about' => 'Reference modules: static, dynamic, parent, child, D4-converted. Fork this when building a new module.' ],
				[ 'url' => 'https://github.com/elegantthemes/d5-example-core-modules', 'about' => 'Reference implementations of core Divi modules (CTA, Blurb, Pricing Tables, Accordion, Blog, Button, Slider).' ],
				[ 'url' => 'https://github.com/elegantthemes/d5-tutorial-module-conversion', 'about' => 'Companion repo to the official D4-to-D5 conversion tutorial.' ],
			],
			'changelog' => [
				[ 'url' => 'https://www.divichangelog.com/divi-update', 'about' => 'Community version-by-version Divi 5 changelog index.' ],
				[ 'url' => 'https://dev.elegantthemes.com/docs/blog', 'about' => 'Official dev-docs blog with per-release developer notes.' ],
				[ 'url' => 'https://help.elegantthemes.com/en/articles/9973580-divi-5-update-status', 'about' => 'Living "what works / what doesn\'t" status page.' ],
			],
		];

			unset( $catalog['migration_d4_to_d5'] );

			$how = 'On Divi 5, BEFORE writing any layout markup: (1) Call divi_layout_skeleton for a verified-valid Section>Row>Column scaffold. (2) Read the must_read_first pages — the 16wells `rest-api-content` playbook covers the block format + REST emission end-to-end, `known-limitations` lists the LLM anti-patterns, and the official `attribute-format` page covers the desktop/tablet/phone/hover value structure. Use this tool with `read: "<url>"` to fetch any of them inline (relay-cached, no web-fetch needed). (3) After must-reads, consult topic-specific categories by re-calling with a `topic` argument.';

		$topic = strtolower( trim( (string) ( $args['topic'] ?? '' ) ) );
		if ( '' !== $topic ) {
			if ( ! isset( $catalog[ $topic ] ) ) {
				return new WP_Error( 'unknown_topic', "Unknown topic '{$topic}'. Available: " . implode( ', ', array_keys( $catalog ) ) );
			}
			return [
				'topic'      => $topic,
				'how_to_use' => $how,
				'docs'       => $catalog[ $topic ],
			];
		}

		return [
			'how_to_use' => $how,
			'categories' => array_keys( $catalog ),
			'docs'       => $catalog,
		];
	}

	/**
	 * Ask the Conduit relay to fetch a Divi 5 documentation page and return
	 * its main content as markdown. The relay handles HTML-to-markdown
	 * conversion, caches the result for 24h in Upstash KV, and enforces a
	 * domain allowlist. The plugin just shuttles the request and the answer.
	 *
	 * @return array|WP_Error  { url, title, content, cached, fetched_at } on success.
	 */
	private static function fetch_divi5_doc_via_relay( string $url ) {
		$url = trim( $url );
		if ( '' === $url ) {
			return new WP_Error( 'missing_url', 'Pass a `read` URL from the divi5_docs catalog (or any URL on an allowed Divi 5 doc domain).' );
		}
		if ( ! class_exists( 'PCD_PC_Pair' ) || ! PCD_PC_Pair::is_paired() ) {
			return new WP_Error( 'not_paired', 'Conduit must be paired with the relay to fetch documentation pages.' );
		}
		$backend = PCD_PC_Pair::backend_url();
		if ( ! $backend ) {
			return new WP_Error( 'no_backend', 'Relay backend URL is not configured.' );
		}

		$response = wp_remote_post( $backend . '/divi5-doc', [
			'headers' => [
				'Content-Type'       => 'application/json',
				'Accept'             => 'application/json',
				'X-Conduit-Customer' => PCD_PC_Pair::customer_id(),
				'X-Conduit-Site'     => site_url(),
				'X-Conduit-Token'    => PCD_PC_Auth::get_token(),
			],
			'body'    => wp_json_encode( [ 'url' => $url ] ),
			'timeout' => 25,
		] );

		if ( is_wp_error( $response ) ) {
			return new WP_Error( 'relay_unreachable', 'Relay request failed: ' . $response->get_error_message() );
		}

		$status = (int) wp_remote_retrieve_response_code( $response );
		$body   = wp_remote_retrieve_body( $response );
		$json   = json_decode( $body, true );

		if ( 200 !== $status ) {
			$err = is_array( $json ) && isset( $json['error'] ) ? $json['error'] : ( 'HTTP ' . $status );
			$extra = is_array( $json ) && isset( $json['allowed'] ) ? ( ' Allowed hosts: ' . implode( ', ', $json['allowed'] ) ) : '';
			return new WP_Error( 'relay_error', 'Relay declined to fetch this doc: ' . $err . $extra );
		}
		if ( ! is_array( $json ) || ! isset( $json['content'] ) ) {
			return new WP_Error( 'relay_bad_response', 'Relay returned an unexpected payload.' );
		}

		return [
			'url'        => $json['url'] ?? $url,
			'title'      => $json['title'] ?? '',
			'cached'     => ! empty( $json['cached'] ),
			'fetched_at' => $json['fetched_at'] ?? null,
			'content'    => (string) $json['content'],
			'warning'    => $json['warning'] ?? null,
		];
	}

	/**
	 * Walk a parse_blocks() tree and collect EVERY occurrence of $name into
	 * $out. Used by divi_module_example to score candidates by configuration
	 * depth rather than just grabbing the first match (which is often an
	 * empty scaffold).
	 */
	private static function collect_blocks_in_tree( string $name, array $blocks, array &$out, $row, int $max ): void {
		foreach ( $blocks as $block ) {
			if ( count( $out ) >= $max ) return;
			if ( ( $block['blockName'] ?? null ) === $name ) {
				$out[] = [ 'block' => $block, 'row' => $row ];
			}
			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
				self::collect_blocks_in_tree( $name, $block['innerBlocks'], $out, $row, $max );
			}
		}
	}

	/**
	 * Score a block by how many configured attribute leaves it carries —
	 * recursively counts scalar values inside attrs, plus the same recursive
	 * count on each child block. A bare `<!-- wp:divi/row -->` scores 0; one
	 * with rowGap, columnGap, padding, and styled children scores many higher.
	 */
	private static function score_block_attrs( array $block ): int {
		$score = self::count_attr_leaves( $block['attrs'] ?? [] );
		if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
			foreach ( $block['innerBlocks'] as $inner ) {
				$score += self::score_block_attrs( $inner );
			}
		}
		return $score;
	}

	private static function count_attr_leaves( $v ): int {
		if ( ! is_array( $v ) ) {
			// Scalar leaf — counts as one configured value. Empty string
			// counts too: the AI cares about the path's existence.
			return 1;
		}
		$count = 0;
		foreach ( $v as $sub ) {
			$count += self::count_attr_leaves( $sub );
		}
		return $count;
	}

	private static function find_block_in_tree( string $name, array $blocks ): ?array {
		foreach ( $blocks as $block ) {
			if ( ( $block['blockName'] ?? null ) === $name ) {
				return $block;
			}
			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
				$found = self::find_block_in_tree( $name, $block['innerBlocks'] );
				if ( $found ) {
					return $found;
				}
			}
		}
		return null;
	}

	/**
	 * Walk forward from the first occurrence of [slug ...] tracking balanced
	 * opens/closes for the same tag so nested instances don't terminate early.
	 * Returns the full shortcode block (open tag + inner + close tag) or just
	 * the opening tag if no close exists.
	 */
	private static function extract_d4_shortcode( string $slug, string $content ): ?string {
		$open_marker  = '[' . $slug;
		$close_marker = '[/' . $slug . ']';
		$open_len     = strlen( $open_marker );
		$close_len    = strlen( $close_marker );
		$clen         = strlen( $content );

		$pos = stripos( $content, $open_marker );
		if ( $pos === false ) {
			return null;
		}
		// Ensure the char after the slug is a tag boundary, not a longer slug
		// (e.g. avoid matching et_pb_text when scanning for et_pb_tex).
		$after = $content[ $pos + $open_len ] ?? '';
		if ( $after !== '' && ! preg_match( '/[\s\]]/', $after ) ) {
			// Try further occurrences with a stricter pattern.
			if ( ! preg_match( '/\[' . preg_quote( $slug, '/' ) . '(?=[\s\]])/i', $content, $m, PREG_OFFSET_CAPTURE ) ) {
				return null;
			}
			$pos = $m[0][1];
		}

		$open_end = strpos( $content, ']', $pos );
		if ( $open_end === false ) {
			return null;
		}

		$depth = 1;
		$i     = $open_end + 1;
		while ( $i < $clen && $depth > 0 ) {
			$next_open  = stripos( $content, $open_marker, $i );
			$next_close = stripos( $content, $close_marker, $i );
			if ( $next_close === false ) {
				// No closer at all — return the opening tag alone as a useful example.
				return substr( $content, $pos, $open_end - $pos + 1 );
			}
			$is_real_open = false;
			if ( $next_open !== false && $next_open < $next_close ) {
				$boundary = $content[ $next_open + $open_len ] ?? '';
				if ( $boundary === '' || preg_match( '/[\s\]]/', $boundary ) ) {
					$is_real_open = true;
				}
			}
			if ( $is_real_open ) {
				$depth++;
				$i = $next_open + $open_len;
			} else {
				$depth--;
				if ( $depth === 0 ) {
					return substr( $content, $pos, $next_close + $close_len - $pos );
				}
				$i = $next_close + $close_len;
			}
		}
		return substr( $content, $pos, $open_end - $pos + 1 );
	}

	// ------------------------------------------------------------------
	// Capture tools (divi_layout_outline / divi_capture)
	// ------------------------------------------------------------------

	public static function layout_outline( array $args ) {
		$post_id   = (int) ( $args['post_id'] ?? 0 );
		$max_depth = max( 0, (int) ( $args['max_depth'] ?? 0 ) );
		$post      = get_post( $post_id );
		if ( ! $post ) {
			return new WP_Error( 'not_found', "Post #{$post_id} not found." );
		}
		if ( PCD_PC_Builder::is_divi4() ) {
			return self::layout_outline_d4( $post, $max_depth );
		}
		$tree = self::parsed_divi_tree( (string) $post->post_content );
		if ( is_wp_error( $tree ) ) {
			return $tree;
		}
		$out = [];
		self::outline_walk( $tree['blocks'], '', 1, $max_depth, $out );
		return [
			'post_id'                => $post_id,
			'post_title'             => $post->post_title,
			'wrapped_in_placeholder' => $tree['wrapped'],
			'count'                  => count( $out ),
			'blocks'                 => $out,
		];
	}

	public static function capture( array $args ) {
		$post_id = (int) ( $args['post_id'] ?? 0 );
		$post    = get_post( $post_id );
		if ( ! $post ) {
			return new WP_Error( 'not_found', "Post #{$post_id} not found." );
		}
		if ( PCD_PC_Builder::is_divi4() ) {
			return self::capture_d4( $post, $args );
		}
		$tree = self::parsed_divi_tree( (string) $post->post_content );
		if ( is_wp_error( $tree ) ) {
			return $tree;
		}
		$blocks = $tree['blocks'];

		$path = isset( $args['path'] ) ? trim( (string) $args['path'] ) : '';
		$label = isset( $args['admin_label'] ) ? trim( (string) $args['admin_label'] ) : '';
		$block_name = isset( $args['block'] ) ? trim( (string) $args['block'] ) : '';

		$node = null;
		$found_path = '';

		if ( $path !== '' ) {
			$indices = explode( '.', $path );
			$level   = $blocks;
			$node    = null;
			foreach ( $indices as $depth_i => $idx ) {
				if ( ! ctype_digit( (string) $idx ) ) {
					return new WP_Error( 'bad_path', "Path segment \"{$idx}\" is not a number." );
				}
				$idx = (int) $idx;
				if ( ! isset( $level[ $idx ] ) ) {
					$max = count( $level ) - 1;
					return new WP_Error( 'bad_path', "Path index {$idx} out of range at depth {$depth_i} (valid: 0-{$max})." );
				}
				$node  = $level[ $idx ];
				$level = self::divi_children( $node );
			}
			$found_path = $path;
		} elseif ( $label !== '' ) {
			$matches = [];
			self::label_walk( $blocks, '', $label, $matches );
			if ( count( $matches ) === 0 ) {
				return new WP_Error( 'no_match', "No block with admin label matching \"{$label}\". Run divi_layout_outline to see what is on the page." );
			}
			if ( count( $matches ) > 1 ) {
				return [
					'ambiguous' => true,
					'matches'   => array_map( static function ( $m ) {
						return [ 'path' => $m['path'], 'block' => $m['block']['blockName'], 'admin_label' => $m['label'] ];
					}, $matches ),
					'note'      => 'Multiple blocks match — re-call with the path of the one you want.',
				];
			}
			$node       = $matches[0]['block'];
			$found_path = $matches[0]['path'];
		} elseif ( $block_name !== '' ) {
			$block_name = strpos( $block_name, '/' ) !== false ? $block_name : 'divi/' . $block_name;
			$nth        = max( 1, (int) ( $args['nth'] ?? 1 ) );
			$counter    = 0;
			$hit        = self::nth_walk( $blocks, '', $block_name, $nth, $counter );
			if ( ! $hit ) {
				return new WP_Error( 'no_match', "Found only {$counter} \"{$block_name}\" block(s); nth={$nth} is out of range." );
			}
			$node       = $hit['block'];
			$found_path = $hit['path'];
		} else {
			return new WP_Error( 'no_pointer', 'Pass path, admin_label, or block (+nth). Run divi_layout_outline first to choose.' );
		}

		$markup = serialize_block( $node );
		if ( strlen( $markup ) > 150000 ) {
			return new WP_Error( 'too_large', 'Captured subtree exceeds 150KB — point at a smaller subtree (a row or group instead of the section).' );
		}

		$modules = [];
		self::module_census( $node, $modules );
		$findings = [];
		self::lint_walk( $node, $found_path, $findings );
		$errors = array_values( array_filter( $findings, static function ( $f ) {
			return $f['severity'] === 'error';
		} ) );

		return [
			'post_id'            => $post_id,
			'path'               => $found_path,
			'block'              => $node['blockName'],
			'admin_label'        => self::attr_get( $node['attrs'] ?? [], [ 'module', 'meta', 'adminLabel', 'desktop', 'value' ] ),
			'modules'            => $modules,
			'attribute_richness' => self::score_block_attrs( $node ),
			'compliance'         => [
				'portable' => count( $errors ) === 0,
				'warnings' => $findings,
			],
			'markup'             => $markup,
		];
	}

	// ---------------------------------------------------------------------
	// Divi 4 scissors. Divi 4 is shortcodes, not blocks, so parse_blocks()
	// does not apply. We build an addressable tree from the [et_pb_*] tags and
	// slice captures as byte-exact substrings of the original content (no
	// re-serialization, so the markup round-trips perfectly). Node shape mirrors
	// the D5 block node (blockName / attrs / innerBlocks) so module_census and
	// the path walk are shared; D4-specific extraction (admin_label, module_class,
	// raw slice) lives in the *_d4 helpers below.
	// ---------------------------------------------------------------------

	private static function layout_outline_d4( WP_Post $post, int $max_depth ) {
		$tree = self::parsed_divi4_tree( (string) $post->post_content );
		if ( is_wp_error( $tree ) ) {
			return $tree;
		}
		$out = [];
		self::outline_walk_d4( $tree['blocks'], '', 1, $max_depth, $out );
		return [
			'post_id'    => $post->ID,
			'post_title' => $post->post_title,
			'engine'     => 'divi4',
			'count'      => count( $out ),
			'blocks'     => $out,
		];
	}

	private static function capture_d4( WP_Post $post, array $args ) {
		$tree = self::parsed_divi4_tree( (string) $post->post_content );
		if ( is_wp_error( $tree ) ) {
			return $tree;
		}
		$blocks     = $tree['blocks'];
		$path       = isset( $args['path'] ) ? trim( (string) $args['path'] ) : '';
		$label      = isset( $args['admin_label'] ) ? trim( (string) $args['admin_label'] ) : '';
		$block_name = isset( $args['block'] ) ? trim( (string) $args['block'] ) : '';

		$node       = null;
		$found_path = '';

		if ( $path !== '' ) {
			$level = $blocks;
			foreach ( explode( '.', $path ) as $depth_i => $idx ) {
				if ( ! ctype_digit( (string) $idx ) ) {
					return new WP_Error( 'bad_path', "Path segment \"{$idx}\" is not a number." );
				}
				$idx = (int) $idx;
				if ( ! isset( $level[ $idx ] ) ) {
					$max = count( $level ) - 1;
					return new WP_Error( 'bad_path', "Path index {$idx} out of range at depth {$depth_i} (valid: 0-{$max})." );
				}
				$node  = $level[ $idx ];
				$level = $node['innerBlocks'] ?? [];
			}
			$found_path = $path;
		} elseif ( $label !== '' ) {
			$matches = [];
			self::label_walk_d4( $blocks, '', $label, $matches );
			if ( count( $matches ) === 0 ) {
				return new WP_Error( 'no_match', "No module with admin_label matching \"{$label}\". Run divi_layout_outline first." );
			}
			if ( count( $matches ) > 1 ) {
				return [
					'ambiguous' => true,
					'matches'   => array_map( static function ( $m ) {
						return [ 'path' => $m['path'], 'block' => $m['block']['blockName'], 'admin_label' => $m['label'] ];
					}, $matches ),
					'note'      => 'Multiple modules match — re-call with the path of the one you want.',
				];
			}
			$node       = $matches[0]['block'];
			$found_path = $matches[0]['path'];
		} elseif ( $block_name !== '' ) {
			$block_name = strpos( $block_name, 'et_pb_' ) === 0 ? $block_name : 'et_pb_' . $block_name;
			$nth        = max( 1, (int) ( $args['nth'] ?? 1 ) );
			$counter    = 0;
			$hit        = self::nth_walk_d4( $blocks, '', $block_name, $nth, $counter );
			if ( ! $hit ) {
				return new WP_Error( 'no_match', "Found only {$counter} \"{$block_name}\" module(s); nth={$nth} is out of range." );
			}
			$node       = $hit['block'];
			$found_path = $hit['path'];
		} else {
			return new WP_Error( 'no_pointer', 'Pass path, admin_label, or block (+nth). Run divi_layout_outline first to choose.' );
		}

		$markup = $node['_raw'] ?? '';
		if ( strlen( $markup ) > 150000 ) {
			return new WP_Error( 'too_large', 'Captured subtree exceeds 150KB — point at a smaller subtree (a row instead of the section).' );
		}

		$modules = [];
		self::module_census( $node, $modules );
		$findings = [];
		self::lint_walk_d4( $node, $found_path, $findings );
		$errors = array_values( array_filter( $findings, static function ( $f ) {
			return $f['severity'] === 'error';
		} ) );

		return [
			'post_id'     => $post->ID,
			'engine'      => 'divi4',
			'path'        => $found_path,
			'block'       => $node['blockName'],
			'admin_label' => $node['attrs']['admin_label'] ?? '',
			'modules'     => $modules,
			'compliance'  => [
				'portable' => count( $errors ) === 0,
				'warnings' => $findings,
			],
			'markup'      => $markup,
		];
	}

	/**
	 * Parse Divi 4 builder shortcodes into an addressable tree.
	 *
	 * Walks every [et_pb_*] / [/et_pb_*] tag with a stack. Each node records its
	 * byte span in the ORIGINAL content (_start/_end) so capture slices it back
	 * out byte-perfectly. Tag positions are ASCII brackets, so byte offsets never
	 * land mid-multibyte. Builder output is always well-formed; a stray close with
	 * no open is ignored, and any unclosed node is closed at end-of-content.
	 */
	private static function parsed_divi4_tree( string $content ) {
		if ( strpos( $content, '[et_pb_' ) === false ) {
			return new WP_Error( 'not_divi4', 'Post content contains no Divi 4 builder shortcodes.' );
		}
		if ( ! preg_match_all( '/\[(\/?)(et_pb_[a-z0-9_]+)([^\]]*)\]/i', $content, $tokens, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ) ) {
			return new WP_Error( 'not_divi4', 'No Divi 4 shortcode tags found.' );
		}

		$roots = [];
		$stack = [];

		foreach ( $tokens as $tok ) {
			$full     = $tok[0][0];
			$tag_off  = (int) $tok[0][1];
			$is_close = '/' === $tok[1][0];
			$name     = strtolower( $tok[2][0] );

			if ( ! $is_close ) {
				$stack[] = [
					'blockName'   => $name,
					'attrs'       => self::parse_d4_atts( (string) $tok[3][0] ),
					'innerBlocks' => [],
					'_start'      => $tag_off,
					'_open_end'   => $tag_off + strlen( $full ),
					'_end'        => 0,
				];
				continue;
			}

			// Closing tag: find nearest matching open on the stack.
			$match = -1;
			for ( $k = count( $stack ) - 1; $k >= 0; $k-- ) {
				if ( $stack[ $k ]['blockName'] === $name ) {
					$match = $k;
					break;
				}
			}
			if ( $match < 0 ) {
				continue; // stray close, ignore
			}
			// Close any unclosed descendants sitting above the match (rare).
			while ( count( $stack ) - 1 > $match ) {
				$orphan = array_pop( $stack );
				self::finalize_d4_node( $orphan, $content, $tag_off );
				$stack[ count( $stack ) - 1 ]['innerBlocks'][] = $orphan;
			}
			$node = array_pop( $stack );
			self::finalize_d4_node( $node, $content, $tag_off + strlen( $full ) );
			if ( $stack ) {
				$stack[ count( $stack ) - 1 ]['innerBlocks'][] = $node;
			} else {
				$roots[] = $node;
			}
		}

		// Any still-open nodes: close at end of content.
		while ( $stack ) {
			$node = array_pop( $stack );
			self::finalize_d4_node( $node, $content, strlen( $content ) );
			if ( $stack ) {
				$stack[ count( $stack ) - 1 ]['innerBlocks'][] = $node;
			} else {
				$roots[] = $node;
			}
		}

		return [ 'blocks' => $roots, 'wrapped' => false ];
	}

	private static function finalize_d4_node( array &$node, string $content, int $end_off ): void {
		$node['_end'] = $end_off;
		$node['_raw'] = substr( $content, $node['_start'], $end_off - $node['_start'] );
		$inner        = substr( $content, $node['_open_end'], max( 0, $end_off - $node['_open_end'] ) );
		// Drop a trailing close tag and any nested shortcode tags for snippet text.
		$node['_text'] = preg_replace( '/\[\/?et_pb_[^\]]*\]/i', '', $inner );
	}

	private static function parse_d4_atts( string $attr_str ): array {
		$atts = [];
		if ( preg_match_all( '/([a-zA-Z0-9_\-]+)="([^"]*)"/', $attr_str, $mm, PREG_SET_ORDER ) ) {
			foreach ( $mm as $a ) {
				$atts[ $a[1] ] = $a[2];
			}
		}
		return $atts;
	}

	private static function outline_walk_d4( array $nodes, string $prefix, int $depth, int $max_depth, array &$out ): void {
		foreach ( $nodes as $i => $node ) {
			$path  = $prefix === '' ? (string) $i : $prefix . '.' . $i;
			$attrs = $node['attrs'] ?? [];
			$kids  = $node['innerBlocks'] ?? [];
			$entry = [
				'path'  => $path,
				'block' => $node['blockName'],
			];
			if ( ! empty( $attrs['admin_label'] ) ) {
				$entry['admin_label'] = $attrs['admin_label'];
			}
			if ( ! empty( $attrs['module_class'] ) ) {
				$entry['class'] = $attrs['module_class'];
			}
			if ( $kids ) {
				$entry['children'] = count( $kids );
			} else {
				$snippet = self::d4_snippet( $node );
				if ( $snippet !== '' ) {
					$entry['snippet'] = $snippet;
				}
			}
			$out[] = $entry;
			if ( $kids && ( $max_depth === 0 || $depth < $max_depth ) ) {
				self::outline_walk_d4( $kids, $path, $depth + 1, $max_depth, $out );
			}
		}
	}

	private static function d4_snippet( array $node ): string {
		$text = (string) ( $node['_text'] ?? '' );
		$text = trim( wp_strip_all_tags( $text ) );
		if ( $text === '' ) {
			return '';
		}
		return function_exists( 'mb_substr' ) ? mb_substr( $text, 0, 80 ) : substr( $text, 0, 80 );
	}

	private static function label_walk_d4( array $nodes, string $prefix, string $needle, array &$matches ): void {
		foreach ( $nodes as $i => $node ) {
			$path  = $prefix === '' ? (string) $i : $prefix . '.' . $i;
			$label = $node['attrs']['admin_label'] ?? '';
			if ( is_string( $label ) && $label !== '' && stripos( $label, $needle ) !== false ) {
				$matches[] = [ 'path' => $path, 'label' => $label, 'block' => $node ];
			}
			self::label_walk_d4( $node['innerBlocks'] ?? [], $path, $needle, $matches );
		}
	}

	private static function nth_walk_d4( array $nodes, string $prefix, string $name, int $nth, int &$counter ): ?array {
		foreach ( $nodes as $i => $node ) {
			$path = $prefix === '' ? (string) $i : $prefix . '.' . $i;
			if ( $node['blockName'] === $name ) {
				$counter++;
				if ( $counter === $nth ) {
					return [ 'path' => $path, 'block' => $node ];
				}
			}
			$hit = self::nth_walk_d4( $node['innerBlocks'] ?? [], $path, $name, $nth, $counter );
			if ( $hit ) {
				return $hit;
			}
		}
		return null;
	}

	/**
	 * Divi 4 portability lint. Lighter than the D5 four-clause lint for now —
	 * flags the clearest non-portable / non-editable cases. Extend as patterns
	 * surface during cookbook capture.
	 */
	private static function lint_walk_d4( array $node, string $path, array &$findings ): void {
		$name  = $node['blockName'] ?? '';
		$attrs = $node['attrs'] ?? [];
		if ( in_array( $name, [ 'et_pb_code', 'et_pb_fullwidth_code' ], true ) ) {
			$findings[] = [
				'path'     => $path,
				'block'    => $name,
				'severity' => 'warning',
				'code'     => 'code_module',
				'message'  => 'Code module — its contents are not editable in the Visual Builder. Prefer native modules so a non-Divi user can edit the text.',
			];
		}
		if ( ! empty( $attrs['module_class'] ) ) {
			$findings[] = [
				'path'     => $path,
				'block'    => $name,
				'severity' => 'info',
				'code'     => 'custom_class',
				'message'  => "Custom CSS class \"{$attrs['module_class']}\" — make sure any styling it carries travels with the specimen (or is reproducible via module settings).",
			];
		}
		foreach ( $node['innerBlocks'] ?? [] as $i => $child ) {
			$cp = $path === '' ? (string) $i : $path . '.' . $i;
			self::lint_walk_d4( $child, $cp, $findings );
		}
	}

	/**
	 * parse_blocks() + strip whitespace-only nodes + unwrap the divi/placeholder
	 * root that Divi 5.5+ adds around page content. Paths are relative to the
	 * UNWRAPPED tree so the same path works on pages with and without it.
	 */
	private static function parsed_divi_tree( string $content ) {
		if ( strpos( $content, 'wp:divi/' ) === false ) {
			return new WP_Error( 'not_divi5', 'Post content contains no Divi 5 blocks.' );
		}
		$blocks = self::divi_only( parse_blocks( $content ) );
		$wrapped = false;
		if ( count( $blocks ) === 1 && $blocks[0]['blockName'] === 'divi/placeholder' ) {
			$wrapped = true;
			$blocks  = self::divi_only( $blocks[0]['innerBlocks'] ?? [] );
		}
		return [ 'blocks' => $blocks, 'wrapped' => $wrapped ];
	}

	private static function divi_only( array $blocks ): array {
		return array_values( array_filter( $blocks, static function ( $b ) {
			return ! empty( $b['blockName'] );
		} ) );
	}

	private static function divi_children( array $block ): array {
		return self::divi_only( $block['innerBlocks'] ?? [] );
	}

	private static function outline_walk( array $blocks, string $prefix, int $depth, int $max_depth, array &$out ): void {
		foreach ( $blocks as $i => $block ) {
			$path  = $prefix === '' ? (string) $i : $prefix . '.' . $i;
			$attrs = $block['attrs'] ?? [];
			$kids  = self::divi_children( $block );
			$entry = [
				'path'  => $path,
				'block' => $block['blockName'],
			];
			$label = self::attr_get( $attrs, [ 'module', 'meta', 'adminLabel', 'desktop', 'value' ] );
			if ( is_string( $label ) && $label !== '' ) {
				$entry['admin_label'] = $label;
			}
			$class = self::attr_get( $attrs, [ 'module', 'advanced', 'htmlAttributes', 'desktop', 'value', 'class' ] );
			if ( is_string( $class ) && $class !== '' ) {
				$entry['class'] = $class;
			}
			if ( $kids ) {
				$entry['children'] = count( $kids );
			} else {
				$snippet = self::block_snippet( $block );
				if ( $snippet !== '' ) {
					$entry['snippet'] = $snippet;
				}
			}
			$out[] = $entry;
			if ( $kids && ( $max_depth === 0 || $depth < $max_depth ) ) {
				self::outline_walk( $kids, $path, $depth + 1, $max_depth, $out );
			}
		}
	}

	private static function block_snippet( array $block ): string {
		$attrs = $block['attrs'] ?? [];
		$candidates = [
			self::attr_get( $attrs, [ 'title', 'innerContent', 'desktop', 'value' ] ),
			self::attr_get( $attrs, [ 'content', 'innerContent', 'desktop', 'value' ] ),
			self::attr_get( $attrs, [ 'button', 'innerContent', 'desktop', 'value', 'text' ] ),
		];
		foreach ( $candidates as $c ) {
			if ( is_string( $c ) && $c !== '' ) {
				$text = trim( wp_strip_all_tags( $c ) );
				if ( $text !== '' ) {
					return function_exists( 'mb_substr' ) ? mb_substr( $text, 0, 80 ) : substr( $text, 0, 80 );
				}
			}
		}
		return '';
	}

	private static function label_walk( array $blocks, string $prefix, string $needle, array &$matches ): void {
		foreach ( $blocks as $i => $block ) {
			$path  = $prefix === '' ? (string) $i : $prefix . '.' . $i;
			$label = self::attr_get( $block['attrs'] ?? [], [ 'module', 'meta', 'adminLabel', 'desktop', 'value' ] );
			if ( is_string( $label ) && $label !== '' && stripos( $label, $needle ) !== false ) {
				$matches[] = [ 'path' => $path, 'label' => $label, 'block' => $block ];
			}
			self::label_walk( self::divi_children( $block ), $path, $needle, $matches );
		}
	}

	private static function nth_walk( array $blocks, string $prefix, string $name, int $nth, int &$counter ): ?array {
		foreach ( $blocks as $i => $block ) {
			$path = $prefix === '' ? (string) $i : $prefix . '.' . $i;
			if ( $block['blockName'] === $name ) {
				$counter++;
				if ( $counter === $nth ) {
					return [ 'path' => $path, 'block' => $block ];
				}
			}
			$hit = self::nth_walk( self::divi_children( $block ), $path, $name, $nth, $counter );
			if ( $hit ) {
				return $hit;
			}
		}
		return null;
	}

	private static function module_census( array $block, array &$out ): void {
		$name = $block['blockName'];
		$out[ $name ] = ( $out[ $name ] ?? 0 ) + 1;
		foreach ( self::divi_children( $block ) as $child ) {
			self::module_census( $child, $out );
		}
	}

	/**
	 * The four-clause portability lint. Walks the captured subtree and flags
	 * anything whose look would not survive the trip to a different site:
	 * theme-CSS classes, inline styles, hard-coded colors (vs design-variable /
	 * global-color references), and site-local presets. Code modules are
	 * flagged info-level as the labeled escape hatch.
	 */
	private static function lint_walk( array $block, string $path, array &$findings ): void {
		$name  = $block['blockName'];
		$attrs = $block['attrs'] ?? [];

		// Empty-module check: a content module with no attributes is almost
		// always one the editor silently emptied or dropped on save (seen with
		// the contact-form fields and a styled "Most Popular" text pill). It
		// passes the portability rules — there's nothing unportable about
		// nothing — so flag it here, or a blank module ships unnoticed.
		// Structural containers are allowed to be bare.
		$structural = [ 'divi/section', 'divi/row', 'divi/row-inner', 'divi/column', 'divi/column-inner', 'divi/group' ];
		if ( empty( $attrs ) && ! in_array( $name, $structural, true ) ) {
			$findings[] = [
				'rule'     => 'empty-module',
				'severity' => 'warn',
				'path'     => $path,
				'block'    => $name,
				'detail'   => "{$name} has no configured attributes — likely emptied or dropped by the editor on save. Confirm it still has its content/settings (re-build it via a raw post write if the page controller stripped it).",
			];
		}

		$class = self::attr_get( $attrs, [ 'module', 'advanced', 'htmlAttributes', 'desktop', 'value', 'class' ] );
		if ( is_string( $class ) && $class !== '' ) {
			$findings[] = [
				'rule'     => 'styles-must-travel',
				'severity' => 'error',
				'path'     => $path,
				'block'    => $name,
				'detail'   => "CSS class \"{$class}\" — its look likely lives in theme CSS and will not transfer.",
			];
		}

		$is_code = in_array( $name, [ 'divi/code', 'divi/fullwidth-code' ], true );
		if ( $is_code ) {
			$findings[] = [
				'rule'     => 'escape-hatch',
				'severity' => 'info',
				'path'     => $path,
				'block'    => $name,
				'detail'   => 'Code module — portable only if its CSS ships inside it; content is locked out of the Visual Builder.',
			];
		}

		$colors = [];
		self::scan_attr_strings( $attrs, $is_code, $path, $name, $findings, $colors );
		if ( $colors ) {
			$findings[] = [
				'rule'     => 'tokenize-colors',
				'severity' => 'warn',
				'path'     => $path,
				'block'    => $name,
				'detail'   => 'Hard-coded color(s) ' . implode( ', ', array_slice( array_keys( $colors ), 0, 8 ) ) . ' — reference a Design Variable or global color so a palette swap restyles them.',
			];
		}

		$presets = $attrs['modulePreset'] ?? null;
		if ( is_array( $presets ) ) {
			$custom = array_values( array_diff( $presets, [ 'default' ] ) );
			if ( $custom ) {
				$findings[] = [
					'rule'     => 'no-site-presets',
					'severity' => 'warn',
					'path'     => $path,
					'block'    => $name,
					'detail'   => 'References site-local preset(s) ' . implode( ', ', $custom ) . ' — those will not exist on a target site.',
				];
			}
		}

		foreach ( self::divi_children( $block ) as $i => $child ) {
			self::lint_walk( $child, $path . '.' . $i, $findings );
		}
	}

	/**
	 * Recursively scan attribute strings for inline styles / style tags
	 * (errors) and collect hard-coded color literals (the caller reports them
	 * as one warning per block). gcid- and var(--) references are token-safe.
	 */
	private static function scan_attr_strings( $value, bool $is_code, string $path, string $block_name, array &$findings, array &$colors ): void {
		if ( is_array( $value ) ) {
			foreach ( $value as $sub ) {
				self::scan_attr_strings( $sub, $is_code, $path, $block_name, $findings, $colors );
			}
			return;
		}
		if ( ! is_string( $value ) || $value === '' ) {
			return;
		}
		if ( ! $is_code && stripos( $value, '<style' ) !== false ) {
			$findings[] = [
				'rule'     => 'styles-must-travel',
				'severity' => 'error',
				'path'     => $path,
				'block'    => $block_name,
				'detail'   => 'Embedded style element inside a non-code module.',
			];
		}
		if ( ! $is_code && strpos( $value, '<' ) !== false && preg_match( '/<[^>]+\sstyle\s*=/i', $value ) ) {
			$findings[] = [
				'rule'     => 'styles-must-travel',
				'severity' => 'error',
				'path'     => $path,
				'block'    => $block_name,
				'detail'   => 'Inline style="" attribute inside innerContent HTML — invisible in the Visual Builder.',
			];
		}
		if ( preg_match( '#https?://[^"\'\s)]+/wp-content/uploads/[^"\'\s)]+#i', $value, $media ) ) {
			$findings[] = [
				'rule'     => 'site-media',
				'severity' => 'warn',
				'path'     => $path,
				'block'    => $block_name,
				'detail'   => "Media URL {$media[0]} points at a specific site's uploads — it breaks if that site moves. For library specimens use a neutral placeholder; on a customer page upload to their media library.",
			];
		}
		if ( strpos( $value, 'gcid-' ) === 0 || strpos( $value, 'var(--' ) === 0 ) {
			return; // token reference — travel-safe.
		}
		if ( preg_match_all( '/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b|rgba?\([^)]*\)/', $value, $m ) ) {
			foreach ( $m[0] as $hit ) {
				$colors[ $hit ] = true;
			}
		}
	}

	private static function attr_get( $arr, array $keys ) {
		foreach ( $keys as $k ) {
			if ( ! is_array( $arr ) || ! array_key_exists( $k, $arr ) ) {
				return null;
			}
			$arr = $arr[ $k ];
		}
		return $arr;
	}

	// ------------------------------------------------------------------
	// Design Variables (Divi 5 Variable Manager — gvid-* tokens)
	// ------------------------------------------------------------------

	private static function global_data_class(): ?string {
		$class = '\\ET\\Builder\\Packages\\GlobalData\\GlobalData';
		return class_exists( $class ) ? $class : null;
	}

	public static function variables_get( array $args ) {
		$gd = self::global_data_class();
		if ( ! $gd ) {
			return new WP_Error( 'no_divi5', 'Divi 5 GlobalData class not available — Design Variables need Divi 5.7+.' );
		}
		$raw = $gd::get_global_variables();
		$out = [];
		foreach ( (array) $raw as $type => $items ) {
			$items = (array) $items;
			$list  = [];
			foreach ( $items as $id => $item ) {
				$item = (array) $item;
				unset( $item['allowedActions'] );
				$list[] = array_merge( [ 'id' => $id ], $item );
			}
			usort( $list, static function ( $a, $b ) {
				return (int) ( $a['order'] ?? 0 ) <=> (int) ( $b['order'] ?? 0 );
			} );
			$out[ $type ] = $list;
		}
		return [
			'variables' => $out,
			'reference_syntax' => 'Use var(--gvid-xxxx) inside any CSS-valued module attribute. Color tokens stay in the global_colors system (gcid-*) — manage those with divi_global_colors_get/set.',
		];
	}

	public static function variables_set( array $args ) {
		$gd = self::global_data_class();
		if ( ! $gd ) {
			return new WP_Error( 'no_divi5', 'Divi 5 GlobalData class not available — Design Variables need Divi 5.7+.' );
		}
		$input = $args['variables'] ?? null;
		if ( ! is_array( $input ) || empty( $input ) ) {
			return new WP_Error( 'missing_variables', 'Pass variables as { "numbers": [ { "label": "...", "value": "..." } ], ... }.' );
		}
		$valid_types = [ 'numbers', 'strings', 'images', 'links', 'fonts', 'gradients' ];
		$merge       = array_key_exists( 'merge', $args ) ? (bool) $args['merge'] : true;

		$payload = [];
		$created = [];
		foreach ( $input as $type => $items ) {
			if ( ! in_array( $type, $valid_types, true ) ) {
				return new WP_Error( 'bad_type', "Unknown variable type \"{$type}\". Valid: " . implode( ', ', $valid_types ) . '. Colors go through divi_global_colors_set.' );
			}
			if ( ! is_array( $items ) ) {
				continue;
			}
			$order = 1;
			foreach ( $items as $item ) {
				$item = (array) $item;
				if ( empty( $item['label'] ) || ! isset( $item['value'] ) ) {
					return new WP_Error( 'bad_item', "Each {$type} item needs at least label and value." );
				}
				$id = $item['id'] ?? '';
				if ( ! is_string( $id ) || strpos( $id, 'gvid-' ) !== 0 ) {
					$id = 'gvid-' . wp_generate_uuid4();
				}
				$payload[ $type ][ $id ] = [
					'label'  => (string) $item['label'],
					'value'  => (string) $item['value'],
					'order'  => (int) ( $item['order'] ?? $order ),
					'status' => in_array( $item['status'] ?? 'active', [ 'active', 'archived' ], true ) ? ( $item['status'] ?? 'active' ) : 'active',
				];
				$created[] = [ 'id' => $id, 'type' => $type, 'label' => $item['label'], 'reference' => "var(--{$id})" ];
				$order++;
			}
		}
		if ( empty( $payload ) ) {
			return new WP_Error( 'missing_variables', 'No valid variable items found in input.' );
		}

		if ( $merge ) {
			$gd::import_global_variables( $payload );
		} else {
			$gd::set_global_variables( $payload );
		}

		return [
			'merged'    => $merge,
			'saved'     => $created,
			'note'      => 'Reference each variable in module attributes as var(--gvid-xxxx). The scissors lint already treats var(--) references as travel-safe.',
		];
	}

	// ------------------------------------------------------------------
	// Specimen library (built-in verified section recipes)
	// ------------------------------------------------------------------

	private static function specimens_dir(): string {
		return dirname( __DIR__ ) . '/library/specimens/';
	}

	/**
	 * Load every specimen JSON in the library. Cheap fields only unless
	 * $full — markup stays on disk until a specific specimen is requested.
	 */
	private static function specimen_index( bool $full = false ): array {
		$dir = self::specimens_dir();
		if ( ! is_dir( $dir ) && function_exists( 'wp_mkdir_p' ) ) {
			wp_mkdir_p( $dir );
		}
		$files  = is_dir( $dir ) ? glob( $dir . '*.json' ) : [];
		$engine = PCD_PC_Builder::divi_generation(); // 'divi4' | 'divi5' | 'none'
		$out    = [];
		foreach ( (array) $files as $file ) {
			$data = json_decode( (string) file_get_contents( $file ), true );
			if ( ! is_array( $data ) || empty( $data['name'] ) || empty( $data['markup'] ) ) {
				continue;
			}
			// Serve only specimens matching the site's active generation. A
			// specimen with no explicit engine is Divi 5 (the original library
			// predates the field). Prevents a D5 site listing d4- specimens and
			// vice-versa.
			$spec_engine = $data['engine'] ?? 'divi5';
			if ( $spec_engine !== $engine ) {
				continue;
			}
			if ( ! $full ) {
				unset( $data['markup'] );
			}
			$out[ $data['name'] ] = $data;
		}
		return $out;
	}

	private static function specimens_containing( string $block_name ): array {
		$hits = [];
		foreach ( self::specimen_index() as $name => $data ) {
			if ( in_array( $block_name, (array) ( $data['modules'] ?? [] ), true ) ) {
				$hits[] = $name;
			}
		}
		return $hits;
	}

	public static function specimen_list( array $args ): array {
		$list = [];
		foreach ( self::specimen_index() as $data ) {
			$list[] = [
				'name'        => $data['name'],
				'title'       => $data['title'] ?? $data['name'],
				'unit'        => $data['unit'] ?? 'section',
				'description' => $data['description'] ?? '',
				'modules'     => $data['modules'] ?? [],
			];
		}
		return [
			'count'     => count( $list ),
			'specimens' => $list,
			'note'      => 'Call divi_specimen_get with a name for the full markup, token requirements, and adaptation annotations. Prefer adapting a specimen over composing a common section from scratch.',
		];
	}

	public static function specimen_get( array $args ) {
		$name  = sanitize_key( (string) ( $args['name'] ?? '' ) );
		$index = self::specimen_index( true );
		if ( ! isset( $index[ $name ] ) ) {
			$have = implode( ', ', array_keys( $index ) );
			return new WP_Error( 'not_found', "No specimen \"{$name}\". Available: {$have}." );
		}
		return $index[ $name ];
	}
}
