<?php
/**
 * Strict Gutenberg content contract shared by imports and publications.
 *
 * @package PufferPDF
 */

defined( 'ABSPATH' ) || exit;

/**
 * Validates serialized Gutenberg content accepted by PufferPDF.
 */
final class PufferPDF_Block_Contract {

	/** Maximum serialized Gutenberg content accepted by the contract. */
	private const MAX_CONTENT_BYTES = 5242880;

	/** Maximum number of blocks accepted in one document. */
	private const MAX_BLOCK_COUNT = 5000;

	/** Maximum nested Gutenberg block depth accepted by the contract. */
	private const MAX_BLOCK_DEPTH = 8;

	/** Maximum number of HTML tokens inspected across one document. */
	private const MAX_HTML_TOKENS = 100000;

	/** Maximum HTML element depth accepted inside one block. */
	private const MAX_HTML_DEPTH = 16;

	/** Maximum byte length accepted for a generated heading anchor. */
	private const MAX_ANCHOR_BYTES = 128;

	/**
	 * Validate and normalize one complete serialized Gutenberg document.
	 *
	 * The returned blocks correspond exactly to the normalized content, with
	 * whitespace-only top-level freeform fragments removed.
	 *
	 * @param string $content Serialized Gutenberg content.
	 * @return array|WP_Error Normalized content and meaningful top-level blocks.
	 */
	public function validate_content( $content ) {
		if ( ! is_string( $content ) || '' === trim( $content ) || strlen( $content ) > self::MAX_CONTENT_BYTES ) {
			return new WP_Error(
				'pufferpdf_invalid_content_size',
				__( 'The converted content is empty or too large to import safely.', 'pufferpdf' ),
				array( 'status' => 413 )
			);
		}

		$parsed_blocks = parse_blocks( $content );
		if ( serialize_blocks( $parsed_blocks ) !== $content ) {
			return new WP_Error(
				'pufferpdf_malformed_block_serialization',
				__( 'The converted content contains malformed block delimiters.', 'pufferpdf' ),
				array( 'status' => 422 )
			);
		}

		$sanitized_content       = wp_kses_post( $content );
		$kses_comparable_content = preg_replace(
			'/<hr\b([^<>]*?)\s*\/>/i',
			'<hr$1 />',
			$content
		);
		if ( ! is_string( $kses_comparable_content ) || $sanitized_content !== $kses_comparable_content ) {
			return new WP_Error(
				'pufferpdf_unsafe_markup',
				__( 'The converted content contains unsafe markup.', 'pufferpdf' ),
				array( 'status' => 422 )
			);
		}

		$heading_anchors = array();
		$fragment_links  = array();
		$validation      = $this->validate_blocks( $parsed_blocks, $heading_anchors, $fragment_links );

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

		foreach ( $fragment_links as $fragment ) {
			if ( ! isset( $heading_anchors[ $fragment ] ) ) {
				return $this->invalid_import_markup_error();
			}
		}

		$normalized_blocks = parse_blocks( $sanitized_content );
		$meaningful_blocks = array_values(
			array_filter(
				$normalized_blocks,
				static function ( $block ) {
					$name       = isset( $block['blockName'] ) ? $block['blockName'] : null;
					$inner_html = isset( $block['innerHTML'] ) ? (string) $block['innerHTML'] : '';

					return ! ( empty( $name ) && '' === trim( $inner_html ) );
				}
			)
		);

		return array(
			'content' => $sanitized_content,
			'blocks'  => $meaningful_blocks,
		);
	}

	/**
	 * Validate the complete parsed block tree against the content contract.
	 *
	 * @param array $blocks          Parsed Gutenberg blocks.
	 * @param array $heading_anchors Validated heading anchors, keyed by ID.
	 * @param array $fragment_links  Validated fragment link targets.
	 * @return true|WP_Error
	 */
	private function validate_blocks( array $blocks, array &$heading_anchors, array &$fragment_links ) {
		$top_level_blocks = array(
			'core/paragraph',
			'core/heading',
			'core/list',
			'core/table',
			'core/code',
			'core/quote',
			'core/separator',
		);
		$block_count      = 0;
		$html_token_count = 0;
		$queue            = array();

		for ( $index = count( $blocks ) - 1; $index >= 0; --$index ) {
			$queue[] = array(
				'block'  => $blocks[ $index ],
				'parent' => null,
				'depth'  => 0,
			);
		}

		while ( ! empty( $queue ) ) {
			$current = array_pop( $queue );
			$block   = $current['block'];
			$parent  = $current['parent'];
			$depth   = $current['depth'];
			$name    = isset( $block['blockName'] ) ? $block['blockName'] : null;

			if ( empty( $name ) && null === $parent && '' === trim( (string) $block['innerHTML'] ) ) {
				continue;
			}
			$valid_nested_block =
				( 'core/list' === $parent && 'core/list-item' === $name ) ||
				( 'core/list-item' === $parent && 'core/list' === $name );

			if (
				empty( $name ) ||
				( null === $parent && ! in_array( $name, $top_level_blocks, true ) ) ||
				( null !== $parent && ! $valid_nested_block )
			) {
				return new WP_Error(
					'pufferpdf_forbidden_block',
					__( 'The converted content contains an unsupported or misplaced block.', 'pufferpdf' ),
					array( 'status' => 422 )
				);
			}

			if ( $depth > self::MAX_BLOCK_DEPTH ) {
				return new WP_Error(
					'pufferpdf_block_depth_exceeded',
					__( 'The converted document contains blocks nested too deeply.', 'pufferpdf' ),
					array( 'status' => 413 )
				);
			}

			++$block_count;
			if ( $block_count > self::MAX_BLOCK_COUNT ) {
				return new WP_Error(
					'pufferpdf_too_many_blocks',
					__( 'The converted document contains too many blocks for one import.', 'pufferpdf' ),
					array( 'status' => 413 )
				);
			}

			$attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
			$validation = $this->validate_block_attributes( $name, $attributes );
			if ( is_wp_error( $validation ) ) {
				return $validation;
			}

			$inner_blocks  = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array();
			$inner_content = isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ? $block['innerContent'] : array();
			$static_html   = '';
			$skeleton      = '';
			$slot_count    = 0;

			foreach ( $inner_content as $fragment ) {
				if ( null === $fragment ) {
					++$slot_count;
					$skeleton .= '<pufferpdf-slot></pufferpdf-slot>';
					continue;
				}

				if ( ! is_string( $fragment ) ) {
					return new WP_Error(
						'pufferpdf_invalid_block_markup',
						__( 'The converted content contains malformed block markup.', 'pufferpdf' ),
						array( 'status' => 422 )
					);
				}

				$static_html .= $fragment;
				$skeleton    .= $fragment;
			}

			if ( $static_html !== (string) $block['innerHTML'] ) {
				return new WP_Error(
					'pufferpdf_invalid_block_markup',
					__( 'The converted content contains malformed block markup.', 'pufferpdf' ),
					array( 'status' => 422 )
				);
			}

			if ( 'core/list' === $name ) {
				if ( empty( $inner_blocks ) || count( $inner_blocks ) !== $slot_count ) {
					return new WP_Error(
						'pufferpdf_invalid_block_nesting',
						__( 'The converted content contains malformed list nesting.', 'pufferpdf' ),
						array( 'status' => 422 )
					);
				}

				$markup = $skeleton;
			} elseif ( 'core/list-item' === $name ) {
				if ( count( $inner_blocks ) !== $slot_count ) {
					return new WP_Error(
						'pufferpdf_invalid_block_nesting',
						__( 'The converted content contains malformed list-item nesting.', 'pufferpdf' ),
						array( 'status' => 422 )
					);
				}

				$markup = $skeleton;
			} else {
				if ( ! empty( $inner_blocks ) || 0 !== $slot_count ) {
					return new WP_Error(
						'pufferpdf_invalid_block_nesting',
						__( 'The converted content contains unsupported block nesting.', 'pufferpdf' ),
						array( 'status' => 422 )
					);
				}

				$markup = (string) $block['innerHTML'];
			}

			$validation = $this->validate_block_markup(
				$name,
				$attributes,
				$markup,
				$html_token_count,
				count( $inner_blocks ),
				$heading_anchors,
				$fragment_links
			);
			if ( is_wp_error( $validation ) ) {
				return $validation;
			}

			for ( $index = count( $inner_blocks ) - 1; $index >= 0; --$index ) {
				$queue[] = array(
					'block'  => $inner_blocks[ $index ],
					'parent' => $name,
					'depth'  => $depth + 1,
				);
			}
		}

		return true;
	}

	/**
	 * Validate serialized comment attributes for one allowed block.
	 *
	 * @param string $name       Block name.
	 * @param array  $attributes Parsed block attributes.
	 * @return true|WP_Error
	 */
	private function validate_block_attributes( $name, array $attributes ) {
		$valid = empty( $attributes );

		if ( 'core/heading' === $name ) {
			$attribute_names = array_keys( $attributes );
			$unknown_names   = array_diff( $attribute_names, array( 'level', 'anchor' ) );
			$valid           = empty( $unknown_names );

			if ( array_key_exists( 'level', $attributes ) ) {
				$valid = $valid && is_int( $attributes['level'] ) && $attributes['level'] >= 1 && $attributes['level'] <= 6 && 2 !== $attributes['level'];
			}

			if ( array_key_exists( 'anchor', $attributes ) ) {
				$valid = $valid && is_string( $attributes['anchor'] ) && $this->is_valid_anchor( $attributes['anchor'] );
			}
		}

		if ( 'core/paragraph' === $name && ! empty( $attributes ) ) {
			$valid =
				array( 'direction' ) === array_keys( $attributes ) &&
				in_array( $attributes['direction'], array( 'ltr', 'rtl' ), true );
		}

		if ( 'core/list' === $name && ! empty( $attributes ) ) {
			$attribute_names = array_keys( $attributes );
			$unknown_names   = array_diff( $attribute_names, array( 'ordered', 'start' ) );
			$valid           = empty( $unknown_names ) && true === ( $attributes['ordered'] ?? false );

			if ( array_key_exists( 'start', $attributes ) ) {
				$valid = $valid && is_int( $attributes['start'] ) && $attributes['start'] >= 1;
			}
		}

		if ( ! $valid ) {
			return new WP_Error(
				'pufferpdf_invalid_block_attributes',
				__( 'The converted content contains unsupported block attributes.', 'pufferpdf' ),
				array( 'status' => 422 )
			);
		}

		return true;
	}

	/**
	 * Validate one block's saved HTML with a strict, balanced tag grammar.
	 *
	 * @param string $name             Block name.
	 * @param array  $attributes       Parsed block attributes.
	 * @param string $markup           Saved block HTML or list slot skeleton.
	 * @param int    $html_token_count Running token count for the document.
	 * @param int    $expected_slots   Expected list-item slot count.
	 * @param array  $heading_anchors  Validated heading anchors, keyed by ID.
	 * @param array  $fragment_links   Validated fragment link targets.
	 * @return true|WP_Error
	 */
	private function validate_block_markup( $name, array $attributes, $markup, &$html_token_count, $expected_slots, array &$heading_anchors, array &$fragment_links ) {
		$expected_root = $this->get_block_root( $name, $attributes );
		$processor     = new WP_HTML_Tag_Processor( $markup );
		$stack         = array();
		$root_count    = 0;
		$slot_count    = 0;
		$quote_count   = 0;
		$table_counts  = array(
			'TABLE' => 0,
			'THEAD' => 0,
			'TBODY' => 0,
		);
		$table_rows    = array();
		$media_tags    = array( 'AUDIO', 'CANVAS', 'EMBED', 'IFRAME', 'IMG', 'OBJECT', 'PICTURE', 'SOURCE', 'SVG', 'TRACK', 'VIDEO' );

		while ( $processor->next_token() ) {
			++$html_token_count;
			if ( $html_token_count > self::MAX_HTML_TOKENS ) {
				return new WP_Error(
					'pufferpdf_too_many_html_tokens',
					__( 'The converted document contains too much HTML markup.', 'pufferpdf' ),
					array( 'status' => 413 )
				);
			}

			$token_type = $processor->get_token_type();
			if ( '#text' === $token_type ) {
				$parent = empty( $stack ) ? '' : end( $stack );
				if ( ! $this->markup_parent_allows_text( $parent ) && '' !== trim( $processor->get_modifiable_text() ) ) {
					return $this->invalid_import_markup_error();
				}
				continue;
			}

			if ( '#tag' !== $token_type ) {
				return $this->invalid_import_markup_error();
			}

			$tag = $processor->get_tag();
			if ( in_array( $tag, $media_tags, true ) ) {
				return new WP_Error(
					'pufferpdf_embedded_media_forbidden',
					__( 'The converted content may not embed media.', 'pufferpdf' ),
					array( 'status' => 422 )
				);
			}

			if ( $processor->is_tag_closer() ) {
				if ( empty( $stack ) || end( $stack ) !== $tag ) {
					return $this->invalid_import_markup_error();
				}

				array_pop( $stack );
				continue;
			}

			$parent      = empty( $stack ) ? '' : end( $stack );
			$grandparent = count( $stack ) >= 2 ? $stack[ count( $stack ) - 2 ] : '';

			if ( '' === $parent ) {
				if ( $tag !== $expected_root || 0 !== $root_count ) {
					return $this->invalid_import_markup_error();
				}
				++$root_count;
			} elseif ( ! $this->markup_tag_is_allowed( $name, $tag, $parent, $grandparent, $stack ) ) {
				return $this->invalid_import_markup_error();
			}

			$validation = $this->validate_html_attributes( $processor, $tag, $name, $attributes, $heading_anchors, $fragment_links );
			if ( is_wp_error( $validation ) ) {
				return $validation;
			}

			if ( 'PUFFERPDF-SLOT' === $tag ) {
				++$slot_count;
			}
			if ( 'P' === $tag && 'BLOCKQUOTE' === $parent ) {
				++$quote_count;
			}
			if ( isset( $table_counts[ $tag ] ) ) {
				++$table_counts[ $tag ];
			}
			if ( 'TR' === $tag ) {
				$table_rows[] = array(
					'section' => $parent,
					'cells'   => 0,
				);
			}
			if ( 'TH' === $tag || 'TD' === $tag ) {
				$row_index = count( $table_rows ) - 1;
				if ( $row_index < 0 ) {
					return $this->invalid_import_markup_error();
				}
				++$table_rows[ $row_index ]['cells'];
			}

			$is_void = 'HR' === $tag;
			if ( $processor->has_self_closing_flag() && ! $is_void ) {
				return $this->invalid_import_markup_error();
			}
			if ( ! $is_void ) {
				$stack[] = $tag;
				if ( count( $stack ) > self::MAX_HTML_DEPTH ) {
					return new WP_Error(
						'pufferpdf_html_depth_exceeded',
						__( 'The converted document contains HTML nested too deeply.', 'pufferpdf' ),
						array( 'status' => 413 )
					);
				}
			}
		}

		if ( $processor->paused_at_incomplete_token() || ! empty( $stack ) || 1 !== $root_count ) {
			return $this->invalid_import_markup_error();
		}

		if ( 'core/list' === $name && ( $slot_count !== $expected_slots || 0 === $slot_count ) ) {
			return $this->invalid_import_markup_error();
		}

		if ( 'core/list-item' === $name && $slot_count !== $expected_slots ) {
			return $this->invalid_import_markup_error();
		}

		if ( 'core/quote' === $name && 1 !== $quote_count ) {
			return $this->invalid_import_markup_error();
		}

		if ( 'core/table' === $name && ! $this->validate_table_shape( $table_counts, $table_rows ) ) {
			return $this->invalid_import_markup_error();
		}

		return true;
	}

	/**
	 * Get the exact root element expected for an allowed block.
	 *
	 * @param string $name       Block name.
	 * @param array  $attributes Block attributes.
	 * @return string
	 */
	private function get_block_root( $name, array $attributes ) {
		$roots = array(
			'core/paragraph' => 'P',
			'core/list-item' => 'LI',
			'core/table'     => 'FIGURE',
			'core/code'      => 'PRE',
			'core/quote'     => 'BLOCKQUOTE',
			'core/separator' => 'HR',
		);

		if ( 'core/heading' === $name ) {
			return 'H' . ( isset( $attributes['level'] ) ? $attributes['level'] : 2 );
		}

		if ( 'core/list' === $name ) {
			return ! empty( $attributes['ordered'] ) ? 'OL' : 'UL';
		}

		return isset( $roots[ $name ] ) ? $roots[ $name ] : '';
	}

	/**
	 * Check whether an opening tag is legal at the current grammar position.
	 *
	 * @param string $block_name Block name.
	 * @param string $tag        Opening tag.
	 * @param string $parent_tag Parent tag.
	 * @param string $grandparent Grandparent tag.
	 * @param array  $stack      Open tag stack.
	 * @return bool
	 */
	private function markup_tag_is_allowed( $block_name, $tag, $parent_tag, $grandparent, array $stack ) {
		$inline_tags    = array( 'A', 'STRONG', 'EM', 'CODE' );
		$inline_parents = array( 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'TH', 'TD', 'A', 'STRONG', 'EM' );

		if ( in_array( $parent_tag, $inline_parents, true ) && in_array( $tag, $inline_tags, true ) ) {
			return 'A' !== $tag || ! in_array( 'A', $stack, true );
		}

		if ( 'core/list' === $block_name ) {
			return ( 'UL' === $parent_tag || 'OL' === $parent_tag ) && 'PUFFERPDF-SLOT' === $tag;
		}

		if ( 'core/list-item' === $block_name ) {
			return 'LI' === $parent_tag && 'PUFFERPDF-SLOT' === $tag;
		}

		if ( 'core/table' === $block_name ) {
			if ( 'FIGURE' === $parent_tag ) {
				return 'TABLE' === $tag;
			}
			if ( 'TABLE' === $parent_tag ) {
				return 'THEAD' === $tag || 'TBODY' === $tag;
			}
			if ( 'THEAD' === $parent_tag || 'TBODY' === $parent_tag ) {
				return 'TR' === $tag;
			}
			if ( 'TR' === $parent_tag ) {
				return ( 'THEAD' === $grandparent && 'TH' === $tag ) || ( 'TBODY' === $grandparent && 'TD' === $tag );
			}
		}

		if ( 'core/code' === $block_name ) {
			return 'PRE' === $parent_tag && 'CODE' === $tag;
		}

		if ( 'core/quote' === $block_name ) {
			return 'BLOCKQUOTE' === $parent_tag && 'P' === $tag;
		}

		return false;
	}

	/**
	 * Check whether non-whitespace text is legal inside a tag.
	 *
	 * @param string $parent_tag Parent tag or an empty string outside the root.
	 * @return bool
	 */
	private function markup_parent_allows_text( $parent_tag ) {
		return in_array(
			$parent_tag,
			array( 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'TH', 'TD', 'A', 'STRONG', 'EM', 'CODE' ),
			true
		);
	}

	/**
	 * Validate exact HTML attributes for one allowed saved-content tag.
	 *
	 * @param WP_HTML_Tag_Processor $processor       HTML token processor.
	 * @param string                $tag             Opening tag.
	 * @param string                $block_name      Current block name.
	 * @param array                 $block_attributes Current block attributes.
	 * @param array                 $heading_anchors Validated heading anchors, keyed by ID.
	 * @param array                 $fragment_links  Validated fragment link targets.
	 * @return true|WP_Error
	 */
	private function validate_html_attributes( WP_HTML_Tag_Processor $processor, $tag, $block_name, array $block_attributes, array &$heading_anchors, array &$fragment_links ) {
		$expected_names   = array();
		$expected_classes = null;

		if ( in_array( $tag, array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) ) {
			$expected_names   = array( 'class' );
			$expected_classes = array( 'wp-block-heading' );
			if ( 'core/heading' === $block_name && array_key_exists( 'anchor', $block_attributes ) ) {
				$expected_names[] = 'id';
			}
		} elseif ( 'P' === $tag && 'core/paragraph' === $block_name && array_key_exists( 'direction', $block_attributes ) ) {
			$expected_names = array( 'dir' );
		} elseif ( 'UL' === $tag || 'OL' === $tag ) {
			$expected_names   = array( 'class' );
			$expected_classes = array( 'wp-block-list' );
			if ( 'OL' === $tag && array_key_exists( 'start', $block_attributes ) ) {
				$expected_names[] = 'start';
			}
		} elseif ( 'FIGURE' === $tag ) {
			$expected_names   = array( 'class' );
			$expected_classes = array( 'wp-block-table' );
		} elseif ( 'TABLE' === $tag && $this->table_uses_fixed_layout_by_default() ) {
			$expected_names   = array( 'class' );
			$expected_classes = array( 'has-fixed-layout' );
		} elseif ( 'PRE' === $tag ) {
			$expected_names   = array( 'class' );
			$expected_classes = array( 'wp-block-code' );
		} elseif ( 'BLOCKQUOTE' === $tag ) {
			$expected_names   = array( 'class' );
			$expected_classes = array( 'wp-block-quote' );
		} elseif ( 'HR' === $tag ) {
			$expected_names   = array( 'class' );
			$expected_classes = array( 'has-alpha-channel-opacity', 'wp-block-separator' );
		} elseif ( 'A' === $tag ) {
			$expected_names = array( 'href' );
		}

		$attribute_names = $processor->get_attribute_names_with_prefix( '' );
		$attribute_names = is_array( $attribute_names ) ? $attribute_names : array();
		sort( $attribute_names, SORT_STRING );
		sort( $expected_names, SORT_STRING );

		if ( $attribute_names !== $expected_names ) {
			return $this->invalid_import_markup_error();
		}

		if ( null !== $expected_classes ) {
			$class_names = preg_split( '/[\t\n\f\r ]+/', trim( (string) $processor->get_attribute( 'class' ) ) );
			$class_names = array_values( array_filter( $class_names, 'strlen' ) );
			sort( $class_names, SORT_STRING );
			sort( $expected_classes, SORT_STRING );
			if ( $class_names !== $expected_classes ) {
				return $this->invalid_import_markup_error();
			}
		}

		if ( 'OL' === $tag && array_key_exists( 'start', $block_attributes ) ) {
			if ( (string) $block_attributes['start'] !== (string) $processor->get_attribute( 'start' ) ) {
				return $this->invalid_import_markup_error();
			}
		}

		if ( 'P' === $tag && 'core/paragraph' === $block_name && array_key_exists( 'direction', $block_attributes ) ) {
			if ( strtolower( (string) $processor->get_attribute( 'dir' ) ) !== $block_attributes['direction'] ) {
				return $this->invalid_import_markup_error();
			}
		}

		if ( in_array( $tag, array( 'H1', 'H2', 'H3', 'H4', 'H5', 'H6' ), true ) && array_key_exists( 'anchor', $block_attributes ) ) {
			$anchor = $processor->get_attribute( 'id' );
			if ( ! is_string( $anchor ) || $anchor !== $block_attributes['anchor'] || isset( $heading_anchors[ $anchor ] ) ) {
				return $this->invalid_import_markup_error();
			}
			$heading_anchors[ $anchor ] = true;
		}

		if ( 'A' === $tag ) {
			$href = $processor->get_attribute( 'href' );
			if ( is_string( $href ) && '#' === substr( $href, 0, 1 ) ) {
				$fragment = substr( $href, 1 );
				if ( ! $this->is_valid_anchor( $fragment ) ) {
					return $this->invalid_import_markup_error();
				}
				$fragment_links[] = $fragment;
			} else {
				$scheme = is_string( $href ) ? strtolower( (string) wp_parse_url( $href, PHP_URL_SCHEME ) ) : '';
				if ( ! in_array( $scheme, array( 'http', 'https', 'mailto' ), true ) || '' === esc_url_raw( $href, array( 'http', 'https', 'mailto' ) ) ) {
					return $this->invalid_import_markup_error();
				}
			}
		}

		return true;
	}

	/**
	 * Check the active core/table default, which differs between WP 6.5 and 7.0.
	 *
	 * @return bool
	 */
	private function table_uses_fixed_layout_by_default() {
		$block_type = WP_Block_Type_Registry::get_instance()->get_registered( 'core/table' );

		return $block_type && ! empty( $block_type->attributes['hasFixedLayout']['default'] );
	}

	/**
	 * Validate the table sections and equal row widths emitted by the converter.
	 *
	 * @param array $counts Section counts.
	 * @param array $rows   Row descriptors.
	 * @return bool
	 */
	private function validate_table_shape( array $counts, array $rows ) {
		if ( 1 !== $counts['TABLE'] || 1 !== $counts['THEAD'] || $counts['TBODY'] > 1 ) {
			return false;
		}

		$header_rows = array_values(
			array_filter(
				$rows,
				static function ( $row ) {
					return 'THEAD' === $row['section'];
				}
			)
		);
		$body_rows   = array_values(
			array_filter(
				$rows,
				static function ( $row ) {
					return 'TBODY' === $row['section'];
				}
			)
		);

		if (
			1 !== count( $header_rows ) ||
			$header_rows[0]['cells'] < 1 ||
			empty( $rows ) ||
			'THEAD' !== $rows[0]['section']
		) {
			return false;
		}

		if ( ( 0 === $counts['TBODY'] && ! empty( $body_rows ) ) || ( 1 === $counts['TBODY'] && empty( $body_rows ) ) ) {
			return false;
		}

		foreach ( $rows as $index => $row ) {
			if ( $index > 0 && 'TBODY' !== $row['section'] ) {
				return false;
			}

			if ( 'TBODY' === $row['section'] && $row['cells'] !== $header_rows[0]['cells'] ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Check a narrow ASCII ID grammar for server-generated anchors.
	 *
	 * @param string $anchor Candidate heading ID or fragment target.
	 * @return bool
	 */
	private function is_valid_anchor( $anchor ) {
		return '' !== $anchor &&
			strlen( $anchor ) <= self::MAX_ANCHOR_BYTES &&
			1 === preg_match( '/\A[a-z][a-z0-9]*(?:-[a-z0-9]+)*\z/', $anchor );
	}

	/**
	 * Create the common malformed-markup REST error.
	 *
	 * @return WP_Error
	 */
	private function invalid_import_markup_error() {
		return new WP_Error(
			'pufferpdf_invalid_block_markup',
			__( 'The converted content contains malformed or unsupported block markup.', 'pufferpdf' ),
			array( 'status' => 422 )
		);
	}
}
