<?php
/**
 * Append_blocks_structured tool: append 1–N blocks via strict JSON, server serializes to Gutenberg.
 *
 * @package AgenticWP
 */

namespace Agentic_WP;

use Agentic_WP\Error_Handler;

defined( 'ABSPATH' ) || exit;

/**
 * Append blocks structured tool for appending 1-N blocks via strict JSON.
 *
 * @since 1.0.0
 */
class Tool_Append_Blocks_Structured {
	/**
	 * Returns the OpenAI tool schema for appending structured blocks.
	 *
	 * @since 1.0.0
	 *
	 * @return array Tool schema definition.
	 */
	public static function schema(): array {
		return array(
			'type'        => 'function',
			'name'        => 'append_blocks_structured',
			'description' => 'Append 1–N Gutenberg blocks using a strict JSON structure. The server validates and serializes to block markup.',
			'strict'      => true,
			'parameters'  => array(
				'type'                 => 'object',
				'properties'           => array(
					'session_id' => array( 'type' => 'string' ),
					'blocks'     => array(
						'type'     => 'array',
						'items'    => array( '$ref' => '#/definitions/block' ),
						'minItems' => 1,
						'maxItems' => 8,
					),
				),
				'required'             => array( 'session_id', 'blocks' ),
				'additionalProperties' => false,
				'definitions'          => array(
					'block' => array(
						'type'                 => 'object',
						'properties'           => array(
							'slug'     => array( 'type' => 'string' ),
							'attrs'    => array(
								'type'        => 'string',
								'description' => 'JSON object string for block attributes.',
							),
							'text'     => array( 'type' => 'string' ),
							'html'     => array( 'type' => 'string' ),
							'children' => array(
								'type'  => 'array',
								'items' => array( '$ref' => '#/definitions/block' ),
							),
						),
						'required'             => array( 'slug', 'attrs', 'text', 'html', 'children' ),
						'additionalProperties' => false,
					),
				),
			),
		);
	}

	/**
	 * Executes the append blocks structured tool.
	 *
	 * @since 1.0.0
	 *
	 * @param array $args Tool arguments containing session_id and blocks.
	 * @return string JSON response or error message.
	 */
	public static function run( array $args ): string {
		$sid = isset( $args['session_id'] ) && is_string( $args['session_id'] ) ? sanitize_text_field( $args['session_id'] ) : '';
		if ( '' === $sid ) {
			return 'ERROR: missing_session_id';
		}
		$session = get_transient( Tool_Start_Post::tkey( $sid ) );
		if ( ! is_array( $session ) || empty( $session['post_id'] ) ) {
			return 'ERROR: invalid_session';
		}

		$blocks = isset( $args['blocks'] ) && is_array( $args['blocks'] ) ? $args['blocks'] : array();
		if ( empty( $blocks ) ) {
			return 'ERROR: missing_blocks';
		}

		$registry  = \WP_Block_Type_Registry::get_instance();
		$wp_blocks = array();

		foreach ( $blocks as $node ) {
			$built = self::build_block_array( $node, $registry );
			if ( is_string( $built ) && str_starts_with( $built, 'ERROR:' ) ) {
				return $built;
			}
			if ( is_array( $built ) ) {
				$wp_blocks[] = $built;
			}
		}

		if ( empty( $wp_blocks ) ) {
			return 'ERROR: serialization_failed';
		}

		if ( ! function_exists( 'serialize_blocks' ) ) {
			return 'ERROR: wp_block_functions_unavailable';
		}

		$serialized = serialize_blocks( $wp_blocks );

		$timeout                 = max( 5, (int) Settings::get_session_timeout_min() ) * MINUTE_IN_SECONDS;
		$session['content']     .= ( '' === $session['content'] ? '' : "\n" ) . $serialized;
		$session['bytes_total'] += strlen( $serialized );
		set_transient( Tool_Start_Post::tkey( $sid ), $session, $timeout );

		return wp_json_encode(
			array(
				'status'         => 'ok',
				'appended_bytes' => strlen( $serialized ),
				'total_bytes'    => (int) $session['bytes_total'],
			)
		);
	}

	/**
	 * Builds WordPress block array from structured node.
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $node     The node data to convert.
	 * @param mixed $registry The block registry instance.
	 * @return array|string Block array or error string.
	 */
	private static function build_block_array( $node, $registry ) {
		if ( ! is_array( $node ) ) {
			return self::error( 'invalid_block_node', 'Invalid node type; expected object.' );
		}
		$slug = isset( $node['slug'] ) ? (string) $node['slug'] : '';
		if ( '' === $slug ) {
			return self::error( 'missing_block_slug', 'Missing block slug.' );
		}
		if ( method_exists( $registry, 'is_registered' ) ) {
			if ( ! $registry->is_registered( $slug ) ) {
				return self::error( 'invalid_block_slug', 'Unknown block slug.', array( 'slug' => $slug ) );
			}
		} else {
			$all = $registry->get_all_registered();
			if ( ! isset( $all[ $slug ] ) ) {
				return self::error( 'invalid_block_slug', 'Unknown block slug.', array( 'slug' => $slug ) );
			}
		}

		$attrs = array();
		if ( isset( $node['attrs'] ) ) {
			if ( is_string( $node['attrs'] ) ) {
				$attrs_string = trim( $node['attrs'] );
				if ( '' === $attrs_string ) {
					$attrs = array();
				} else {
					$decoded = json_decode( $attrs_string, true );
					if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $decoded ) ) {
						return self::error( 'invalid_attrs_json', 'Attrs must be a valid JSON object string.' );
					}
					$attrs = $decoded;
				}
			} elseif ( is_array( $node['attrs'] ) ) {
				$attrs = $node['attrs'];
			} else {
				return self::error( 'invalid_attrs_type', 'Attrs must be an object or JSON string.' );
			}
		}

		$children_arr = array();
		if ( isset( $node['children'] ) ) {
			if ( ! is_array( $node['children'] ) ) {
				return self::error( 'invalid_children_type', 'Children must be an array of blocks.' );
			}
			foreach ( $node['children'] as $child ) {
				$built_child = self::build_block_array( $child, $registry );
				if ( is_string( $built_child ) && str_starts_with( $built_child, 'ERROR:' ) ) {
					return $built_child;
				}
				if ( is_array( $built_child ) ) {
					$children_arr[] = $built_child;
				}
			}
		}

		$text = isset( $node['text'] ) && is_string( $node['text'] ) ? $node['text'] : '';
		$html = isset( $node['html'] ) && is_string( $node['html'] ) ? $node['html'] : '';

		/**
		 * Filters whether to enable block adapters for content transformation.
		 *
		 * Block adapters allow custom processing and transformation of block content
		 * before it is appended to posts. When enabled, the system will attempt to
		 * use specialized adapters for each block type.
		 *
		 * @hook agentic_wp_enable_block_adapters
		 * @since 1.0.0
		 *
		 * @param bool $enabled Whether block adapters are enabled. Default true.
		 * @return bool Whether block adapters should be enabled.
		 */
		$adapters_enabled = (bool) apply_filters( 'agentic_wp_enable_block_adapters', true );
		if ( $adapters_enabled ) {
			$maybe = self::build_with_adapter( $slug, $attrs, $text, $html, $node, $registry );
			if ( is_array( $maybe ) ) {
				return $maybe;
			}
			if ( is_string( $maybe ) && str_starts_with( $maybe, 'ERROR:' ) ) {
				return $maybe;
			}
		}

		$inner_html = '';
		if ( empty( $children_arr ) ) {
			if ( '' !== $text ) {
				if ( 'core/paragraph' === $slug ) {
					$inner_html = '<p>' . esc_html( $text ) . '</p>';
				} elseif ( 'core/heading' === $slug ) {
					$level = 2;
					if ( isset( $attrs['level'] ) ) {
						$lvl = (int) $attrs['level'];
						if ( $lvl >= 1 && $lvl <= 6 ) {
							$level = $lvl;
						}
					}
					$inner_html = '<h' . $level . '>' . esc_html( $text ) . '</h' . $level . '>';
				} else {
					return self::error(
						'invalid_leaf_payload',
						'Provide html or use supported text+attrs adapters.',
						array(
							'slug' => $slug,
							'hint' => self::supported_adapters_hint(),
						)
					);
				}
			} elseif ( '' !== $html ) {
				$inner_html = wp_kses_post( $html );
			} else {
				return self::error(
					'missing_block_content',
					'Expected text for paragraph/heading or html for other blocks.',
					array(
						'slug' => $slug,
						'hint' => self::supported_adapters_hint(),
					)
				);
			}
		} else {
			return self::error(
				'unsupported_children_payload',
				'Children provided for a block without an adapter.',
				array(
					'slug' => $slug,
					'hint' => 'Use core/buttons or core/columns for nested content.',
				)
			);
		}

		$block = array(
			'blockName'   => $slug,
			'attrs'       => is_array( $attrs ) ? $attrs : array(),
			'innerBlocks' => $children_arr,
		);

		if ( ! empty( $children_arr ) ) {
			$block['innerContent'] = array_fill( 0, count( $children_arr ) + 1, '' );
		} else {
			$block['innerHTML']    = $inner_html;
			$block['innerContent'] = array( $inner_html );
		}

		return $block;
	}

	/**
	 * Builds block using specialized adapter for specific block types.
	 *
	 * @since 1.0.0
	 *
	 * @param string $slug     Block slug.
	 * @param array  $attrs    Block attributes.
	 * @param string $text     Block text content.
	 * @param string $html     Block HTML content.
	 * @param array  $node     Original node data.
	 * @param mixed  $registry Block registry instance.
	 * @return array|string|null Block array, error string, or null if no adapter.
	 */
	private static function build_with_adapter( string $slug, array $attrs, string $text, string $html, array $node, $registry ) {
		if ( ! function_exists( 'parse_blocks' ) || ! function_exists( 'serialize_blocks' ) ) {
			return null;
		}

		switch ( $slug ) {
			case 'core/quote':
				if ( '' === $text ) {
					return self::error( 'quote_missing_text', 'For core/quote provide text and optional attrs.citation.' );
				}
				$citation = isset( $attrs['citation'] ) && is_string( $attrs['citation'] ) ? trim( $attrs['citation'] ) : '';
				$inner    = '<blockquote class="wp-block-quote"><p>' . esc_html( $text ) . '</p>';
				if ( '' !== $citation ) {
					$inner .= '<cite>' . esc_html( $citation ) . '</cite>';
				}
				$inner .= '</blockquote>';
				$a_json = array();
				if ( '' !== $citation ) {
					$a_json['citation'] = $citation;
				}
				$wrapped = self::wrap_block_comment( 'core/quote', $a_json, $inner );
				$parsed  = parse_blocks( $wrapped );
				return is_array( $parsed ) && ! empty( $parsed ) ? $parsed[0] : self::error( 'parse_failed_quote', 'Failed to parse quote block.' );

			case 'core/list':
				$ordered = isset( $attrs['ordered'] ) ? (bool) $attrs['ordered'] : false;
				$items   = array();
				if ( isset( $attrs['items'] ) ) {
					if ( is_string( $attrs['items'] ) ) {
						$items = array_filter( array_map( 'trim', explode( "\n", $attrs['items'] ) ) );
					} elseif ( is_array( $attrs['items'] ) ) {
						foreach ( $attrs['items'] as $it ) {
							if ( is_string( $it ) ) {
								$t = trim( $it );
								if ( '' !== $t ) {
									$items[] = $t;
								}
							}
						}
					}
				}
				if ( empty( $items ) && '' !== $text ) {
					$items = array_filter( array_map( 'trim', explode( "\n", $text ) ) );
				}
				if ( empty( $items ) ) {
					return self::error( 'list_missing_items', 'For core/list provide attrs.items as ["..."] or newline text.' );
				}
				$tag = $ordered ? 'ol' : 'ul';
				$lis = '';
				foreach ( $items as $it ) {
					$lis .= '<li>' . esc_html( $it ) . '</li>';
				}
				$inner  = '<' . $tag . '>' . $lis . '</' . $tag . '>';
				$a_json = array();
				if ( $ordered ) {
					$a_json['ordered'] = true;
				}
				$wrapped = self::wrap_block_comment( 'core/list', $a_json, $inner );
				$parsed  = parse_blocks( $wrapped );
				return is_array( $parsed ) && ! empty( $parsed ) ? $parsed[0] : self::error( 'parse_failed_list', 'Failed to parse list block.' );

			case 'core/code':
				if ( '' === $text ) {
					return self::error( 'code_missing_text', 'For core/code provide text and optional attrs.language.' );
				}
				$language = isset( $attrs['language'] ) && is_string( $attrs['language'] ) ? trim( $attrs['language'] ) : '';
				$code_cls = '' !== $language ? ' class="language-' . esc_attr( $language ) . '"' : '';
				$inner    = '<pre class="wp-block-code"><code' . $code_cls . '>' . esc_html( $text ) . '</code></pre>';
				$a_json   = array();
				if ( '' !== $language ) {
					$a_json['language'] = $language;
				}
				$wrapped = self::wrap_block_comment( 'core/code', $a_json, $inner );
				$parsed  = parse_blocks( $wrapped );
				return is_array( $parsed ) && ! empty( $parsed ) ? $parsed[0] : self::error( 'parse_failed_code', 'Failed to parse code block.' );

			case 'core/image':
				$alt = isset( $attrs['alt'] ) && is_string( $attrs['alt'] ) ? $attrs['alt'] : '';
				$cap = isset( $attrs['caption'] ) && is_string( $attrs['caption'] ) ? $attrs['caption'] : '';

				$url = '';
				$id  = 0;
				if ( class_exists( __NAMESPACE__ . '\\Settings' ) ) {
					$url = (string) Settings::get_default_image_url();
					$id  = (int) Settings::get_default_image_id();
				}
				if ( '' === $url ) {
					return self::error( 'image_missing_default', 'No default image URL available.' );
				}

				$img_classes = '';
				if ( $id > 0 ) {
					$img_classes = ' class="wp-image-' . (int) $id . '"';
				}
				$img   = '<img src="' . esc_url( $url ) . '" alt="' . esc_attr( $alt ) . '"' . $img_classes . ' />';
				$inner = '<figure class="wp-block-image">' . $img;
				if ( trim( $cap ) !== '' ) {
					$inner .= '<figcaption>' . esc_html( $cap ) . '</figcaption>';
				}
				$inner .= '</figure>';
				$a_json = array( 'url' => $url );
				if ( $id > 0 ) {
					$a_json['id'] = $id;
				}
				if ( '' !== $alt ) {
					$a_json['alt'] = $alt;
				}
				$wrapped = self::wrap_block_comment( 'core/image', $a_json, $inner );
				$parsed  = parse_blocks( $wrapped );
				return is_array( $parsed ) && ! empty( $parsed ) ? $parsed[0] : self::error( 'parse_failed_image', 'Failed to parse image block.' );

			case 'core/table':
				$rows        = isset( $attrs['rows'] ) && is_array( $attrs['rows'] ) ? $attrs['rows'] : array();
				$has_header  = isset( $attrs['hasHeader'] ) ? (bool) $attrs['hasHeader'] : true;
				$has_caption = isset( $attrs['caption'] ) && is_string( $attrs['caption'] ) && trim( $attrs['caption'] ) !== '';
				$caption     = $has_caption ? trim( $attrs['caption'] ) : '';

				if ( empty( $rows ) ) {
					return self::error( 'table_missing_rows', 'For core/table provide attrs.rows as 2D array [[\"A\",\"B\"],[\"C\",\"D\"]].' );
				}

				$table_html = '<table>';
				if ( $has_header && ! empty( $rows ) ) {
					$header_row  = array_shift( $rows );
					$table_html .= '<thead><tr>';
					foreach ( $header_row as $cell ) {
						$table_html .= '<th>' . esc_html( (string) $cell ) . '</th>';
					}
					$table_html .= '</tr></thead>';
				}

				if ( ! empty( $rows ) ) {
					$table_html .= '<tbody>';
					foreach ( $rows as $row ) {
						if ( is_array( $row ) ) {
							$table_html .= '<tr>';
							foreach ( $row as $cell ) {
								$table_html .= '<td>' . esc_html( (string) $cell ) . '</td>';
							}
							$table_html .= '</tr>';
						}
					}
					$table_html .= '</tbody>';
				}
				$table_html .= '</table>';

				$inner = '<figure class="wp-block-table">' . $table_html;
				if ( $has_caption ) {
					$inner .= '<figcaption>' . esc_html( $caption ) . '</figcaption>';
				}
				$inner .= '</figure>';

				$a_json = array();
				if ( $has_header ) {
					$a_json['hasFixedLayout'] = false;
				}
				$wrapped = self::wrap_block_comment( 'core/table', $a_json, $inner );
				$parsed  = parse_blocks( $wrapped );
				return is_array( $parsed ) && ! empty( $parsed ) ? $parsed[0] : self::error( 'parse_failed_table', 'Failed to parse table block.' );

			case 'core/media-text':
				$media_position = isset( $attrs['mediaPosition'] ) && 'right' === $attrs['mediaPosition'] ? 'right' : 'left';
				$media_alt      = isset( $attrs['mediaAlt'] ) && is_string( $attrs['mediaAlt'] ) ? $attrs['mediaAlt'] : '';

				$url = '';
				$id  = 0;
				if ( class_exists( __NAMESPACE__ . '\\Settings' ) ) {
					$url = (string) Settings::get_default_image_url();
					$id  = (int) Settings::get_default_image_id();
				}
				if ( '' === $url ) {
					return self::error( 'media_text_missing_default', 'No default image URL available for media-text.' );
				}

				$inner_children_html = '';
				if ( isset( $node['children'] ) && is_array( $node['children'] ) ) {
					foreach ( $node['children'] as $ch ) {
						$built_child = self::build_block_array( $ch, $registry );
						if ( is_string( $built_child ) && str_starts_with( $built_child, 'ERROR:' ) ) {
							return $built_child;
						}
						if ( is_array( $built_child ) ) {
							$inner_children_html .= serialize_blocks( array( $built_child ) );
						}
					}
				}

				$img_classes = '';
				if ( $id > 0 ) {
					$img_classes = ' class="wp-image-' . (int) $id . '"';
				}
				$figure      = '<figure class="wp-block-media-text__media"><img src="' . esc_url( $url ) . '" alt="' . esc_attr( $media_alt ) . '"' . $img_classes . ' /></figure>';
				$align_class = 'right' === $media_position ? ' has-media-on-the-right' : '';
				$inner       = '<div class="wp-block-media-text' . $align_class . '">' . $figure . '<div class="wp-block-media-text__content">' . $inner_children_html . '</div></div>';

				$a_json = array(
					'mediaUrl'  => $url,
					'mediaType' => 'image',
				);
				if ( $id > 0 ) {
					$a_json['mediaId'] = $id;
				}
				if ( '' !== $media_alt ) {
					$a_json['mediaAlt'] = $media_alt;
				}
				if ( 'right' === $media_position ) {
					$a_json['mediaPosition'] = 'right';
				}
				$wrapped = self::wrap_block_comment( 'core/media-text', $a_json, $inner );
				$parsed  = parse_blocks( $wrapped );
				return is_array( $parsed ) && ! empty( $parsed ) ? $parsed[0] : self::error( 'parse_failed_media_text', 'Failed to parse media-text block.' );

			case 'core/buttons':
				$buttons = array();
				if ( isset( $node['children'] ) && is_array( $node['children'] ) && ! empty( $node['children'] ) ) {
					$buttons = $node['children'];
				} elseif ( isset( $attrs['buttons'] ) && is_array( $attrs['buttons'] ) ) {
					foreach ( $attrs['buttons'] as $b ) {
						if ( is_array( $b ) ) {
							$buttons[] = array(
								'slug'     => 'core/button',
								'attrs'    => array(
									'url' => isset( $b['url'] ) ? (string) $b['url'] : '#',
								),
								'text'     => isset( $b['text'] ) ? (string) $b['text'] : 'Learn more',
								'html'     => '',
								'children' => array(),
							);
						}
					}
				}
				if ( empty( $buttons ) ) {
					return self::error( 'buttons_missing_children', 'For core/buttons provide children core/button or attrs.buttons shorthand.' );
				}
				$children_html = '';
				foreach ( $buttons as $bn ) {
					if ( isset( $bn['attrs'] ) && is_string( $bn['attrs'] ) ) {
						$bn['attrs'] = json_decode( $bn['attrs'], true );
					}
					$btn = self::build_with_adapter( 'core/button', isset( $bn['attrs'] ) && is_array( $bn['attrs'] ) ? $bn['attrs'] : array(), isset( $bn['text'] ) ? (string) $bn['text'] : '', isset( $bn['html'] ) ? (string) $bn['html'] : '', $bn, $registry );
					if ( is_string( $btn ) && str_starts_with( $btn, 'ERROR:' ) ) {
						return $btn;
					}
					if ( is_array( $btn ) ) {
						$children_html .= serialize_blocks( array( $btn ) );
					}
				}
				$inner   = '<div class="wp-block-buttons">' . $children_html . '</div>';
				$wrapped = self::wrap_block_comment( 'core/buttons', array(), $inner );
				$parsed  = parse_blocks( $wrapped );
				return is_array( $parsed ) && ! empty( $parsed ) ? $parsed[0] : self::error( 'parse_failed_buttons', 'Failed to parse buttons block.' );

			case 'core/button':
				$url    = isset( $attrs['url'] ) && is_string( $attrs['url'] ) ? trim( $attrs['url'] ) : '';
				$label  = '' !== $text ? $text : ( isset( $attrs['text'] ) && is_string( $attrs['text'] ) ? $attrs['text'] : 'Learn more' );
				$a      = '<a class="wp-block-button__link"' . ( '' !== $url ? ' href="' . esc_url( $url ) . '"' : '' ) . '>' . esc_html( $label ) . '</a>';
				$inner  = '<div class="wp-block-button">' . $a . '</div>';
				$a_json = array();
				if ( '' !== $url ) {
					$a_json['url'] = $url;
				}
				$wrapped = self::wrap_block_comment( 'core/button', $a_json, $inner );
				$parsed  = parse_blocks( $wrapped );
				return is_array( $parsed ) && ! empty( $parsed ) ? $parsed[0] : self::error( 'parse_failed_button', 'Failed to parse button block.' );

			case 'core/columns':
				$cols = array();
				if ( isset( $node['children'] ) && is_array( $node['children'] ) && ! empty( $node['children'] ) ) {
					$cols = $node['children'];
				} elseif ( isset( $attrs['columns'] ) ) {
					$n = max( 1, (int) $attrs['columns'] );
					for ( $i = 0; $i < $n; $i++ ) {
						$cols[] = array(
							'slug'     => 'core/column',
							'attrs'    => array(),
							'text'     => '',
							'html'     => '',
							'children' => array(),
						);
					}
				}
				if ( empty( $cols ) ) {
					return self::error( 'columns_missing_children', 'For core/columns provide core/column children or attrs.columns number.' );
				}
				$cols_html = '';
				foreach ( $cols as $col ) {
					if ( isset( $col['attrs'] ) && is_string( $col['attrs'] ) ) {
						$col['attrs'] = json_decode( $col['attrs'], true );
					}
					$col_built = self::build_with_adapter( 'core/column', isset( $col['attrs'] ) && is_array( $col['attrs'] ) ? $col['attrs'] : array(), isset( $col['text'] ) ? (string) $col['text'] : '', isset( $col['html'] ) ? (string) $col['html'] : '', $col, $registry );
					if ( is_string( $col_built ) && str_starts_with( $col_built, 'ERROR:' ) ) {
						return $col_built;
					}
					if ( is_array( $col_built ) ) {
						$cols_html .= serialize_blocks( array( $col_built ) );
					}
				}
				$inner   = '<div class="wp-block-columns">' . $cols_html . '</div>';
				$wrapped = self::wrap_block_comment( 'core/columns', array(), $inner );
				$parsed  = parse_blocks( $wrapped );
				return is_array( $parsed ) && ! empty( $parsed ) ? $parsed[0] : self::error( 'parse_failed_columns', 'Failed to parse columns block.' );

			case 'core/column':
				$inner_children_html = '';
				if ( isset( $node['children'] ) && is_array( $node['children'] ) ) {
					foreach ( $node['children'] as $ch ) {
						$built_child = self::build_block_array( $ch, $registry );
						if ( is_string( $built_child ) && str_starts_with( $built_child, 'ERROR:' ) ) {
							return $built_child;
						}
						if ( is_array( $built_child ) ) {
							$inner_children_html .= serialize_blocks( array( $built_child ) );
						}
					}
				}
				$inner   = '<div class="wp-block-column">' . $inner_children_html . '</div>';
				$wrapped = self::wrap_block_comment( 'core/column', array(), $inner );
				$parsed  = parse_blocks( $wrapped );
				return is_array( $parsed ) && ! empty( $parsed ) ? $parsed[0] : self::error( 'parse_failed_column', 'Failed to parse column block.' );
		}

		return null;
	}

	/**
	 * Wraps block content with WordPress block comment markers.
	 *
	 * @since 1.0.0
	 *
	 * @param string $slug       Block slug.
	 * @param array  $attrs      Block attributes.
	 * @param string $inner_html Inner HTML content.
	 * @return string Block markup with comment delimiters.
	 */
	private static function wrap_block_comment( string $slug, array $attrs, string $inner_html ): string {
		$attr_json = '';
		if ( ! empty( $attrs ) ) {
			$attr_json = ' ' . wp_json_encode( $attrs );
		}
		return '<!-- wp:' . $slug . $attr_json . ' -->' . $inner_html . '<!-- /wp:' . $slug . ' -->';
	}

	/**
	 * Returns hint text listing supported block adapters.
	 *
	 * @since 1.0.0
	 *
	 * @return string Adapter hint text.
	 */
	private static function supported_adapters_hint(): string {
		return 'Adapters: core/paragraph, core/heading, core/quote, core/list, core/code, core/image, core/table, core/media-text, core/buttons, core/button, core/columns, core/column. Use text+attrs per docs.';
	}

	/**
	 * Formats and logs error message.
	 *
	 * @since 1.0.0
	 *
	 * @param string $code    Error code.
	 * @param string $message Error message.
	 * @param array  $context Optional. Error context data.
	 * @return string Formatted error string.
	 */
	private static function error( string $code, string $message, array $context = array() ): string {
		$slug  = isset( $context['slug'] ) ? (string) $context['slug'] : '';
		$hint  = isset( $context['hint'] ) ? (string) $context['hint'] : '';
		$parts = array( 'ERROR:' . $code, $message );
		if ( '' !== $slug ) {
			$parts[] = 'slug=' . $slug;
		}
		if ( '' !== $hint ) {
			$parts[] = $hint;
		}
		$msg = implode( ' | ', $parts );
		Error_Handler::log_error( 'append_blocks_structured', $msg, $context );
		return $msg;
	}
}
