<?php
/**
 * Canonical, zero-write Phase 3 publication planning.
 *
 * @package PufferPDF
 */

defined( 'ABSPATH' ) || exit;

/**
 * Validates a complete reviewed document and derives every publication item.
 */
final class PufferPDF_Publication_Planner {

	/** Maximum objects derived from document headings, excluding the companion. */
	private const MAX_ITEMS = 50;

	/** Maximum aggregate serialized output, including generated artifacts. */
	private const MAX_AGGREGATE_BYTES = 5242880;

	/** Maximum source page count. */
	private const MAX_PAGE_COUNT = 2000;

	/**
	 * Gutenberg block validator.
	 *
	 * @var PufferPDF_Block_Contract
	 */
	private $blocks;

	/**
	 * Destination capability service.
	 *
	 * @var PufferPDF_Destinations
	 */
	private $destinations;

	/**
	 * Source-record service.
	 *
	 * @var PufferPDF_Records
	 */
	private $records;

	/**
	 * Construct the planner.
	 *
	 * @param PufferPDF_Block_Contract $blocks Block contract.
	 * @param PufferPDF_Destinations   $destinations Destination resolver.
	 * @param PufferPDF_Records        $records Source verification service.
	 */
	public function __construct( PufferPDF_Block_Contract $blocks, PufferPDF_Destinations $destinations, PufferPDF_Records $records ) {
		$this->blocks       = $blocks;
		$this->destinations = $destinations;
		$this->records      = $records;
	}

	/**
	 * REST permission check for plans and publications.
	 *
	 * @return bool|WP_Error
	 */
	public function permissions_check() {
		if ( ! is_user_logged_in() ) {
			return new WP_Error(
				'pufferpdf_authentication_required',
				__( 'Sign in to plan PufferPDF publishing.', 'pufferpdf' ),
				array( 'status' => 401 )
			);
		}
		if ( ! current_user_can( 'edit_posts' ) ) {
			return new WP_Error(
				'pufferpdf_publication_forbidden',
				__( 'You do not have permission to plan PufferPDF publications.', 'pufferpdf' ),
				array( 'status' => 403 )
			);
		}

		return true;
	}

	/**
	 * Build and return one zero-write canonical preview.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function preview( WP_REST_Request $request ) {
		$payload = $request->get_json_params();
		if ( ! is_array( $payload ) ) {
			$payload = $request->get_params();
		}

		$plan = $this->build_plan( $payload );
		if ( is_wp_error( $plan ) ) {
			return $plan;
		}

		return rest_ensure_response( $this->format_plan_response( $plan ) );
	}

	/**
	 * Derive the authoritative plan used by preview and creation.
	 *
	 * @param array $payload Complete client payload.
	 * @return array|WP_Error
	 */
	public function build_plan( array $payload ) {
		$allowed_top = array(
			'schemaVersion',
			'requestId',
			'source',
			'document',
			'split',
			'destination',
			'overrides',
			'artifacts',
			'expectedPlanHash',
			'confirm',
		);
		if ( ! empty( array_diff( array_keys( $payload ), $allowed_top ) ) ) {
			return $this->invalid_plan( __( 'The publishing request contains unsupported fields.', 'pufferpdf' ) );
		}
		foreach ( array( 'source', 'document', 'destination', 'split', 'artifacts' ) as $object_field ) {
			if ( array_key_exists( $object_field, $payload ) && ! is_array( $payload[ $object_field ] ) ) {
				return $this->invalid_plan( __( 'Publishing request objects must use JSON object values.', 'pufferpdf' ) );
			}
		}

		if ( array_key_exists( 'schemaVersion', $payload ) && ! is_int( $payload['schemaVersion'] ) ) {
			return $this->invalid_plan( __( 'The publishing plan version must use an integer value.', 'pufferpdf' ) );
		}
		$schema_version = isset( $payload['schemaVersion'] ) ? $payload['schemaVersion'] : 1;
		if ( 1 !== $schema_version ) {
			return $this->invalid_plan( __( 'The publishing plan version is not supported.', 'pufferpdf' ) );
		}

		$source = $this->normalize_source( isset( $payload['source'] ) ? $payload['source'] : array() );
		if ( is_wp_error( $source ) ) {
			return $source;
		}

		$document = isset( $payload['document'] ) ? $payload['document'] : array();
		if ( ! empty( array_diff( array_keys( $document ), array( 'title', 'content', 'blockPages' ) ) ) ) {
			return $this->invalid_plan( __( 'The reviewed document contains unsupported fields.', 'pufferpdf' ) );
		}
		if ( isset( $document['content'] ) && ! is_string( $document['content'] ) ) {
			return $this->invalid_plan( __( 'Reviewed document content must use a string value.', 'pufferpdf' ) );
		}
		if ( ! array_key_exists( 'title', $document ) || ! is_string( $document['title'] ) ) {
			return $this->invalid_plan( __( 'Reviewed document title must use a string value.', 'pufferpdf' ) );
		}

		$document_title = $this->normalize_title( $document['title'] );
		if ( is_wp_error( $document_title ) ) {
			return $document_title;
		}

		$validated_document = $this->blocks->validate_content( isset( $document['content'] ) ? $document['content'] : '' );
		if ( is_wp_error( $validated_document ) ) {
			return $validated_document;
		}

		$block_pages = $this->normalize_block_pages(
			isset( $document['blockPages'] ) ? $document['blockPages'] : array(),
			count( $validated_document['blocks'] ),
			$source['pageCount']
		);
		if ( is_wp_error( $block_pages ) ) {
			return $block_pages;
		}

		$destination_input = isset( $payload['destination'] ) ? $payload['destination'] : array();
		$destination       = $this->destinations->normalize_destination( $destination_input );
		if ( is_wp_error( $destination ) ) {
			return $destination;
		}

		$split = $this->normalize_split( isset( $payload['split'] ) ? $payload['split'] : array() );
		if ( is_wp_error( $split ) ) {
			return $split;
		}

		$items = $this->split_document(
			$validated_document['blocks'],
			$block_pages,
			$document_title,
			$split,
			$validated_document['content']
		);
		if ( is_wp_error( $items ) ) {
			return $items;
		}

		$artifacts = $this->normalize_artifacts( isset( $payload['artifacts'] ) ? $payload['artifacts'] : array() );
		if ( is_wp_error( $artifacts ) ) {
			return $artifacts;
		}
		if ( $artifacts['toc'] && empty( $destination['viewable'] ) ) {
			return new WP_Error(
				'pufferpdf_toc_destination_unviewable',
				__( 'A generated table of contents requires a destination with public or preview URLs.', 'pufferpdf' ),
				array( 'status' => 422 )
			);
		}

		$override_limit = count( $items ) + ( $artifacts['companion'] ? 1 : 0 );
		$overrides      = $this->normalize_overrides( isset( $payload['overrides'] ) ? $payload['overrides'] : array(), $override_limit );
		if ( is_wp_error( $overrides ) ) {
			return $overrides;
		}

		foreach ( $items as $index => &$item ) {
			$override = isset( $overrides[ $index ] ) ? $overrides[ $index ] : array();
			$applied  = $this->apply_item_override( $item, $override, $destination, $index );
			if ( is_wp_error( $applied ) ) {
				return $applied;
			}
			$item             = $applied;
			$item['postType'] = $destination['postType'];
			$item['status']   = $destination['status'];
			$item['authorId'] = $destination['authorId'];
			$item['terms']    = $destination['terms'];

			if ( $artifacts['citations'] ) {
				$item['content'] .= "\n\n" . $this->build_source_citation_block( $source, $item['sourcePages'] );
			}

			$validated_item = $this->blocks->validate_content( $item['content'] );
			if ( is_wp_error( $validated_item ) ) {
				if ( 'heading' === $split['mode'] && 'pufferpdf_invalid_block_markup' === $validated_item->get_error_code() ) {
					return new WP_Error(
						'pufferpdf_split_fragment_link',
						__( 'The selected heading split would separate a local link from its target heading. Remove or replace that local link before publishing multiple posts.', 'pufferpdf' ),
						array( 'status' => 422 )
					);
				}
				return $validated_item;
			}
			$item['content']     = $validated_item['content'];
			$item['contentHash'] = hash( 'sha256', $item['content'] );
		}
		unset( $item );

		foreach ( $items as $item ) {
			if (
				! empty( $item['approved'] ) &&
				null !== $item['parentIndex'] &&
				( ! isset( $items[ $item['parentIndex'] ] ) || empty( $items[ $item['parentIndex'] ]['approved'] ) )
			) {
				return $this->invalid_plan( __( 'An approved child item cannot use an unapproved parent item.', 'pufferpdf' ) );
			}
		}

		$approved_outputs = array_values(
			array_filter(
				$items,
				static function ( $item ) {
					return ! empty( $item['approved'] );
				}
			)
		);
		if ( empty( $approved_outputs ) ) {
			return $this->invalid_plan( __( 'Approve at least one publishing item before continuing.', 'pufferpdf' ) );
		}

		$toc_entries = array();
		foreach ( $items as $item ) {
			if ( empty( $item['approved'] ) ) {
				continue;
			}
			$toc_entries[] = array(
				'index'       => $item['index'],
				'key'         => $item['key'],
				'title'       => $item['title'],
				'parentIndex' => $item['parentIndex'],
				'sourcePages' => $item['sourcePages'],
			);
		}

		$companion = null;
		if ( $artifacts['companion'] ) {
			if ( ! $source['attachmentId'] || '' === $source['url'] ) {
				return $this->invalid_plan( __( 'An accessible companion requires an existing Media Library PDF with a usable URL.', 'pufferpdf' ) );
			}

			$companion_destination = $this->destinations->normalize_companion_destination( $destination['status'] );
			if ( is_wp_error( $companion_destination ) ) {
				return $companion_destination;
			}

			$companion_index = count( $items );
			$companion       = $this->build_companion_item(
				$companion_index,
				$document_title,
				$items,
				$source,
				isset( $overrides[ $companion_index ] ) ? $overrides[ $companion_index ] : array(),
				$companion_destination
			);
			if ( is_wp_error( $companion ) ) {
				return $companion;
			}
			$items[] = $companion;
		}
		if ( count( $items ) > self::MAX_ITEMS ) {
			return new WP_Error(
				'pufferpdf_too_many_plan_items',
				__( 'The heading rule and generated companion create too many publishing items.', 'pufferpdf' ),
				array( 'status' => 413 )
			);
		}

		$output_bytes = 0;
		foreach ( $items as $item ) {
			if ( ! empty( $item['approved'] ) ) {
				$output_bytes += strlen( $item['content'] );
			}
		}
		$toc_estimate_bytes = $artifacts['toc'] ? $this->estimate_created_toc_bytes( $toc_entries ) : 0;
		$aggregate_bytes    = $output_bytes + $toc_estimate_bytes;
		if ( $aggregate_bytes > self::MAX_AGGREGATE_BYTES ) {
			return new WP_Error(
				'pufferpdf_plan_too_large',
				__( 'The approved content and generated artifacts exceed the safe aggregate publishing limit.', 'pufferpdf' ),
				array( 'status' => 413 )
			);
		}

		$items      = $this->attach_collisions( $items );
		$can_create = true;
		foreach ( $items as $item ) {
			if ( ! empty( $item['approved'] ) && ! empty( $item['collisions'] ) ) {
				$can_create = false;
				break;
			}
		}

		$canonical = array(
			'schemaVersion' => 1,
			'userId'        => get_current_user_id(),
			'source'        => $source,
			'document'      => array(
				'title'       => $document_title,
				'contentHash' => hash( 'sha256', $validated_document['content'] ),
				'blockPages'  => $block_pages,
			),
			'split'         => $split,
			'destination'   => $destination,
			'artifacts'     => $artifacts,
			'items'         => array_map( array( $this, 'canonicalize_item' ), $items ),
			'toc'           => $toc_entries,
		);
		$canonical = $this->sort_recursive( $canonical );
		$encoded   = wp_json_encode( $canonical, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		if ( ! is_string( $encoded ) ) {
			return new WP_Error(
				'pufferpdf_plan_encoding_failed',
				__( 'WordPress could not encode the canonical publishing plan.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}

		return array(
			'planHash'       => hash( 'sha256', $encoded ),
			'source'         => $source,
			'documentTitle'  => $document_title,
			'destination'    => $destination,
			'artifacts'      => $artifacts,
			'items'          => $items,
			'tocEntries'     => $toc_entries,
			'outputBytes'    => $output_bytes,
			'aggregateBytes' => $aggregate_bytes,
			'canCreate'      => $can_create,
		);
	}

	/**
	 * Format the public preview without leaking internal canonical content.
	 *
	 * @param array $plan Internal plan.
	 * @return array
	 */
	public function format_plan_response( array $plan ) {
		$items = array();
		foreach ( $plan['items'] as $item ) {
			$items[] = array(
				'index'       => $item['index'],
				'key'         => $item['key'],
				'kind'        => $item['kind'],
				'title'       => $item['title'],
				'slug'        => $item['slug'],
				'startPage'   => min( $item['sourcePages'] ),
				'endPage'     => max( $item['sourcePages'] ),
				'sourcePages' => $item['sourcePages'],
				'parentIndex' => $item['parentIndex'],
				'approved'    => $item['approved'],
				'collisions'  => $item['collisions'],
				'contentHash' => $item['contentHash'],
			);
		}

		return array(
			'planHash'  => $plan['planHash'],
			'items'     => $items,
			'toc'       => array(
				'entries' => $plan['tocEntries'],
			),
			'summary'   => array(
				'itemCount'      => count( $items ),
				'approvedCount'  => count(
					array_filter(
						$items,
						static function ( $item ) {
							return ! empty( $item['approved'] );
						}
					)
				),
				'aggregateBytes' => $plan['aggregateBytes'],
				'postType'       => $plan['destination']['postType'],
				'status'         => $plan['destination']['status'],
			),
			'canCreate' => $plan['canCreate'],
		);
	}

	/**
	 * Return deterministic mutex scopes for an approved plan.
	 *
	 * @param array  $plan Plan.
	 * @param string $request_id Idempotency key.
	 * @return string[]
	 */
	public function get_lock_keys( array $plan, $request_id ) {
		$keys = array(
			'publication-request:' . get_current_user_id() . ':' . $request_id,
		);
		if ( $plan['source']['attachmentId'] ) {
			$keys[] = 'attachment:' . $plan['source']['attachmentId'];
		} else {
			$keys[] = 'source-hash:' . $plan['source']['sourceHash'];
		}

		$title_namespaces = array();
		foreach ( $plan['items'] as $item ) {
			if ( empty( $item['approved'] ) ) {
				continue;
			}
			$scope                                 = null === $item['parentIndex'] ? 'existing:' . $item['parentId'] : 'planned:' . $item['parentIndex'];
			$keys[]                                = 'destination:' . $item['postType'] . ':' . $scope . ':' . $item['slug'];
			$title_namespaces[ $item['postType'] ] = true;
		}
		ksort( $title_namespaces, SORT_STRING );
		foreach ( array_keys( $title_namespaces ) as $post_type ) {
			$keys[] = 'destination-title-namespace:' . $post_type;
		}

		return array_values( array_unique( $keys ) );
	}

	/**
	 * Normalize source identity and inspection metadata.
	 *
	 * @param array $input Source payload.
	 * @return array|WP_Error
	 */
	private function normalize_source( array $input ) {
		$allowed = array(
			'filename',
			'attachmentId',
			'sourceHash',
			'engineVersion',
			'pdfType',
			'pageCount',
			'pagesNeedingOcr',
			'hasEncodingIssues',
			'profile',
			'pageDividers',
			'acknowledgeIncomplete',
		);
		if ( ! empty( array_diff( array_keys( $input ), $allowed ) ) ) {
			return $this->invalid_plan( __( 'The source descriptor contains unsupported fields.', 'pufferpdf' ) );
		}
		foreach ( array( 'hasEncodingIssues', 'pageDividers', 'acknowledgeIncomplete' ) as $boolean_field ) {
			if ( array_key_exists( $boolean_field, $input ) && ! is_bool( $input[ $boolean_field ] ) ) {
				return $this->invalid_plan( __( 'Source inspection flags must use JSON boolean values.', 'pufferpdf' ) );
			}
		}
		foreach ( array( 'filename', 'sourceHash', 'engineVersion', 'pdfType', 'profile' ) as $string_field ) {
			if ( array_key_exists( $string_field, $input ) && ! is_string( $input[ $string_field ] ) ) {
				return $this->invalid_plan( __( 'Source descriptor text fields must use string values.', 'pufferpdf' ) );
			}
		}
		if ( array_key_exists( 'attachmentId', $input ) && ! is_int( $input['attachmentId'] ) ) {
			return $this->invalid_plan( __( 'The source attachment ID must use an integer value.', 'pufferpdf' ) );
		}
		if ( array_key_exists( 'pageCount', $input ) && ! is_int( $input['pageCount'] ) ) {
			return $this->invalid_plan( __( 'The source page count must use an integer value.', 'pufferpdf' ) );
		}

		$attachment_id = isset( $input['attachmentId'] ) ? $input['attachmentId'] : 0;
		if ( $attachment_id < 0 ) {
			return $this->invalid_plan( __( 'The source attachment ID is invalid.', 'pufferpdf' ) );
		}
		$source_hash = strtolower( trim( isset( $input['sourceHash'] ) ? $input['sourceHash'] : '' ) );
		if ( ! preg_match( '/\A[a-f0-9]{64}\z/', $source_hash ) ) {
			return $this->invalid_plan( __( 'A complete SHA-256 source hash is required.', 'pufferpdf' ) );
		}

		$page_count = isset( $input['pageCount'] ) ? $input['pageCount'] : 0;
		if ( $page_count < 1 || $page_count > self::MAX_PAGE_COUNT ) {
			return $this->invalid_plan( __( 'The source page count is outside the supported range.', 'pufferpdf' ) );
		}

		$pdf_type = isset( $input['pdfType'] ) ? (string) $input['pdfType'] : '';
		if ( ! in_array( $pdf_type, array( 'TextBased', 'Scanned', 'ImageBased', 'Mixed' ), true ) ) {
			return $this->invalid_plan( __( 'The source PDF classification is invalid.', 'pufferpdf' ) );
		}
		if ( in_array( $pdf_type, array( 'Scanned', 'ImageBased' ), true ) ) {
			return new WP_Error(
				'pufferpdf_ocr_required',
				__( 'This PDF needs OCR before it can be published.', 'pufferpdf' ),
				array( 'status' => 422 )
			);
		}

		$raw_ocr_pages = isset( $input['pagesNeedingOcr'] ) ? $input['pagesNeedingOcr'] : array();
		if ( ! is_array( $raw_ocr_pages ) ) {
			return $this->invalid_plan( __( 'OCR page numbers must be provided as a list.', 'pufferpdf' ) );
		}
		if ( count( $raw_ocr_pages ) > self::MAX_PAGE_COUNT ) {
			return $this->invalid_plan( __( 'The OCR page list exceeds the supported page limit.', 'pufferpdf' ) );
		}

		$pages_needing_ocr = array();
		foreach ( $raw_ocr_pages as $value ) {
			if ( ! is_int( $value ) ) {
				return $this->invalid_plan( __( 'The OCR page list contains a non-integer value.', 'pufferpdf' ) );
			}
			$page = (int) $value;
			if ( $page < 1 || $page > $page_count ) {
				return $this->invalid_plan( __( 'An OCR page number is outside the source document.', 'pufferpdf' ) );
			}
			$pages_needing_ocr[] = $page;
		}
		$pages_needing_ocr = array_values( array_unique( $pages_needing_ocr ) );
		sort( $pages_needing_ocr, SORT_NUMERIC );

		$has_encoding_issues = isset( $input['hasEncodingIssues'] ) ? $input['hasEncodingIssues'] : false;
		$acknowledged        = isset( $input['acknowledgeIncomplete'] ) ? $input['acknowledgeIncomplete'] : false;
		if ( ( 'Mixed' === $pdf_type || ! empty( $pages_needing_ocr ) || $has_encoding_issues ) && ! $acknowledged ) {
			return new WP_Error(
				'pufferpdf_incomplete_not_acknowledged',
				__( 'Confirm that you understand the published content may be incomplete.', 'pufferpdf' ),
				array( 'status' => 422 )
			);
		}

		$engine_version = sanitize_text_field( isset( $input['engineVersion'] ) ? (string) $input['engineVersion'] : '' );
		if ( '' === $engine_version || strlen( $engine_version ) > 32 ) {
			return $this->invalid_plan( __( 'The PDF parser version is required.', 'pufferpdf' ) );
		}

		$profile = isset( $input['profile'] ) ? (string) $input['profile'] : 'fidelity';
		if ( ! in_array( $profile, array( 'fidelity', 'compact' ), true ) ) {
			return $this->invalid_plan( __( 'The conversion profile is invalid.', 'pufferpdf' ) );
		}

		$filename     = sanitize_file_name( isset( $input['filename'] ) ? (string) $input['filename'] : '' );
		$verification = 'browser';
		$bytes        = null;
		$mtime        = null;
		$url          = '';

		if ( $attachment_id ) {
			$verified = $this->records->validate_attachment_source( $attachment_id, $source_hash, 20971520 );
			if ( is_wp_error( $verified ) ) {
				return $verified;
			}
			if ( 'server' !== $verified['verification'] ) {
				return new WP_Error(
					'pufferpdf_attachment_unverifiable',
					__( 'Phase 3 publishing requires a Media Library PDF whose exact bytes WordPress can verify on this server.', 'pufferpdf' ),
					array( 'status' => 422 )
				);
			}
			$filename     = $verified['filename'];
			$source_hash  = $verified['hash'];
			$verification = $verified['verification'];
			$bytes        = $verified['bytes'];
			$mtime        = $verified['mtime'];
			$raw_url      = wp_get_attachment_url( $attachment_id );
			$url          = is_string( $raw_url ) ? esc_url_raw( $raw_url, array( 'http', 'https' ) ) : '';
		}

		if ( '' === $filename ) {
			$filename = 'document.pdf';
		}
		if ( strlen( $filename ) > 255 ) {
			return $this->invalid_plan( __( 'The source filename exceeds the supported 255-byte limit.', 'pufferpdf' ) );
		}

		return array(
			'attachmentId'          => $attachment_id,
			'filename'              => $filename,
			'sourceHash'            => $source_hash,
			'verification'          => $verification,
			'bytes'                 => $bytes,
			'mtime'                 => $mtime,
			'url'                   => $url,
			'engineVersion'         => $engine_version,
			'pdfType'               => $pdf_type,
			'pageCount'             => $page_count,
			'pagesNeedingOcr'       => $pages_needing_ocr,
			'hasEncodingIssues'     => $has_encoding_issues,
			'profile'               => $profile,
			'pageDividers'          => isset( $input['pageDividers'] ) ? $input['pageDividers'] : true,
			'acknowledgeIncomplete' => $acknowledged,
		);
	}

	/**
	 * Validate one page number per meaningful top-level block.
	 *
	 * @param mixed $input Requested map.
	 * @param int   $block_count Block count.
	 * @param int   $page_count Page count.
	 * @return int[]|WP_Error
	 */
	private function normalize_block_pages( $input, $block_count, $page_count ) {
		if ( ! is_array( $input ) || count( $input ) !== $block_count ) {
			return $this->invalid_plan( __( 'Provide exactly one source page for every top-level content block.', 'pufferpdf' ) );
		}

		$pages    = array();
		$previous = 0;
		foreach ( $input as $value ) {
			if ( ! is_int( $value ) ) {
				return $this->invalid_plan( __( 'The source page map contains a non-integer value.', 'pufferpdf' ) );
			}
			$page = (int) $value;
			if ( $page < 1 || $page > $page_count || $page < $previous ) {
				return $this->invalid_plan( __( 'The source page map must be monotonic and remain inside the document.', 'pufferpdf' ) );
			}
			$pages[]  = $page;
			$previous = $page;
		}

		return $pages;
	}

	/**
	 * Normalize a single or exact-level heading rule.
	 *
	 * @param array $input Split payload.
	 * @return array|WP_Error
	 */
	private function normalize_split( array $input ) {
		if ( ! empty( array_diff( array_keys( $input ), array( 'mode', 'level' ) ) ) ) {
			return $this->invalid_plan( __( 'The heading rule contains unsupported fields.', 'pufferpdf' ) );
		}
		if ( array_key_exists( 'mode', $input ) && ! is_string( $input['mode'] ) ) {
			return $this->invalid_plan( __( 'The heading-rule mode must use a string value.', 'pufferpdf' ) );
		}
		if ( array_key_exists( 'level', $input ) && ! is_int( $input['level'] ) ) {
			return $this->invalid_plan( __( 'The heading level must use an integer value.', 'pufferpdf' ) );
		}

		$mode  = isset( $input['mode'] ) ? $input['mode'] : 'single';
		$level = isset( $input['level'] ) ? $input['level'] : 0;
		if ( 'single' === $mode ) {
			if ( 0 !== $level ) {
				return $this->invalid_plan( __( 'Single-document output must use heading level zero.', 'pufferpdf' ) );
			}
			return array(
				'mode'  => 'single',
				'level' => 0,
			);
		}
		if ( 'heading' !== $mode || ! in_array( $level, array( 1, 2, 3 ), true ) ) {
			return $this->invalid_plan( __( 'Choose single-document output or an exact heading level from one to three.', 'pufferpdf' ) );
		}

		return array(
			'mode'  => 'heading',
			'level' => $level,
		);
	}

	/**
	 * Split consecutive validated block ranges without trusting client items.
	 *
	 * @param array  $blocks Complete top-level blocks.
	 * @param int[]  $block_pages Page per block.
	 * @param string $document_title Reviewed title.
	 * @param array  $split Split rule.
	 * @param string $complete_content Complete validated serialization.
	 * @return array|WP_Error
	 */
	private function split_document( array $blocks, array $block_pages, $document_title, array $split, $complete_content ) {
		if ( 'single' === $split['mode'] ) {
			return array(
				$this->new_item( 0, 'document', $document_title, $complete_content, $block_pages ),
			);
		}

		$boundaries = array();
		foreach ( $blocks as $index => $block ) {
			if ( 'core/heading' === $block['blockName'] && $this->get_heading_level( $block ) === $split['level'] ) {
				$boundaries[] = $index;
			}
		}
		if ( empty( $boundaries ) ) {
			return $this->invalid_plan( __( 'The selected heading rule must produce at least two publishing items.', 'pufferpdf' ) );
		}

		$items      = array();
		$first      = $boundaries[0];
		$item_index = 0;
		if ( $first > 0 ) {
			$preamble_blocks = array_slice( $blocks, 0, $first );
			$preamble_pages  = array_slice( $block_pages, 0, $first );
			$items[]         = $this->new_item( $item_index++, 'overview', $document_title, serialize_blocks( $preamble_blocks ), $preamble_pages );
		}

		foreach ( $boundaries as $boundary_position => $start ) {
			$end         = isset( $boundaries[ $boundary_position + 1 ] ) ? $boundaries[ $boundary_position + 1 ] : count( $blocks );
			$body_blocks = array_slice( $blocks, $start + 1, $end - $start - 1 );
			if ( empty( $body_blocks ) ) {
				return $this->invalid_plan( __( 'A split heading does not contain any section content.', 'pufferpdf' ) );
			}

			$title = $this->extract_heading_title( $blocks[ $start ] );
			if ( is_wp_error( $title ) ) {
				return $title;
			}
			$pages                     = array_merge(
				array( $block_pages[ $start ] ),
				array_slice( $block_pages, $start + 1, $end - $start - 1 )
			);
			$item                      = $this->new_item( $item_index++, 'section', $title, serialize_blocks( $body_blocks ), $pages );
			$item['boundaryLevel']     = $split['level'];
			$item['companionBoundary'] = $blocks[ $start ];
			$items[]                   = $item;
		}

		if ( count( $items ) < 2 ) {
			return $this->invalid_plan( __( 'The selected heading rule must produce at least two publishing items.', 'pufferpdf' ) );
		}

		if ( count( $items ) > self::MAX_ITEMS ) {
			return new WP_Error(
				'pufferpdf_too_many_plan_items',
				__( 'The heading rule creates too many publishing items.', 'pufferpdf' ),
				array( 'status' => 413 )
			);
		}

		return $items;
	}

	/**
	 * Create a normalized initial plan item.
	 *
	 * @param int    $index Item index.
	 * @param string $kind Item kind.
	 * @param string $title Item title.
	 * @param string $content Serialized body.
	 * @param int[]  $pages Contributing pages.
	 * @return array
	 */
	private function new_item( $index, $kind, $title, $content, array $pages ) {
		$pages = array_values( array_unique( array_map( 'absint', $pages ) ) );
		sort( $pages, SORT_NUMERIC );

		return array(
			'index'          => (int) $index,
			'key'            => substr( hash( 'sha256', $index . "\0" . $kind . "\0" . $title ), 0, 24 ),
			'kind'           => $kind,
			'title'          => $title,
			'companionTitle' => $title,
			'slug'           => $this->normalize_slug( $title, $index ),
			'baseContent'    => $content,
			'content'        => $content,
			'sourcePages'    => $pages,
			'parentIndex'    => null,
			'parentId'       => 0,
			'approved'       => true,
			'boundaryLevel'  => 0,
			'collisions'     => array(),
		);
	}

	/**
	 * Normalize sparse per-index overrides.
	 *
	 * @param mixed $input Overrides.
	 * @param int   $maximum Maximum index plus one.
	 * @return array|WP_Error
	 */
	private function normalize_overrides( $input, $maximum ) {
		if ( null === $input || array() === $input ) {
			return array();
		}
		if ( ! is_array( $input ) || count( $input ) > $maximum ) {
			return $this->invalid_plan( __( 'The item override list is invalid or oversized.', 'pufferpdf' ) );
		}

		$normalized = array();
		foreach ( $input as $override ) {
			if ( ! is_array( $override ) || ! isset( $override['index'] ) ) {
				return $this->invalid_plan( __( 'Each item override must identify a plan index.', 'pufferpdf' ) );
			}
			if ( ! empty( array_diff( array_keys( $override ), array( 'index', 'approved', 'title', 'slug', 'parentIndex' ) ) ) ) {
				return $this->invalid_plan( __( 'An item override contains unsupported fields.', 'pufferpdf' ) );
			}
			if ( array_key_exists( 'approved', $override ) && ! is_bool( $override['approved'] ) ) {
				return $this->invalid_plan( __( 'Item approval overrides must use JSON boolean values.', 'pufferpdf' ) );
			}
			if ( ! is_int( $override['index'] ) ) {
				return $this->invalid_plan( __( 'Item override indexes must use integer values.', 'pufferpdf' ) );
			}
			if ( array_key_exists( 'title', $override ) && ! is_string( $override['title'] ) ) {
				return $this->invalid_plan( __( 'Item override titles must use string values.', 'pufferpdf' ) );
			}
			if ( array_key_exists( 'slug', $override ) && ! is_string( $override['slug'] ) ) {
				return $this->invalid_plan( __( 'Item override slugs must use string values.', 'pufferpdf' ) );
			}
			if ( array_key_exists( 'parentIndex', $override ) && null !== $override['parentIndex'] && ! is_int( $override['parentIndex'] ) ) {
				return $this->invalid_plan( __( 'Item parent indexes must use integer or null values.', 'pufferpdf' ) );
			}

			$index = $override['index'];
			if ( $index < 0 || $index >= $maximum || isset( $normalized[ $index ] ) ) {
				return $this->invalid_plan( __( 'An item override references an invalid or duplicate index.', 'pufferpdf' ) );
			}
			$normalized[ $index ] = $override;
		}

		ksort( $normalized, SORT_NUMERIC );

		return $normalized;
	}

	/**
	 * Apply one validated client override.
	 *
	 * @param array $item Item.
	 * @param array $override Override.
	 * @param array $destination Destination.
	 * @param int   $index Item index.
	 * @return array|WP_Error
	 */
	private function apply_item_override( array $item, array $override, array $destination, $index ) {
		if ( array_key_exists( 'approved', $override ) ) {
			$item['approved'] = $override['approved'];
		}
		if ( array_key_exists( 'title', $override ) ) {
			$title = $this->normalize_title( $override['title'] );
			if ( is_wp_error( $title ) ) {
				return $title;
			}
			$item['title'] = $title;
		}
		if ( array_key_exists( 'slug', $override ) ) {
			$slug = sanitize_title( $override['slug'] );
			if ( '' === $slug ) {
				return $this->invalid_plan( __( 'Every approved item needs a non-empty URL slug.', 'pufferpdf' ) );
			}
			$item['slug'] = $this->truncate_slug( $slug );
		}

		$parent_index = null;
		if ( array_key_exists( 'parentIndex', $override ) && null !== $override['parentIndex'] ) {
			$parent_index = $override['parentIndex'];
		}
		if ( null !== $parent_index ) {
			if ( ! $destination['hierarchical'] || $parent_index < 0 || $parent_index >= $index ) {
				return $this->invalid_plan( __( 'An item parent must be an earlier item of a hierarchical post type.', 'pufferpdf' ) );
			}
			$item['parentIndex'] = $parent_index;
			$item['parentId']    = 0;
		} else {
			$item['parentIndex'] = null;
			$item['parentId']    = $destination['parentId'];
		}

		return $item;
	}

	/**
	 * Normalize requested generated artifacts.
	 *
	 * @param array $input Artifact flags.
	 * @return array|WP_Error
	 */
	private function normalize_artifacts( array $input ) {
		if ( ! empty( array_diff( array_keys( $input ), array( 'toc', 'citations', 'companion' ) ) ) ) {
			return $this->invalid_plan( __( 'The generated-artifact request contains unsupported fields.', 'pufferpdf' ) );
		}
		foreach ( array( 'toc', 'citations', 'companion' ) as $boolean_field ) {
			if ( array_key_exists( $boolean_field, $input ) && ! is_bool( $input[ $boolean_field ] ) ) {
				return $this->invalid_plan( __( 'Generated-artifact flags must use JSON boolean values.', 'pufferpdf' ) );
			}
		}

		return array(
			'toc'       => isset( $input['toc'] ) ? $input['toc'] : false,
			'citations' => isset( $input['citations'] ) ? $input['citations'] : false,
			'companion' => isset( $input['companion'] ) ? $input['companion'] : false,
		);
	}

	/**
	 * Build a full companion item with local-anchor TOC and original PDF link.
	 *
	 * @param int    $index Companion plan index.
	 * @param string $document_title Document title.
	 * @param array  $items Output items.
	 * @param array  $source Source descriptor.
	 * @param array  $override Companion override.
	 * @param array  $destination Page destination.
	 * @return array|WP_Error
	 */
	private function build_companion_item( $index, $document_title, array $items, array $source, array $override, array $destination ) {
		$title = sprintf(
			/* translators: %s: reviewed document title. */
			__( '%s — Accessible HTML', 'pufferpdf' ),
			$document_title
		);
		$source_pages = array();
		foreach ( $items as $item ) {
			$source_pages = array_merge( $source_pages, $item['sourcePages'] );
		}
		$source_pages = array_values( array_unique( array_map( 'absint', $source_pages ) ) );
		sort( $source_pages, SORT_NUMERIC );

		$companion                = $this->new_item( $index, 'companion', $title, '<!-- wp:paragraph --><p>Placeholder</p><!-- /wp:paragraph -->', $source_pages );
		$companion['postType']    = 'page';
		$companion['status']      = $destination['status'];
		$companion['authorId']    = $destination['authorId'];
		$companion['terms']       = array();
		$companion['parentId']    = 0;
		$companion['parentIndex'] = null;
		if ( isset( $override['parentIndex'] ) && null !== $override['parentIndex'] && '' !== $override['parentIndex'] ) {
			return $this->invalid_plan( __( 'The accessible companion must remain a root page.', 'pufferpdf' ) );
		}
		$companion = $this->apply_item_override( $companion, $override, $destination, $index );
		if ( is_wp_error( $companion ) ) {
			return $companion;
		}
		$companion['approved'] = true;
		if ( ! array_key_exists( 'slug', $override ) ) {
			$companion['slug'] = $this->normalize_slug( sanitize_title( $document_title ) . '-html', $index );
		}

		$reserved_anchors = array();
		foreach ( $items as $item ) {
			$body_blocks = parse_blocks( $item['baseContent'] );
			foreach ( $body_blocks as $block ) {
				if ( 'core/heading' === $block['blockName'] && ! empty( $block['attrs']['anchor'] ) ) {
					$reserved_anchors[ $block['attrs']['anchor'] ] = true;
				}
			}
			if ( isset( $item['companionBoundary']['attrs']['anchor'] ) && '' !== $item['companionBoundary']['attrs']['anchor'] ) {
				$reserved_anchors[ $item['companionBoundary']['attrs']['anchor'] ] = true;
			}
		}
		$contents_anchor = 'pufferpdf-contents';
		$contents_suffix = 2;
		while ( isset( $reserved_anchors[ $contents_anchor ] ) ) {
			$contents_anchor = 'pufferpdf-contents-' . $contents_suffix;
			++$contents_suffix;
		}
		$reserved_anchors[ $contents_anchor ] = true;

		$anchors       = array();
		$local_entries = array();
		foreach ( $items as $item ) {
			$anchor = isset( $item['companionBoundary']['attrs']['anchor'] ) ? (string) $item['companionBoundary']['attrs']['anchor'] : '';
			if ( '' === $anchor ) {
				$base_anchor = 'pufferpdf-section-' . ( $item['index'] + 1 );
				$anchor      = $base_anchor;
				$suffix      = 2;
				while ( isset( $reserved_anchors[ $anchor ] ) ) {
					$anchor = $base_anchor . '-' . $suffix;
					++$suffix;
				}
				$reserved_anchors[ $anchor ] = true;
			}
			$anchors[ $item['index'] ] = $anchor;
			$local_entries[]           = array(
				'index'       => $item['index'],
				'title'       => $item['companionTitle'],
				'parentIndex' => $item['parentIndex'],
				'sourcePages' => $item['sourcePages'],
			);
		}

		$content  = $this->build_heading_block( __( 'Contents', 'pufferpdf' ), 2, $contents_anchor );
		$content .= "\n\n" . $this->build_local_toc_block( $local_entries, $anchors );
		foreach ( $items as $item ) {
			$body = $this->normalize_companion_body_headings( $item['baseContent'] );
			if ( is_wp_error( $body ) ) {
				return $body;
			}
			$wrapper = $this->build_companion_item_heading( $item, $anchors[ $item['index'] ] );
			if ( is_wp_error( $wrapper ) ) {
				return $wrapper;
			}
			$content .= "\n\n" . $wrapper;
			$content .= "\n\n" . $body;
		}
		$content .= "\n\n" . $this->build_source_citation_block( $source, $source_pages );

		$validated = $this->blocks->validate_content( $content );
		if ( is_wp_error( $validated ) ) {
			return $validated;
		}
		$companion['baseContent'] = $validated['content'];
		$companion['content']     = $validated['content'];
		$companion['contentHash'] = hash( 'sha256', $validated['content'] );

		return $companion;
	}

	/**
	 * Demote body headings beneath the companion's generated H2 item wrapper.
	 *
	 * @param string $content Validated serialized item body.
	 * @return string|WP_Error
	 */
	private function normalize_companion_body_headings( $content ) {
		$blocks        = parse_blocks( $content );
		$minimum_level = 7;
		foreach ( $blocks as $block ) {
			if ( 'core/heading' === $block['blockName'] ) {
				$minimum_level = min( $minimum_level, $this->get_heading_level( $block ) );
			}
		}
		$level_offset = 7 === $minimum_level ? 0 : 3 - $minimum_level;
		foreach ( $blocks as &$block ) {
			if ( 'core/heading' !== $block['blockName'] ) {
				continue;
			}

			$old_level  = $this->get_heading_level( $block );
			$new_level  = min( 6, max( 3, $old_level + $level_offset ) );
			$inner_html = preg_replace( '/\A(\s*)<h[1-6](\s[^>]*)?>/i', '$1<h' . $new_level . '$2>', (string) $block['innerHTML'], 1, $opening_count );
			$inner_html = preg_replace( '/<\/h[1-6]>(\s*)\z/i', '</h' . $new_level . '>$1', (string) $inner_html, 1, $closing_count );
			if ( 1 !== $opening_count || 1 !== $closing_count || ! is_string( $inner_html ) ) {
				return $this->invalid_plan( __( 'A reviewed heading could not be normalized for the accessible companion.', 'pufferpdf' ) );
			}

			$block['attrs']          = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
			$block['attrs']['level'] = $new_level;
			$block['innerHTML']      = $inner_html;
			$offset                  = 0;
			$inner_content           = array();
			foreach ( (array) $block['innerContent'] as $fragment ) {
				if ( ! is_string( $fragment ) ) {
					return $this->invalid_plan( __( 'A reviewed heading could not be normalized for the accessible companion.', 'pufferpdf' ) );
				}
				$length          = strlen( $fragment );
				$inner_content[] = substr( $inner_html, $offset, $length );
				$offset         += $length;
			}
			if ( strlen( $inner_html ) !== $offset ) {
				return $this->invalid_plan( __( 'A reviewed heading could not be normalized for the accessible companion.', 'pufferpdf' ) );
			}
			$block['innerContent'] = $inner_content;
		}
		unset( $block );

		$normalized = serialize_blocks( $blocks );
		$validated  = $this->blocks->validate_content( $normalized );

		return is_wp_error( $validated ) ? $validated : $validated['content'];
	}

	/**
	 * Preserve a reviewed split-boundary heading as the item's H2 wrapper.
	 *
	 * @param array  $item Item.
	 * @param string $anchor Unique companion anchor.
	 * @return string|WP_Error
	 */
	private function build_companion_item_heading( array $item, $anchor ) {
		if ( ! isset( $item['companionBoundary'] ) ) {
			return $this->build_heading_block( $item['companionTitle'], 2, $anchor );
		}

		$block          = $item['companionBoundary'];
		$block['attrs'] = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
		unset( $block['attrs']['level'] );
		$block['attrs']['anchor'] = $anchor;
		$inner_html               = preg_replace( '/\A(\s*)<h[1-6](\s[^>]*)?>/i', '$1<h2$2>', (string) $block['innerHTML'], 1, $opening_count );
		$inner_html               = preg_replace( '/<\/h[1-6]>(\s*)\z/i', '</h2>$1', (string) $inner_html, 1, $closing_count );
		if ( 1 !== $opening_count || 1 !== $closing_count || ! is_string( $inner_html ) ) {
			return $this->invalid_plan( __( 'A reviewed split heading could not be preserved in the accessible companion.', 'pufferpdf' ) );
		}

		$processor = new WP_HTML_Tag_Processor( $inner_html );
		if ( ! $processor->next_tag() ) {
			return $this->invalid_plan( __( 'A reviewed split heading could not be preserved in the accessible companion.', 'pufferpdf' ) );
		}
		$processor->set_attribute( 'id', $anchor );
		$inner_html            = $processor->get_updated_html();
		$block['innerHTML']    = $inner_html;
		$block['innerContent'] = array( $inner_html );

		return serialize_block( $block );
	}

	/**
	 * Attach duplicate and WordPress slug collision diagnostics.
	 *
	 * @param array $items Plan items.
	 * @return array
	 */
	private function attach_collisions( array $items ) {
		$title_groups = array();
		$slug_groups  = array();

		foreach ( $items as $index => $item ) {
			if ( empty( $item['approved'] ) ) {
				continue;
			}
			$title_key                    = $item['postType'] . ':' . $this->normalize_title_key( $item['title'] );
			$scope                        = $this->collision_parent_scope( $item );
			$slug_key                     = $item['postType'] . ':' . $scope . ':' . $item['slug'];
			$title_groups[ $title_key ][] = $index;
			$slug_groups[ $slug_key ][]   = $index;
		}

		foreach ( $items as $index => &$item ) {
			$item['collisions'] = array();
			if ( empty( $item['approved'] ) ) {
				continue;
			}

			$title_key = $item['postType'] . ':' . $this->normalize_title_key( $item['title'] );
			$scope     = $this->collision_parent_scope( $item );
			$slug_key  = $item['postType'] . ':' . $scope . ':' . $item['slug'];
			if ( count( $title_groups[ $title_key ] ) > 1 ) {
				$item['collisions'][] = $this->collision( 'title', 'duplicate_title_in_plan', __( 'Another approved item has the same title.', 'pufferpdf' ) );
			}
			if ( count( $slug_groups[ $slug_key ] ) > 1 ) {
				$item['collisions'][] = $this->collision( 'slug', 'duplicate_slug_in_plan', __( 'Another approved sibling has the same URL slug.', 'pufferpdf' ) );
			}

			$existing_title = $this->find_existing_title( $item['postType'], $item['title'] );
			if ( $existing_title ) {
				$details = $this->collision( 'title', 'existing_title', __( 'An existing object in this post type has the same title.', 'pufferpdf' ) );
				if ( current_user_can( 'edit_post', $existing_title->ID ) ) {
					$details['existingTitle'] = get_the_title( $existing_title );
					$details['editUrl']       = get_edit_post_link( $existing_title->ID, 'raw' );
				}
				$item['collisions'][] = $details;
			}

			$parent_for_core = null === $item['parentIndex'] ? $item['parentId'] : 2147483000 + $item['parentIndex'];
			$unique_slug     = wp_unique_post_slug( $item['slug'], 0, 'publish', $item['postType'], $parent_for_core );
			if ( $unique_slug !== $item['slug'] ) {
				$item['collisions'][] = $this->collision( 'slug', 'existing_or_reserved_slug', __( 'WordPress would change this slug because it is already used or reserved.', 'pufferpdf' ) );
			}
		}
		unset( $item );

		return $items;
	}

	/**
	 * Find an exact existing title without exposing inaccessible object data.
	 *
	 * @param string $post_type Post type.
	 * @param string $title Title.
	 * @return WP_Post|null
	 */
	private function find_existing_title( $post_type, $title ) {
		$query = new WP_Query(
			array(
				'post_type'              => $post_type,
				'post_status'            => array_diff( get_post_stati(), array( 'trash', 'auto-draft' ) ),
				'title'                  => $title,
				'posts_per_page'         => 1,
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
			)
		);

		return empty( $query->posts ) ? null : $query->posts[0];
	}

	/**
	 * Build one collision descriptor.
	 *
	 * @param string $field Field.
	 * @param string $code Code.
	 * @param string $message Message.
	 * @return array
	 */
	private function collision( $field, $code, $message ) {
		return array(
			'field'   => $field,
			'code'    => $code,
			'message' => $message,
		);
	}

	/**
	 * Return canonical plan-hash fields for an item.
	 *
	 * @param array $item Item.
	 * @return array
	 */
	private function canonicalize_item( array $item ) {
		return array(
			'index'         => $item['index'],
			'key'           => $item['key'],
			'kind'          => $item['kind'],
			'title'         => $item['title'],
			'slug'          => $item['slug'],
			'contentHash'   => $item['contentHash'],
			'sourcePages'   => $item['sourcePages'],
			'parentIndex'   => $item['parentIndex'],
			'parentId'      => $item['parentId'],
			'approved'      => $item['approved'],
			'postType'      => $item['postType'],
			'status'        => $item['status'],
			'authorId'      => $item['authorId'],
			'terms'         => $item['terms'],
			'boundaryLevel' => $item['boundaryLevel'],
		);
	}

	/**
	 * Build one generated citation paragraph.
	 *
	 * @param array $source Source.
	 * @param int[] $pages Source pages.
	 * @return string
	 */
	private function build_source_citation_block( array $source, array $pages ) {
		$prefix = sprintf(
			/* translators: 1: PDF filename, 2: page list. */
			__( 'Source: %1$s (PDF), %2$s.', 'pufferpdf' ),
			$source['filename'],
			$this->format_page_list( $pages )
		);
		$label = esc_html( $prefix );
		if ( '' !== $source['url'] ) {
			$link_text = sprintf(
				/* translators: %s: PDF filename. */
				__( 'Download the original PDF: %s', 'pufferpdf' ),
				$source['filename']
			);
			$label .= ' ' . sprintf(
				'<a href="%1$s">%2$s</a>.',
				esc_attr( $source['url'] ),
				esc_html( $link_text )
			);
		}

		return '<!-- wp:paragraph -->' . "\n" . '<p>' . $label . '</p>' . "\n" . '<!-- /wp:paragraph -->';
	}

	/**
	 * Build a strict generated heading block.
	 *
	 * @param string $title Heading text.
	 * @param int    $level Heading level.
	 * @param string $anchor Heading anchor.
	 * @return string
	 */
	private function build_heading_block( $title, $level, $anchor ) {
		$attrs = array( 'anchor' => $anchor );
		if ( 2 !== $level ) {
			$attrs['level'] = $level;
		}

		return '<!-- wp:heading ' . wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES ) . ' -->' . "\n" .
			'<h' . $level . ' class="wp-block-heading" id="' . esc_attr( $anchor ) . '">' . esc_html( $title ) . '</h' . $level . '>' . "\n" .
			'<!-- /wp:heading -->';
	}

	/**
	 * Build a flat ordered TOC with validated local fragment links.
	 *
	 * @param array $entries TOC entries.
	 * @param array $anchors Item anchors.
	 * @return string
	 */
	private function build_local_toc_block( array $entries, array $anchors ) {
		$items = '';
		foreach ( $entries as $entry ) {
			$items .= '<!-- wp:list-item -->' . "\n" .
				'<li><a href="#' . esc_attr( $anchors[ $entry['index'] ] ) . '">' . esc_html( $entry['title'] ) . '</a></li>' . "\n" .
				'<!-- /wp:list-item -->' . "\n\n";
		}

		return '<!-- wp:list {"ordered":true} -->' . "\n" . '<ol class="wp-block-list">' . rtrim( $items ) . '</ol>' . "\n" . '<!-- /wp:list -->';
	}

	/**
	 * Estimate the stored absolute-link TOC conservatively before IDs exist.
	 *
	 * Creation checks the exact generated byte count inside the transaction.
	 *
	 * @param array $entries Canonical TOC entries.
	 * @return int
	 */
	private function estimate_created_toc_bytes( array $entries ) {
		$placeholder_url = 'https://pufferpdf.invalid/' . str_repeat( 'x', 2021 );
		$items           = '';
		foreach ( $entries as $entry ) {
			$items .= '<!-- wp:list-item -->' . "\n" . '<li><a href="' . $placeholder_url . '">' . esc_html( $entry['title'] ) . '</a></li>' . "\n" . '<!-- /wp:list-item -->' . "\n\n";
		}

		$content = '<!-- wp:list {"ordered":true} -->' . "\n" . '<ol class="wp-block-list">' . rtrim( $items ) . '</ol>' . "\n" . '<!-- /wp:list -->';

		return strlen( $content );
	}

	/**
	 * Extract safe plain text from one already-validated heading block.
	 *
	 * @param array $block Heading block.
	 * @return string|WP_Error
	 */
	private function extract_heading_title( array $block ) {
		$title = html_entity_decode( wp_strip_all_tags( (string) $block['innerHTML'] ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$title = preg_replace( '/\s+/u', ' ', trim( $title ) );

		return $this->normalize_title( is_string( $title ) ? $title : '' );
	}

	/**
	 * Return a heading block's effective core level.
	 *
	 * @param array $block Heading block.
	 * @return int
	 */
	private function get_heading_level( array $block ) {
		return isset( $block['attrs']['level'] ) ? (int) $block['attrs']['level'] : 2;
	}

	/**
	 * Normalize and bound a title without silently truncating it.
	 *
	 * @param mixed $value Candidate title.
	 * @return string|WP_Error
	 */
	private function normalize_title( $value ) {
		if ( ! is_string( $value ) ) {
			return $this->invalid_plan( __( 'Publishing titles must use string values.', 'pufferpdf' ) );
		}
		$title  = sanitize_text_field( $value );
		$length = function_exists( 'mb_strlen' ) ? mb_strlen( $title, 'UTF-8' ) : strlen( $title );
		if ( '' === $title || $length > 200 ) {
			return $this->invalid_plan( __( 'Every publishing item needs a title of 200 characters or fewer.', 'pufferpdf' ) );
		}

		return $title;
	}

	/**
	 * Normalize a title for in-plan duplicate comparison.
	 *
	 * @param string $title Title.
	 * @return string
	 */
	private function normalize_title_key( $title ) {
		$title = remove_accents( preg_replace( '/\s+/u', ' ', trim( $title ) ) );
		if ( function_exists( 'mb_strtolower' ) ) {
			return mb_strtolower( $title, 'UTF-8' );
		}

		return strtolower( $title );
	}

	/**
	 * Create a deterministic non-empty WordPress slug.
	 *
	 * @param string $value Candidate value.
	 * @param int    $index Fallback index.
	 * @return string
	 */
	private function normalize_slug( $value, $index ) {
		$slug = sanitize_title( $value );
		if ( '' === $slug ) {
			$slug = 'document-' . ( $index + 1 );
		}

		return $this->truncate_slug( $slug );
	}

	/**
	 * Apply WordPress's own 200-byte slug boundary.
	 *
	 * @param string $slug Sanitized slug.
	 * @return string
	 */
	private function truncate_slug( $slug ) {
		return function_exists( '_truncate_post_slug' ) ? _truncate_post_slug( $slug, 200 ) : substr( $slug, 0, 200 );
	}

	/**
	 * Return a stable hierarchy namespace for in-plan slug comparison.
	 *
	 * @param array $item Item.
	 * @return string
	 */
	private function collision_parent_scope( array $item ) {
		$post_type = get_post_type_object( $item['postType'] );
		if ( ! $post_type || ! $post_type->hierarchical ) {
			return 'flat';
		}

		return null === $item['parentIndex'] ? 'existing-' . $item['parentId'] : 'planned-' . $item['parentIndex'];
	}

	/**
	 * Format exact page values without claiming absent intermediate pages.
	 *
	 * @param int[] $pages Pages.
	 * @return string
	 */
	private function format_page_list( array $pages ) {
		$pages = array_values( array_unique( array_filter( array_map( 'absint', $pages ) ) ) );
		sort( $pages, SORT_NUMERIC );
		$ranges = array();
		$start  = null;
		$last   = null;
		foreach ( $pages as $page ) {
			if ( null === $start ) {
				$start = $page;
				$last  = $page;
				continue;
			}
			if ( $page === $last + 1 ) {
				$last = $page;
				continue;
			}
			$ranges[] = $start === $last ? (string) $start : $start . '–' . $last;
			$start    = $page;
			$last     = $page;
		}
		if ( null !== $start ) {
			$ranges[] = $start === $last ? (string) $start : $start . '–' . $last;
		}

		return _n( 'page ', 'pages ', count( $pages ), 'pufferpdf' ) . implode( ', ', $ranges );
	}

	/**
	 * Recursively sort associative keys while retaining list order.
	 *
	 * @param mixed $value Value.
	 * @return mixed
	 */
	private function sort_recursive( $value ) {
		if ( ! is_array( $value ) ) {
			return $value;
		}

		$is_list = array_keys( $value ) === range( 0, count( $value ) - 1 );
		foreach ( $value as $key => $child ) {
			$value[ $key ] = $this->sort_recursive( $child );
		}
		if ( ! $is_list ) {
			ksort( $value, SORT_STRING );
		}

		return $value;
	}

	/**
	 * Return a consistent invalid-plan error.
	 *
	 * @param string $message Human-readable message.
	 * @return WP_Error
	 */
	private function invalid_plan( $message ) {
		return new WP_Error(
			'pufferpdf_invalid_publication_plan',
			$message,
			array( 'status' => 400 )
		);
	}
}
