<?php
/**
 * Atomic Phase 3 publication manifests, creation reports, and guarded undo.
 *
 * @package PufferPDF
 */

defined( 'ABSPATH' ) || exit;

/**
 * Persists reversible multi-object publication batches without PDF bytes.
 */
final class PufferPDF_Publications {

	/** Maximum aggregate serialized output and reusable TOC bytes. */
	private const MAX_AGGREGATE_BYTES = 5242880;

	/** Hidden manifest and reversal post type. */
	public const POST_TYPE = 'pufferpdf_manifest';

	/** Manifest meta keys. */
	private const META_KIND           = '_pufferpdf_manifest_kind';
	private const META_REQUEST_ID     = '_pufferpdf_request_id';
	private const META_PLAN_HASH      = '_pufferpdf_plan_hash';
	private const META_SOURCE         = '_pufferpdf_manifest_source';
	private const META_OBJECTS        = '_pufferpdf_manifest_objects';
	private const META_TOC            = '_pufferpdf_manifest_toc';
	private const META_REPORT         = '_pufferpdf_manifest_report';
	private const META_CREATED_AT_GMT = '_pufferpdf_created_at_gmt';
	private const META_REVERSAL_FOR   = '_pufferpdf_reversal_for';
	private const META_MANIFEST_ID    = '_pufferpdf_manifest_id';
	private const META_PLAN_ITEM_KEY  = '_pufferpdf_plan_item_key';
	private const META_SOURCE_PAGES   = '_pufferpdf_source_pages';

	/**
	 * Server-owned publication planner.
	 *
	 * @var PufferPDF_Publication_Planner
	 */
	private $planner;

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

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

	/**
	 * Transaction and lock service.
	 *
	 * @var PufferPDF_Storage
	 */
	private $storage;

	/**
	 * Construct the publication service.
	 *
	 * @param PufferPDF_Publication_Planner $planner Planner.
	 * @param PufferPDF_Block_Contract      $blocks Block contract.
	 * @param PufferPDF_Records             $records Source records.
	 * @param PufferPDF_Storage             $storage Storage service.
	 */
	public function __construct(
		PufferPDF_Publication_Planner $planner,
		PufferPDF_Block_Contract $blocks,
		PufferPDF_Records $records,
		PufferPDF_Storage $storage
	) {
		$this->planner = $planner;
		$this->blocks  = $blocks;
		$this->records = $records;
		$this->storage = $storage;
	}

	/**
	 * Register hidden internal publication manifests.
	 *
	 * @return void
	 */
	public function register_post_type() {
		register_post_type(
			self::POST_TYPE,
			array(
				'labels'              => array(
					'name'          => __( 'PufferPDF publication manifests', 'pufferpdf' ),
					'singular_name' => __( 'PufferPDF publication manifest', 'pufferpdf' ),
				),
				'public'              => false,
				'publicly_queryable'  => false,
				'exclude_from_search' => true,
				'show_ui'             => false,
				'show_in_rest'        => false,
				'query_var'           => false,
				'rewrite'             => false,
				'has_archive'         => false,
				'supports'            => array(),
				'capability_type'     => 'post',
				'map_meta_cap'        => true,
				'capabilities'        => array(
					'create_posts'         => 'do_not_allow',
					'edit_posts'           => 'do_not_allow',
					'edit_others_posts'    => 'do_not_allow',
					'edit_private_posts'   => 'do_not_allow',
					'edit_published_posts' => 'do_not_allow',
					'delete_posts'         => 'do_not_allow',
					'delete_others_posts'  => 'do_not_allow',
					'delete_private_posts' => 'do_not_allow',
					'publish_posts'        => 'do_not_allow',
					'read_private_posts'   => 'do_not_allow',
				),
			)
		);
	}

	/**
	 * Create all approved items and their immutable manifest.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 * @throws RuntimeException When a planned parent is unavailable during the transaction.
	 */
	public function create( WP_REST_Request $request ) {
		$payload = $request->get_json_params();
		if ( ! is_array( $payload ) ) {
			return $this->invalid_request( __( 'The publication request must be a JSON object.', 'pufferpdf' ) );
		}

		if ( ! isset( $payload['requestId'] ) || ! is_string( $payload['requestId'] ) ) {
			return $this->invalid_request( __( 'The publication request ID must use a string value.', 'pufferpdf' ) );
		}
		if ( ! isset( $payload['expectedPlanHash'] ) || ! is_string( $payload['expectedPlanHash'] ) ) {
			return $this->invalid_request( __( 'The publication plan hash must use a string value.', 'pufferpdf' ) );
		}
		$request_id    = trim( $payload['requestId'] );
		$expected_hash = strtolower( trim( $payload['expectedPlanHash'] ) );
		if ( ! preg_match( '/\A[a-zA-Z0-9][a-zA-Z0-9_-]{7,99}\z/', $request_id ) ) {
			return $this->invalid_request( __( 'A stable request ID from 8 to 100 characters is required.', 'pufferpdf' ) );
		}
		if ( ! preg_match( '/\A[a-f0-9]{64}\z/', $expected_hash ) ) {
			return $this->invalid_request( __( 'The exact previewed plan hash is required.', 'pufferpdf' ) );
		}
		if ( ! array_key_exists( 'confirm', $payload ) || true !== $payload['confirm'] ) {
			return $this->invalid_request( __( 'Confirm the exact previewed publishing plan before creation.', 'pufferpdf' ) );
		}

		$touched_post_ids = array();
		$touched_term_ids = array();
		$request_lock     = $this->storage->acquire_locks(
			array( 'publication-request:' . get_current_user_id() . ':' . $request_id )
		);
		if ( is_wp_error( $request_lock ) ) {
			return $request_lock;
		}

		try {
			$existing = $this->get_manifest_by_request( $request_id );
			if ( $existing ) {
				return $this->replay_manifest( $existing, $expected_hash );
			}

			$plan = $this->planner->build_plan( $payload );
			if ( is_wp_error( $plan ) ) {
				return $plan;
			}
			$precondition = $this->validate_plan_precondition( $plan, $expected_hash );
			if ( is_wp_error( $precondition ) ) {
				return $precondition;
			}

			$locks = $this->storage->acquire_locks( $this->planner->get_lock_keys( $plan, $request_id ) );
			if ( is_wp_error( $locks ) ) {
				return $locks;
			}

			$existing = $this->get_manifest_by_request( $request_id );
			if ( $existing ) {
				return $this->replay_manifest( $existing, $expected_hash );
			}

			$transaction = $this->storage->begin_transaction( true );
			if ( is_wp_error( $transaction ) ) {
				return $transaction;
			}

			$parent_ids = array();
			$term_ids   = array();
			$term_map   = array();
			foreach ( $plan['items'] as $item ) {
				if ( empty( $item['approved'] ) ) {
					continue;
				}
				if ( ! empty( $item['parentId'] ) ) {
					$parent_ids[] = $item['parentId'];
				}
				foreach ( $item['terms'] as $taxonomy => $ids ) {
					$term_ids = array_merge( $term_ids, $ids );
					if ( ! isset( $term_map[ $taxonomy ] ) ) {
						$term_map[ $taxonomy ] = array();
					}
					$term_map[ $taxonomy ] = array_merge( $term_map[ $taxonomy ], $ids );
				}
			}
			$touched_term_ids = array_values( array_unique( array_filter( array_map( 'absint', $term_ids ) ) ) );
			if ( count( $this->storage->lock_post_rows( $parent_ids ) ) !== count( array_unique( array_filter( $parent_ids ) ) ) ) {
				$this->storage->rollback_transaction();

				return new WP_Error(
					'pufferpdf_destination_changed',
					__( 'A selected destination parent disappeared before publication.', 'pufferpdf' ),
					array( 'status' => 409 )
				);
			}
			$locked_terms = $this->storage->lock_term_rows( $term_map );
			if ( is_wp_error( $locked_terms ) ) {
				$this->storage->rollback_transaction();

				return $locked_terms;
			}

			$plan = $this->planner->build_plan( $payload );
			if ( is_wp_error( $plan ) ) {
				$this->storage->rollback_transaction();

				return $plan;
			}
			$precondition = $this->validate_plan_precondition( $plan, $expected_hash );
			if ( is_wp_error( $precondition ) ) {
				$this->storage->rollback_transaction();

				return $precondition;
			}

			$manifest_shell = $this->build_publication_manifest_shell( $request_id, $plan );
			$manifest_id    = $this->insert_manifest_shell( $manifest_shell );
			if ( is_wp_error( $manifest_id ) ) {
				$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

				return $manifest_id;
			}
			$touched_post_ids[] = $manifest_id;
			$manifest_post      = $this->verify_manifest_shell(
				$manifest_id,
				$manifest_shell,
				'pufferpdf_manifest_write_changed',
				__( 'Another component changed the publication manifest while it was being created.', 'pufferpdf' )
			);
			if ( is_wp_error( $manifest_post ) ) {
				$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

				return $manifest_post;
			}
			$manifest_core_state = $this->get_core_post_state( $manifest_post );
			$manifest_created_at = $manifest_shell['meta_input'][ self::META_CREATED_AT_GMT ];

			$created_ids = array();
			$snapshots   = array();
			$objects     = array();
			foreach ( $plan['items'] as $item ) {
				if ( empty( $item['approved'] ) ) {
					continue;
				}

				$parent_id = $item['parentId'];
				if ( null !== $item['parentIndex'] ) {
					if ( ! isset( $created_ids[ $item['parentIndex'] ] ) ) {
						throw new RuntimeException( 'A planned parent was not created before its child.' );
					}
					$parent_id = $created_ids[ $item['parentIndex'] ];
				}

				$post_id = wp_insert_post(
					wp_slash(
						array(
							'post_type'    => $item['postType'],
							'post_status'  => $item['status'],
							'post_title'   => $item['title'],
							'post_name'    => $item['slug'],
							'post_content' => $item['content'],
							'post_parent'  => $parent_id,
							'post_author'  => $item['authorId'],
							'meta_input'   => array(
								self::META_MANIFEST_ID     => $manifest_id,
								self::META_PLAN_ITEM_KEY   => $item['key'],
								self::META_PLAN_HASH       => $plan['planHash'],
								self::META_SOURCE_PAGES    => $item['sourcePages'],
								'_pufferpdf_attachment_id' => $plan['source']['attachmentId'],
								'_pufferpdf_source_hash'   => $plan['source']['sourceHash'],
							),
						)
					),
					true
				);
				if ( is_wp_error( $post_id ) ) {
					$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

					return $post_id;
				}
				$touched_post_ids[]            = $post_id;
				$created_ids[ $item['index'] ] = $post_id;

				$assigned_terms = $this->assign_terms( $post_id, $item['terms'] );
				if ( is_wp_error( $assigned_terms ) ) {
					$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

					return $assigned_terms;
				}

				$stored = $this->verify_created_post( $post_id, $item, $parent_id, $manifest_id, $plan['planHash'], $plan['source'] );
				if ( is_wp_error( $stored ) ) {
					$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

					return $stored;
				}
				$namespace = $this->verify_created_namespace( $stored, $item );
				if ( is_wp_error( $namespace ) ) {
					$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

					return $namespace;
				}

				$snapshot    = $this->snapshot_post( $stored, $assigned_terms, $item['kind'] );
				$snapshots[] = $snapshot;
				$objects[]   = $this->format_created_object( $stored, $item['kind'] );
			}

			$toc = $plan['artifacts']['toc'] ? $this->build_created_toc( $plan['tocEntries'], $created_ids ) : array(
				'content' => '',
				'entries' => array(),
			);
			if ( is_wp_error( $toc ) ) {
				$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

				return $toc;
			}
			if ( $plan['outputBytes'] + strlen( $toc['content'] ) > self::MAX_AGGREGATE_BYTES ) {
				$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

				return new WP_Error(
					'pufferpdf_plan_too_large',
					__( 'The approved content and generated table of contents exceed the safe aggregate publishing limit.', 'pufferpdf' ),
					array( 'status' => 413 )
				);
			}

			$report = array(
				'publicationId' => (int) $manifest_id,
				'id'            => (int) $manifest_id,
				'planHash'      => $plan['planHash'],
				'unchanged'     => false,
				'objects'       => $objects,
				'toc'           => $toc,
				'undoUrl'       => esc_url_raw( rest_url( 'pufferpdf/v1/publications/' . $manifest_id . '/undo' ) ),
			);

			$stored_manifest = $this->finish_manifest( $manifest_id, $plan, $snapshots, $toc, $report );
			if ( is_wp_error( $stored_manifest ) ) {
				$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

				return $stored_manifest;
			}

			$source_recheck = $this->source_still_matches( $plan['source'] );
			if ( is_wp_error( $source_recheck ) ) {
				$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

				return $source_recheck;
			}

			$final_state = $this->verify_final_creation_state(
				$manifest_id,
				$request_id,
				$manifest_core_state,
				$manifest_created_at,
				$plan,
				$created_ids,
				$snapshots,
				$toc,
				$report
			);
			if ( is_wp_error( $final_state ) ) {
				$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

				return $final_state;
			}

			if ( ! $this->storage->commit_transaction() ) {
				$this->storage->rollback_transaction( $touched_post_ids, $term_ids );

				return new WP_Error(
					'pufferpdf_storage_unavailable',
					__( 'PufferPDF could not commit the complete publication safely.', 'pufferpdf' ),
					array( 'status' => 500 )
				);
			}

			$this->storage->clean_runtime_caches( $touched_post_ids, $term_ids );

			return rest_ensure_response( $report );
		} catch ( Throwable $exception ) {
			$this->storage->rollback_transaction( $touched_post_ids, $touched_term_ids );

			$data = array( 'status' => 500 );
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				$data['exceptionType'] = get_class( $exception );
			}

			return new WP_Error(
				'pufferpdf_publication_failed',
				__( 'PufferPDF could not create the complete publication safely.', 'pufferpdf' ),
				$data
			);
		} finally {
			$this->storage->release_locks();
		}
	}

	/**
	 * Preview or execute an all-or-nothing move to Trash.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function undo( WP_REST_Request $request ) {
		$manifest_id = absint( $request['id'] );
		$payload     = $request->get_json_params();
		$payload     = is_array( $payload ) ? $payload : array();
		if ( ! empty( array_diff( array_keys( $payload ), array( 'preview', 'confirm', 'expectedPlanHash', 'requestId' ) ) ) ) {
			return $this->invalid_request( __( 'The undo request contains unsupported fields.', 'pufferpdf' ) );
		}
		if ( array_key_exists( 'preview', $payload ) && ! is_bool( $payload['preview'] ) ) {
			return $this->invalid_request( __( 'The undo preview flag must use a JSON boolean value.', 'pufferpdf' ) );
		}
		if ( array_key_exists( 'confirm', $payload ) && ! is_bool( $payload['confirm'] ) ) {
			return $this->invalid_request( __( 'The undo confirmation flag must use a JSON boolean value.', 'pufferpdf' ) );
		}
		if ( array_key_exists( 'requestId', $payload ) && ! is_string( $payload['requestId'] ) ) {
			return $this->invalid_request( __( 'The undo request ID must use a string value.', 'pufferpdf' ) );
		}
		if ( array_key_exists( 'expectedPlanHash', $payload ) && ! is_string( $payload['expectedPlanHash'] ) ) {
			return $this->invalid_request( __( 'The undo plan hash must use a string value.', 'pufferpdf' ) );
		}
		$request_id = isset( $payload['requestId'] ) ? trim( $payload['requestId'] ) : '';
		if ( '' !== $request_id && ! preg_match( '/\A[a-zA-Z0-9][a-zA-Z0-9_-]{7,99}\z/', $request_id ) ) {
			return $this->invalid_request( __( 'The undo request ID must contain from 8 to 100 safe characters.', 'pufferpdf' ) );
		}
		$manifest = $this->get_owned_manifest( $manifest_id );
		if ( is_wp_error( $manifest ) ) {
			return $manifest;
		}

		$existing_reversal = $this->get_reversal_id( $manifest_id );
		if ( $existing_reversal ) {
			return rest_ensure_response(
				array(
					'undone'     => true,
					'unchanged'  => true,
					'reversalId' => $existing_reversal,
					'objects'    => get_post_meta( $manifest_id, self::META_OBJECTS, true ),
				)
			);
		}

		$assessment = $this->assess_undo( $manifest_id );
		if ( isset( $payload['preview'] ) && true === $payload['preview'] ) {
			return rest_ensure_response( $assessment );
		}
		if ( ! isset( $payload['confirm'] ) || true !== $payload['confirm'] ) {
			return $this->invalid_request( __( 'Confirm the complete batch undo before moving anything to Trash.', 'pufferpdf' ) );
		}
		if ( ! $assessment['canUndo'] ) {
			return new WP_Error(
				'pufferpdf_undo_blocked',
				__( 'This publication changed after creation and cannot be undone automatically.', 'pufferpdf' ),
				array(
					'status'   => 409,
					'blockers' => $assessment['blockers'],
				)
			);
		}

		$plan_hash     = (string) get_post_meta( $manifest_id, self::META_PLAN_HASH, true );
		$expected_hash = isset( $payload['expectedPlanHash'] ) ? strtolower( trim( $payload['expectedPlanHash'] ) ) : '';
		if ( ! preg_match( '/\A[a-f0-9]{64}\z/', $expected_hash ) || ! hash_equals( $plan_hash, $expected_hash ) ) {
			return new WP_Error(
				'pufferpdf_stale_undo',
				__( 'The publication identity changed. Preview undo again.', 'pufferpdf' ),
				array( 'status' => 409 )
			);
		}

		$lock = $this->storage->acquire_locks( array( 'publication-manifest:' . $manifest_id ) );
		if ( is_wp_error( $lock ) ) {
			return $lock;
		}

		$touched = array( $manifest_id );
		try {
			$existing_reversal = $this->get_reversal_id( $manifest_id );
			if ( $existing_reversal ) {
				return rest_ensure_response(
					array(
						'undone'     => true,
						'unchanged'  => true,
						'reversalId' => $existing_reversal,
						'objects'    => get_post_meta( $manifest_id, self::META_OBJECTS, true ),
					)
				);
			}

			$transaction = $this->storage->begin_transaction( true, true );
			if ( is_wp_error( $transaction ) ) {
				return $transaction;
			}

			$locked_manifest = $this->storage->lock_post_rows( array( $manifest_id ) );
			if ( array( $manifest_id ) !== $locked_manifest ) {
				$this->storage->rollback_transaction( $touched );

				return new WP_Error(
					'pufferpdf_undo_blocked',
					__( 'The publication manifest disappeared before undo.', 'pufferpdf' ),
					array( 'status' => 409 )
				);
			}
			$manifest_relationship_locks = $this->storage->lock_post_relationship_rows( array( $manifest_id ) );
			if ( is_wp_error( $manifest_relationship_locks ) ) {
				$this->storage->rollback_transaction( $touched );

				return $manifest_relationship_locks;
			}
			$manifest_state = $this->read_locked_manifest_state( $manifest_id );
			if ( is_wp_error( $manifest_state ) ) {
				$this->storage->rollback_transaction( $touched );

				return $manifest_state;
			}
			$snapshots        = $manifest_state['objects'];
			$locked_plan_hash = $manifest_state['planHash'];
			if ( ! hash_equals( $expected_hash, $locked_plan_hash ) ) {
				$this->storage->rollback_transaction( $touched );

				return new WP_Error(
					'pufferpdf_stale_undo',
					__( 'The publication identity changed while undo was starting.', 'pufferpdf' ),
					array( 'status' => 409 )
				);
			}
			$object_ids = array_map(
				static function ( $snapshot ) {
					return isset( $snapshot['id'] ) ? absint( $snapshot['id'] ) : 0;
				},
				$snapshots
			);
			$locked     = $this->storage->lock_post_rows( $object_ids );
			if ( count( $locked ) !== count( array_unique( array_filter( $object_ids ) ) ) ) {
				$this->storage->rollback_transaction( $touched );

				return new WP_Error(
					'pufferpdf_undo_blocked',
					__( 'One of the publication objects disappeared before undo.', 'pufferpdf' ),
					array( 'status' => 409 )
				);
			}
			$related_locks = $this->storage->lock_post_relationship_rows( $object_ids );
			if ( is_wp_error( $related_locks ) ) {
				$this->storage->rollback_transaction( $touched );

				return $related_locks;
			}

			$this->storage->clean_runtime_caches( array_merge( array( $manifest_id ), $object_ids ) );
			$assessment = $this->assess_undo( $manifest_id, true, $snapshots );
			if ( ! $assessment['canUndo'] ) {
				$this->storage->rollback_transaction( $touched );

				return new WP_Error(
					'pufferpdf_undo_blocked',
					__( 'This publication changed while undo was starting.', 'pufferpdf' ),
					array(
						'status'   => 409,
						'blockers' => $assessment['blockers'],
					)
				);
			}
			$locked_manifest_post = get_post( $manifest_id );
			if ( ! $locked_manifest_post instanceof WP_Post ) {
				$this->storage->rollback_transaction( $touched );

				return new WP_Error(
					'pufferpdf_undo_blocked',
					__( 'The publication manifest disappeared while undo was starting.', 'pufferpdf' ),
					array( 'status' => 409 )
				);
			}
			$locked_manifest_core_state = $this->get_core_post_state( $locked_manifest_post );
			$locked_manifest_meta_hash  = $this->get_post_metadata_hash( $manifest_id );
			$object_ids                 = array_reverse( $object_ids );
			foreach ( $object_ids as $object_id ) {
				$trashed = wp_trash_post( $object_id );
				if ( ! $trashed || 'trash' !== get_post_status( $object_id ) ) {
					$this->storage->rollback_transaction( $touched );

					return new WP_Error(
						'pufferpdf_undo_failed',
						__( 'WordPress could not move every publication object to Trash.', 'pufferpdf' ),
						array( 'status' => 500 )
					);
				}
				$touched[] = $object_id;
			}

			$reversal_shell = $this->build_reversal_manifest_shell( $manifest_id, $request_id, $locked_plan_hash );
			$reversal_id    = wp_insert_post( wp_slash( $reversal_shell ), true );
			if ( is_wp_error( $reversal_id ) ) {
				$this->storage->rollback_transaction( $touched );

				return $reversal_id;
			}
			$touched[]     = $reversal_id;
			$reversal_post = $this->verify_manifest_shell(
				$reversal_id,
				$reversal_shell,
				'pufferpdf_undo_state_changed',
				__( 'Another component changed the undo manifest while it was being created.', 'pufferpdf' )
			);
			if ( is_wp_error( $reversal_post ) ) {
				$this->storage->rollback_transaction( $touched );

				return $reversal_post;
			}
			$reversal_core_state = $this->get_core_post_state( $reversal_post );
			$reversal_meta_hash  = $this->get_post_metadata_hash( $reversal_id );

			$final_undo_state = $this->verify_final_undo_state(
				$manifest_id,
				$reversal_id,
				$object_ids,
				$locked_plan_hash,
				$locked_manifest_core_state,
				$locked_manifest_meta_hash,
				$reversal_core_state,
				$reversal_meta_hash
			);
			if ( is_wp_error( $final_undo_state ) ) {
				$this->storage->rollback_transaction( $touched );

				return $final_undo_state;
			}

			if ( ! $this->storage->commit_transaction() ) {
				$this->storage->rollback_transaction( $touched );

				return new WP_Error(
					'pufferpdf_undo_failed',
					__( 'PufferPDF could not commit the complete undo safely.', 'pufferpdf' ),
					array( 'status' => 500 )
				);
			}

			$this->storage->clean_runtime_caches( $touched );

			return rest_ensure_response(
				array(
					'undone'     => true,
					'unchanged'  => false,
					'reversalId' => (int) $reversal_id,
					'objects'    => $snapshots,
				)
			);
		} catch ( Throwable $exception ) {
			$this->storage->rollback_transaction( $touched );

			return new WP_Error(
				'pufferpdf_undo_failed',
				__( 'PufferPDF could not undo the complete publication safely.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		} finally {
			$this->storage->release_locks();
		}
	}

	/**
	 * Create the internal manifest row before target objects need its ID.
	 *
	 * @param array $shell Canonical publication-manifest fields.
	 * @return int|WP_Error
	 */
	private function insert_manifest_shell( array $shell ) {
		return wp_insert_post( wp_slash( $shell ), true );
	}

	/**
	 * Build canonical publication-manifest fields before any save hooks run.
	 *
	 * @param string $request_id Idempotency key.
	 * @param array  $plan Canonical plan.
	 * @return array
	 */
	private function build_publication_manifest_shell( $request_id, array $plan ) {
		return array(
			'post_type'   => self::POST_TYPE,
			'post_status' => 'private',
			'post_parent' => 0,
			'post_author' => get_current_user_id(),
			'post_title'  => sprintf(
				/* translators: %s: reviewed document title. */
				__( 'Publication: %s', 'pufferpdf' ),
				$plan['documentTitle']
			),
			'meta_input'  => array(
				self::META_KIND           => 'publication',
				self::META_REQUEST_ID     => $request_id,
				self::META_PLAN_HASH      => $plan['planHash'],
				self::META_CREATED_AT_GMT => current_time( 'mysql', true ),
			),
		);
	}

	/**
	 * Build canonical reversal-manifest fields before any save hooks run.
	 *
	 * @param int    $manifest_id Publication manifest ID.
	 * @param string $request_id Idempotency key.
	 * @param string $plan_hash Locked publication identity.
	 * @return array
	 */
	private function build_reversal_manifest_shell( $manifest_id, $request_id, $plan_hash ) {
		return array(
			'post_type'   => self::POST_TYPE,
			'post_status' => 'private',
			'post_parent' => $manifest_id,
			'post_author' => get_current_user_id(),
			'post_title'  => sprintf(
				/* translators: %d: publication manifest ID. */
				__( 'Undo publication %d', 'pufferpdf' ),
				$manifest_id
			),
			'meta_input'  => array(
				self::META_KIND           => 'reversal',
				self::META_REVERSAL_FOR   => (string) $manifest_id,
				self::META_PLAN_HASH      => $plan_hash,
				self::META_CREATED_AT_GMT => current_time( 'mysql', true ),
				self::META_REQUEST_ID     => $request_id,
			),
		);
	}

	/**
	 * Verify mandatory manifest fields against their pre-insert values.
	 *
	 * @param int    $post_id Inserted manifest ID.
	 * @param array  $shell Canonical pre-insert fields.
	 * @param string $error_code Error code for the operation.
	 * @param string $message Error message for the operation.
	 * @return WP_Post|WP_Error
	 */
	private function verify_manifest_shell( $post_id, array $shell, $error_code, $message ) {
		$this->storage->clean_runtime_caches( array( $post_id ) );
		$post = get_post( $post_id );
		if (
			! $post instanceof WP_Post ||
			$shell['post_type'] !== $post->post_type ||
			$shell['post_status'] !== $post->post_status ||
			(int) $shell['post_parent'] !== (int) $post->post_parent ||
			(int) $shell['post_author'] !== (int) $post->post_author ||
			$shell['post_title'] !== $post->post_title
		) {
			return new WP_Error( $error_code, $message, array( 'status' => 500 ) );
		}

		foreach ( $shell['meta_input'] as $meta_key => $expected ) {
			if (
				! metadata_exists( 'post', $post_id, $meta_key ) ||
				get_post_meta( $post_id, $meta_key, true ) !== $expected
			) {
				return new WP_Error( $error_code, $message, array( 'status' => 500 ) );
			}
		}

		return $post;
	}

	/**
	 * Store and verify the immutable completed manifest fields.
	 *
	 * @param int   $manifest_id Manifest ID.
	 * @param array $plan Plan.
	 * @param array $snapshots Object snapshots.
	 * @param array $toc Created TOC.
	 * @param array $report Stable response.
	 * @return array|WP_Error Exact taxonomy snapshot.
	 */
	private function finish_manifest( $manifest_id, array $plan, array $snapshots, array $toc, array $report ) {
		$values = array(
			self::META_SOURCE  => $plan['source'],
			self::META_OBJECTS => $snapshots,
			self::META_TOC     => $toc,
			self::META_REPORT  => $report,
		);
		foreach ( $values as $key => $value ) {
			if ( false === update_post_meta( $manifest_id, $key, $value ) ) {
				return new WP_Error(
					'pufferpdf_manifest_write_failed',
					__( 'WordPress could not store the complete publication report.', 'pufferpdf' ),
					array( 'status' => 500 )
				);
			}
		}

		foreach ( $values as $key => $value ) {
			if ( get_post_meta( $manifest_id, $key, true ) !== $value ) {
				return new WP_Error(
					'pufferpdf_manifest_write_changed',
					__( 'Another component changed the publication report while it was being stored.', 'pufferpdf' ),
					array( 'status' => 500 )
				);
			}
		}

		return true;
	}

	/**
	 * Recheck every completed object and immutable manifest field before commit.
	 *
	 * @param int    $manifest_id Manifest ID.
	 * @param string $request_id Idempotency key.
	 * @param array  $manifest_core_state Core manifest state captured after insertion.
	 * @param string $manifest_created_at Creation timestamp captured after insertion.
	 * @param array  $plan Canonical plan.
	 * @param array  $created_ids Plan-index to post-ID map.
	 * @param array  $snapshots Immutable object snapshots.
	 * @param array  $toc Created TOC.
	 * @param array  $report Stable response.
	 * @return true|WP_Error
	 */
	private function verify_final_creation_state( $manifest_id, $request_id, array $manifest_core_state, $manifest_created_at, array $plan, array $created_ids, array $snapshots, array $toc, array $report ) {
		$this->storage->clean_runtime_caches( array_merge( array( $manifest_id ), array_values( $created_ids ) ) );

		$snapshots_by_id = array();
		foreach ( $snapshots as $snapshot ) {
			if ( isset( $snapshot['id'] ) ) {
				$snapshots_by_id[ absint( $snapshot['id'] ) ] = $snapshot;
			}
		}
		foreach ( $plan['items'] as $item ) {
			if ( empty( $item['approved'] ) ) {
				continue;
			}
			$post_id = isset( $created_ids[ $item['index'] ] ) ? absint( $created_ids[ $item['index'] ] ) : 0;
			$post    = get_post( $post_id );
			if ( ! $post instanceof WP_Post || ! isset( $snapshots_by_id[ $post_id ] ) ) {
				return new WP_Error(
					'pufferpdf_target_write_changed',
					__( 'A publication object disappeared before the batch could be committed.', 'pufferpdf' ),
					array( 'status' => 500 )
				);
			}
			if ( ! current_user_can( 'edit_post', $post_id ) || ! current_user_can( 'delete_post', $post_id ) ) {
				return new WP_Error(
					'pufferpdf_destination_not_reversible',
					__( 'A created publication object is not both editable and safely reversible for the current user.', 'pufferpdf' ),
					array( 'status' => 403 )
				);
			}

			$expected = $snapshots_by_id[ $post_id ];
			$terms    = isset( $expected['terms'] ) && is_array( $expected['terms'] )
				? $this->read_terms( $post_id, array_keys( $expected['terms'] ) )
				: array();
			$current  = $this->snapshot_post( $post, $terms, $item['kind'] );
			if ( $current !== $expected ) {
				return new WP_Error(
					'pufferpdf_target_write_changed',
					__( 'Another component changed a publication object before the batch could be committed.', 'pufferpdf' ),
					array( 'status' => 500 )
				);
			}
			$namespace = $this->verify_created_namespace( $post, $item );
			if ( is_wp_error( $namespace ) ) {
				return $namespace;
			}
		}

		$manifest = get_post( $manifest_id );
		if ( ! $manifest instanceof WP_Post || $manifest_core_state !== $this->get_core_post_state( $manifest ) ) {
			return new WP_Error(
				'pufferpdf_manifest_write_changed',
				__( 'Another component changed the publication manifest before commit.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}
		$manifest_values = array(
			self::META_KIND           => 'publication',
			self::META_REQUEST_ID     => $request_id,
			self::META_PLAN_HASH      => $plan['planHash'],
			self::META_CREATED_AT_GMT => $manifest_created_at,
			self::META_SOURCE         => $plan['source'],
			self::META_OBJECTS        => $snapshots,
			self::META_TOC            => $toc,
			self::META_REPORT         => $report,
		);
		foreach ( $manifest_values as $key => $value ) {
			if ( get_post_meta( $manifest_id, $key, true ) !== $value ) {
				return new WP_Error(
					'pufferpdf_manifest_write_changed',
					__( 'Another component changed the publication manifest before commit.', 'pufferpdf' ),
					array( 'status' => 500 )
				);
			}
		}

		return true;
	}

	/**
	 * Capture every core posts-table field that must remain immutable.
	 *
	 * @param WP_Post $post Stored post.
	 * @return array
	 */
	private function get_core_post_state( WP_Post $post ) {
		return array(
			'ID'                    => (int) $post->ID,
			'post_author'           => (int) $post->post_author,
			'post_date'             => (string) $post->post_date,
			'post_date_gmt'         => (string) $post->post_date_gmt,
			'post_content'          => (string) $post->post_content,
			'post_title'            => (string) $post->post_title,
			'post_excerpt'          => (string) $post->post_excerpt,
			'post_status'           => (string) $post->post_status,
			'comment_status'        => (string) $post->comment_status,
			'ping_status'           => (string) $post->ping_status,
			'post_password'         => (string) $post->post_password,
			'post_name'             => (string) $post->post_name,
			'to_ping'               => (string) $post->to_ping,
			'pinged'                => (string) $post->pinged,
			'post_modified'         => (string) $post->post_modified,
			'post_modified_gmt'     => (string) $post->post_modified_gmt,
			'post_content_filtered' => (string) $post->post_content_filtered,
			'post_parent'           => (int) $post->post_parent,
			'guid'                  => (string) $post->guid,
			'menu_order'            => (int) $post->menu_order,
			'post_type'             => (string) $post->post_type,
			'post_mime_type'        => (string) $post->post_mime_type,
			'comment_count'         => (int) $post->comment_count,
		);
	}

	/**
	 * Assign and verify exact existing taxonomy IDs.
	 *
	 * @param int   $post_id Post ID.
	 * @param array $terms Taxonomy map.
	 * @return true|WP_Error
	 */
	private function assign_terms( $post_id, array $terms ) {
		foreach ( $terms as $taxonomy => $term_ids ) {
			$result = wp_set_object_terms( $post_id, $term_ids, $taxonomy, false );
			if ( is_wp_error( $result ) ) {
				return $result;
			}

			$stored = wp_get_object_terms( $post_id, $taxonomy, array( 'fields' => 'ids' ) );
			if ( is_wp_error( $stored ) ) {
				return $stored;
			}
			$stored = array_values( array_unique( array_map( 'absint', $stored ) ) );
			sort( $stored, SORT_NUMERIC );
			$expected = array_values( array_unique( array_map( 'absint', $term_ids ) ) );
			sort( $expected, SORT_NUMERIC );
			if ( $stored !== $expected ) {
				return new WP_Error(
					'pufferpdf_term_write_changed',
					__( 'WordPress changed a taxonomy assignment while the publication was being stored.', 'pufferpdf' ),
					array( 'status' => 500 )
				);
			}
		}

		$expected_terms = array();
		foreach ( get_object_taxonomies( get_post_type( $post_id ), 'names' ) as $taxonomy ) {
			$expected_terms[ $taxonomy ] = isset( $terms[ $taxonomy ] ) ? array_values( array_unique( array_map( 'absint', $terms[ $taxonomy ] ) ) ) : array();
			sort( $expected_terms[ $taxonomy ], SORT_NUMERIC );
		}
		ksort( $expected_terms, SORT_STRING );

		$stored_terms = $this->read_terms( $post_id, array_keys( $expected_terms ) );
		if ( $stored_terms !== $expected_terms ) {
			return new WP_Error(
				'pufferpdf_term_write_changed',
				__( 'WordPress added or changed a taxonomy assignment while the publication was being stored.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}

		return $stored_terms;
	}

	/**
	 * Reload and verify every critical post field after save hooks.
	 *
	 * @param int    $post_id Post ID.
	 * @param array  $item Canonical item.
	 * @param int    $parent_id Resolved parent ID.
	 * @param int    $manifest_id Manifest ID.
	 * @param string $plan_hash Canonical plan hash.
	 * @param array  $source Canonical source identity.
	 * @return WP_Post|WP_Error
	 */
	private function verify_created_post( $post_id, array $item, $parent_id, $manifest_id, $plan_hash, array $source ) {
		clean_post_cache( $post_id );
		$post = get_post( $post_id );
		if (
			! $post ||
			$item['postType'] !== $post->post_type ||
			$item['status'] !== $post->post_status ||
			$item['title'] !== (string) $post->post_title ||
			$item['slug'] !== (string) $post->post_name ||
			$item['content'] !== (string) $post->post_content ||
			(int) $parent_id !== (int) $post->post_parent ||
			(int) $item['authorId'] !== (int) $post->post_author ||
			(int) get_post_meta( $post_id, self::META_MANIFEST_ID, true ) !== $manifest_id ||
			(string) get_post_meta( $post_id, self::META_PLAN_ITEM_KEY, true ) !== $item['key'] ||
			(string) get_post_meta( $post_id, self::META_PLAN_HASH, true ) !== $plan_hash ||
			get_post_meta( $post_id, self::META_SOURCE_PAGES, true ) !== $item['sourcePages'] ||
			(int) get_post_meta( $post_id, '_pufferpdf_attachment_id', true ) !== (int) $source['attachmentId'] ||
			(string) get_post_meta( $post_id, '_pufferpdf_source_hash', true ) !== $source['sourceHash']
		) {
			return new WP_Error(
				'pufferpdf_target_write_changed',
				__( 'Another component changed a publication object while PufferPDF was saving it.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}

		return $post;
	}

	/**
	 * Recheck title and core slug namespaces after save hooks have completed.
	 *
	 * @param WP_Post $post Stored post.
	 * @param array   $item Canonical item.
	 * @return true|WP_Error
	 */
	private function verify_created_namespace( WP_Post $post, array $item ) {
		global $wpdb;

		$duplicate_title = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status NOT IN ('trash', 'auto-draft') AND post_title = %s AND ID <> %d LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$post->post_type,
				$post->post_title,
				$post->ID
			)
		);
		$unique_slug     = wp_unique_post_slug(
			$post->post_name,
			$post->ID,
			'publish',
			$post->post_type,
			$post->post_parent
		);
		if ( $duplicate_title || $unique_slug !== $item['slug'] ) {
			return new WP_Error(
				'pufferpdf_publication_collision_race',
				__( 'Another object claimed a planned title or URL while the publication was being stored. Preview the plan again.', 'pufferpdf' ),
				array( 'status' => 409 )
			);
		}

		return true;
	}

	/**
	 * Build a compact immutable state snapshot.
	 *
	 * @param WP_Post $post Stored post.
	 * @param array   $terms Expected terms.
	 * @param string  $kind Object kind.
	 * @return array
	 */
	private function snapshot_post( WP_Post $post, array $terms, $kind ) {
		$source_pages = get_post_meta( $post->ID, self::META_SOURCE_PAGES, true );
		$source_pages = is_array( $source_pages ) ? array_values( array_unique( array_map( 'absint', $source_pages ) ) ) : array();
		sort( $source_pages, SORT_NUMERIC );

		return array(
			'id'                  => (int) $post->ID,
			'kind'                => $kind,
			'postType'            => (string) $post->post_type,
			'status'              => (string) $post->post_status,
			'titleHash'           => hash( 'sha256', (string) $post->post_title ),
			'contentHash'         => hash( 'sha256', (string) $post->post_content ),
			'excerptHash'         => hash( 'sha256', (string) $post->post_excerpt ),
			'passwordHash'        => hash( 'sha256', (string) $post->post_password ),
			'contentFilteredHash' => hash( 'sha256', (string) $post->post_content_filtered ),
			'toPingHash'          => hash( 'sha256', (string) $post->to_ping ),
			'pingedHash'          => hash( 'sha256', (string) $post->pinged ),
			'guidHash'            => hash( 'sha256', (string) $post->guid ),
			'slug'                => (string) $post->post_name,
			'parent'              => (int) $post->post_parent,
			'author'              => (int) $post->post_author,
			'published'           => (string) $post->post_date,
			'publishedGmt'        => (string) $post->post_date_gmt,
			'modified'            => (string) $post->post_modified,
			'modifiedGmt'         => $this->records->get_target_modified_gmt( $post ),
			'modifiedGmtRaw'      => (string) $post->post_modified_gmt,
			'commentStatus'       => (string) $post->comment_status,
			'pingStatus'          => (string) $post->ping_status,
			'mimeType'            => (string) $post->post_mime_type,
			'menuOrder'           => (int) $post->menu_order,
			'commentCount'        => (int) $post->comment_count,
			'terms'               => $terms,
			'itemKey'             => (string) get_post_meta( $post->ID, self::META_PLAN_ITEM_KEY, true ),
			'planHash'            => (string) get_post_meta( $post->ID, self::META_PLAN_HASH, true ),
			'attachmentId'        => (int) get_post_meta( $post->ID, '_pufferpdf_attachment_id', true ),
			'sourceHash'          => (string) get_post_meta( $post->ID, '_pufferpdf_source_hash', true ),
			'sourcePages'         => $source_pages,
			'metadataHash'        => $this->get_post_metadata_hash( $post->ID ),
		);
	}

	/**
	 * Hash the complete target metadata state so custom-field edits fail closed.
	 *
	 * @param int $post_id Post ID.
	 * @return string
	 */
	private function get_post_metadata_hash( $post_id ) {
		$metadata = get_post_meta( $post_id );
		ksort( $metadata, SORT_STRING );

		return hash( 'sha256', serialize( $metadata ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
	}

	/**
	 * Format one stable created object result.
	 *
	 * @param WP_Post $post Stored post.
	 * @param string  $kind Object kind.
	 * @return array
	 */
	private function format_created_object( WP_Post $post, $kind ) {
		return array(
			'id'       => (int) $post->ID,
			'kind'     => $kind,
			'title'    => (string) $post->post_title,
			'postType' => (string) $post->post_type,
			'status'   => (string) $post->post_status,
			'parent'   => (int) $post->post_parent,
			'editUrl'  => get_edit_post_link( $post->ID, 'raw' ),
			'viewUrl'  => $this->get_view_url( $post ),
		);
	}

	/**
	 * Resolve a real frontend or preview URL only for a viewable post type.
	 *
	 * @param WP_Post $post Stored post.
	 * @return string|null
	 */
	private function get_view_url( WP_Post $post ) {
		$post_type = get_post_type_object( $post->post_type );
		if ( ! $post_type || ! is_post_type_viewable( $post_type ) ) {
			return null;
		}

		$url = get_preview_post_link( $post );
		if ( ! is_string( $url ) || '' === $url ) {
			$url = get_permalink( $post );
		}
		$url = is_string( $url ) ? esc_url_raw( $url, array( 'http', 'https' ) ) : '';

		return '' === $url ? null : $url;
	}

	/**
	 * Build canonical reusable TOC block markup after IDs exist.
	 *
	 * @param array $entries Canonical preview entries.
	 * @param array $created_ids Plan index to post ID.
	 * @return array|WP_Error
	 */
	private function build_created_toc( array $entries, array $created_ids ) {
		$items       = '';
		$stored_rows = array();
		foreach ( $entries as $entry ) {
			if ( ! isset( $created_ids[ $entry['index'] ] ) ) {
				continue;
			}
			$post_id = $created_ids[ $entry['index'] ];
			$post    = get_post( $post_id );
			$url     = $post instanceof WP_Post ? $this->get_view_url( $post ) : null;
			if ( null === $url ) {
				return new WP_Error(
					'pufferpdf_toc_url_failed',
					__( 'WordPress could not create a stable URL for the generated table of contents.', 'pufferpdf' ),
					array( 'status' => 500 )
				);
			}
			$items        .= '<!-- wp:list-item -->' . "\n" . '<li><a href="' . esc_attr( $url ) . '">' . esc_html( $entry['title'] ) . '</a></li>' . "\n" . '<!-- /wp:list-item -->' . "\n\n";
			$stored_rows[] = array(
				'index'       => $entry['index'],
				'key'         => $entry['key'],
				'title'       => $entry['title'],
				'parentIndex' => $entry['parentIndex'],
				'sourcePages' => $entry['sourcePages'],
				'postId'      => $post_id,
				'url'         => $url,
			);
		}

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

		return array(
			'content' => $validated['content'],
			'entries' => $stored_rows,
		);
	}

	/**
	 * Compare the attachment identity again immediately before commit.
	 *
	 * @param array $source Canonical source.
	 * @return true|WP_Error
	 */
	private function source_still_matches( array $source ) {
		if ( ! $source['attachmentId'] ) {
			return true;
		}

		$current = $this->records->validate_attachment_source( $source['attachmentId'], $source['sourceHash'], 20971520 );
		if ( is_wp_error( $current ) ) {
			return $current;
		}
		$url = wp_get_attachment_url( $source['attachmentId'] );
		$url = is_string( $url ) ? esc_url_raw( $url, array( 'http', 'https' ) ) : '';
		if (
			$current['hash'] !== $source['sourceHash'] ||
			$current['verification'] !== $source['verification'] ||
			$current['bytes'] !== $source['bytes'] ||
			$current['mtime'] !== $source['mtime'] ||
			$url !== $source['url']
		) {
			return new WP_Error(
				'pufferpdf_source_changed',
				__( 'The source PDF changed while its publication was being saved.', 'pufferpdf' ),
				array( 'status' => 409 )
			);
		}

		return true;
	}

	/**
	 * Verify expected identity and collision-free readiness.
	 *
	 * @param array  $plan Plan.
	 * @param string $expected_hash Preview hash.
	 * @return true|WP_Error
	 */
	private function validate_plan_precondition( array $plan, $expected_hash ) {
		if ( ! hash_equals( $plan['planHash'], $expected_hash ) ) {
			return new WP_Error(
				'pufferpdf_stale_publication_plan',
				__( 'The source or destination changed after preview. Preview the plan again.', 'pufferpdf' ),
				array(
					'status' => 409,
					'plan'   => $this->planner->format_plan_response( $plan ),
				)
			);
		}
		if ( ! $plan['canCreate'] ) {
			return new WP_Error(
				'pufferpdf_plan_has_collisions',
				__( 'Resolve every title and slug collision before creating the publication.', 'pufferpdf' ),
				array(
					'status' => 409,
					'plan'   => $this->planner->format_plan_response( $plan ),
				)
			);
		}

		return true;
	}

	/**
	 * Find the current user's immutable manifest for an idempotency key.
	 *
	 * @param string $request_id Request ID.
	 * @return int
	 */
	private function get_manifest_by_request( $request_id ) {
		$ids = get_posts(
			array(
				'post_type'      => self::POST_TYPE,
				'post_status'    => 'private',
				'author'         => get_current_user_id(),
				'posts_per_page' => 1,
				'fields'         => 'ids',
				'orderby'        => 'ID',
				'order'          => 'DESC',
				'meta_query'     => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
					array(
						'key'   => self::META_REQUEST_ID,
						'value' => $request_id,
					),
					array(
						'key'   => self::META_KIND,
						'value' => 'publication',
					),
				),
			)
		);

		return empty( $ids ) ? 0 : (int) $ids[0];
	}

	/**
	 * Return the stable stored response for an idempotent retry.
	 *
	 * @param int    $manifest_id Manifest ID.
	 * @param string $expected_hash Expected plan hash.
	 * @return WP_REST_Response|WP_Error
	 */
	private function replay_manifest( $manifest_id, $expected_hash ) {
		$stored_hash = (string) get_post_meta( $manifest_id, self::META_PLAN_HASH, true );
		if ( ! preg_match( '/\A[a-f0-9]{64}\z/', $stored_hash ) || ! hash_equals( $stored_hash, $expected_hash ) ) {
			return new WP_Error(
				'pufferpdf_request_id_conflict',
				__( 'This request ID already belongs to a different publication plan.', 'pufferpdf' ),
				array( 'status' => 409 )
			);
		}

		$report = get_post_meta( $manifest_id, self::META_REPORT, true );
		if ( ! is_array( $report ) || empty( $report['objects'] ) ) {
			return new WP_Error(
				'pufferpdf_manifest_incomplete',
				__( 'The stored publication report is incomplete.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}
		$report['unchanged'] = true;

		return rest_ensure_response( $report );
	}

	/**
	 * Load an owned publication manifest without disclosing another user's batch.
	 *
	 * @param int $manifest_id Manifest ID.
	 * @return WP_Post|WP_Error
	 */
	private function get_owned_manifest( $manifest_id ) {
		$manifest = get_post( $manifest_id );
		if (
			! $manifest ||
			self::POST_TYPE !== $manifest->post_type ||
			'private' !== $manifest->post_status ||
			'publication' !== get_post_meta( $manifest_id, self::META_KIND, true ) ||
			get_current_user_id() !== (int) $manifest->post_author
		) {
			return new WP_Error(
				'pufferpdf_publication_not_found',
				__( 'The publication could not be found.', 'pufferpdf' ),
				array( 'status' => 404 )
			);
		}

		return $manifest;
	}

	/**
	 * Recheck the complete trashed batch and both manifests before commit.
	 *
	 * @param int    $manifest_id Publication manifest ID.
	 * @param int    $reversal_id Reversal manifest ID.
	 * @param int[]  $object_ids Trashed object IDs.
	 * @param string $plan_hash Locked publication identity.
	 * @param array  $manifest_core_state Locked publication-manifest core state.
	 * @param string $manifest_meta_hash Locked publication-manifest metadata hash.
	 * @param array  $reversal_core_state Reversal core state captured after insertion.
	 * @param string $reversal_meta_hash Reversal metadata hash captured after insertion.
	 * @return true|WP_Error
	 */
	private function verify_final_undo_state( $manifest_id, $reversal_id, array $object_ids, $plan_hash, array $manifest_core_state, $manifest_meta_hash, array $reversal_core_state, $reversal_meta_hash ) {
		$this->storage->clean_runtime_caches( array_merge( array( $manifest_id, $reversal_id ), $object_ids ) );

		foreach ( $object_ids as $post_id ) {
			$post = get_post( $post_id );
			if (
				! $post instanceof WP_Post ||
				'trash' !== $post->post_status ||
				(int) get_post_meta( $post_id, self::META_MANIFEST_ID, true ) !== $manifest_id ||
				(string) get_post_meta( $post_id, self::META_PLAN_HASH, true ) !== $plan_hash
			) {
				return new WP_Error(
					'pufferpdf_undo_state_changed',
					__( 'Another component changed a publication object before undo could commit.', 'pufferpdf' ),
					array( 'status' => 500 )
				);
			}
		}

		$manifest = get_post( $manifest_id );
		$reversal = get_post( $reversal_id );
		if (
			! $manifest instanceof WP_Post ||
			$this->get_core_post_state( $manifest ) !== $manifest_core_state ||
			$this->get_post_metadata_hash( $manifest_id ) !== $manifest_meta_hash ||
			self::POST_TYPE !== $manifest->post_type ||
			'private' !== $manifest->post_status ||
			(string) get_post_meta( $manifest_id, self::META_PLAN_HASH, true ) !== $plan_hash ||
			! $reversal instanceof WP_Post ||
			$this->get_core_post_state( $reversal ) !== $reversal_core_state ||
			$this->get_post_metadata_hash( $reversal_id ) !== $reversal_meta_hash ||
			self::POST_TYPE !== $reversal->post_type ||
			'private' !== $reversal->post_status ||
			$manifest_id !== (int) $reversal->post_parent ||
			'reversal' !== (string) get_post_meta( $reversal_id, self::META_KIND, true ) ||
			(int) get_post_meta( $reversal_id, self::META_REVERSAL_FOR, true ) !== $manifest_id ||
			(string) get_post_meta( $reversal_id, self::META_PLAN_HASH, true ) !== $plan_hash
		) {
			return new WP_Error(
				'pufferpdf_undo_state_changed',
				__( 'Another component changed the undo manifest before commit.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}

		return true;
	}

	/**
	 * Find an existing reversal manifest.
	 *
	 * @param int $manifest_id Publication manifest ID.
	 * @return int
	 */
	private function get_reversal_id( $manifest_id ) {
		$ids = get_posts(
			array(
				'post_type'      => self::POST_TYPE,
				'post_status'    => 'private',
				'post_parent'    => $manifest_id,
				'posts_per_page' => 1,
				'fields'         => 'ids',
				'orderby'        => 'ID',
				'order'          => 'DESC',
				'meta_key'       => self::META_REVERSAL_FOR, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
				'meta_value'     => $manifest_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
			)
		);

		return empty( $ids ) ? 0 : (int) $ids[0];
	}

	/**
	 * Read immutable manifest state through a locking current read.
	 *
	 * @param int $manifest_id Manifest ID whose postmeta rows are already locked.
	 * @return array|WP_Error
	 */
	private function read_locked_manifest_state( $manifest_id ) {
		global $wpdb;

		$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key IN (%s, %s) ORDER BY meta_id ASC FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$manifest_id,
				self::META_OBJECTS,
				self::META_PLAN_HASH
			),
			ARRAY_A
		);
		if ( ! is_array( $rows ) ) {
			return new WP_Error(
				'pufferpdf_storage_unavailable',
				__( 'PufferPDF could not read the locked publication manifest.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}

		$state = array(
			'objects'  => array(),
			'planHash' => '',
		);
		foreach ( $rows as $row ) {
			if ( self::META_OBJECTS === $row['meta_key'] && empty( $state['objects'] ) ) {
				$value            = maybe_unserialize( $row['meta_value'] );
				$state['objects'] = is_array( $value ) ? $value : array();
			} elseif ( self::META_PLAN_HASH === $row['meta_key'] && '' === $state['planHash'] ) {
				$state['planHash'] = (string) maybe_unserialize( $row['meta_value'] );
			}
		}

		return $state;
	}

	/**
	 * Return whether all immutable snapshots are still safe to Trash together.
	 *
	 * @param int        $manifest_id Manifest ID.
	 * @param bool       $lock_related Whether related-row gaps must be locked for execution.
	 * @param array|null $snapshots Locked manifest snapshots, or null for a preview read.
	 * @return array
	 */
	private function assess_undo( $manifest_id, $lock_related = false, $snapshots = null ) {
		global $wpdb;

		if ( null === $snapshots ) {
			$snapshots = get_post_meta( $manifest_id, self::META_OBJECTS, true );
		}
		$snapshots = is_array( $snapshots ) ? $snapshots : array();
		$blockers  = array();
		$objects   = array();
		$owned_ids = array();
		foreach ( $snapshots as $snapshot ) {
			$owned_ids[] = isset( $snapshot['id'] ) ? absint( $snapshot['id'] ) : 0;
		}

		if ( empty( $snapshots ) ) {
			$blockers[] = array(
				'code'    => 'missing_snapshot',
				'message' => __( 'The publication snapshot is missing.', 'pufferpdf' ),
			);
		}
		if ( ! defined( 'EMPTY_TRASH_DAYS' ) || (int) EMPTY_TRASH_DAYS < 1 ) {
			$blockers[] = array(
				'code'    => 'trash_disabled',
				'message' => __( 'Recoverable WordPress Trash is disabled.', 'pufferpdf' ),
			);
		}

		foreach ( $snapshots as $snapshot ) {
			$post_id = isset( $snapshot['id'] ) ? absint( $snapshot['id'] ) : 0;
			$post    = get_post( $post_id );
			if ( ! $post ) {
				$blockers[] = array(
					'code'    => 'missing_object',
					'id'      => $post_id,
					'message' => __( 'A publication object no longer exists.', 'pufferpdf' ),
				);
				continue;
			}
			$objects[] = $this->format_created_object( $post, isset( $snapshot['kind'] ) ? $snapshot['kind'] : 'document' );
			if ( ! current_user_can( 'delete_post', $post_id ) ) {
				$blockers[] = array(
					'code'    => 'delete_forbidden',
					'id'      => $post_id,
					'message' => __( 'You can no longer delete one of the publication objects.', 'pufferpdf' ),
				);
				continue;
			}
			if ( (int) get_post_meta( $post_id, self::META_MANIFEST_ID, true ) !== $manifest_id ) {
				$blockers[] = array(
					'code'    => 'ownership_changed',
					'id'      => $post_id,
					'message' => __( 'A publication ownership marker changed.', 'pufferpdf' ),
				);
				continue;
			}

			$current = $this->snapshot_post(
				$post,
				isset( $snapshot['terms'] ) && is_array( $snapshot['terms'] ) ? $this->read_terms( $post_id, array_keys( $snapshot['terms'] ) ) : array(),
				isset( $snapshot['kind'] ) ? $snapshot['kind'] : 'document'
			);
			if ( $current !== $snapshot ) {
				$blockers[] = array(
					'code'    => 'object_changed',
					'id'      => $post_id,
					'message' => __( 'A publication object was edited after creation.', 'pufferpdf' ),
				);
			}

			if ( $lock_related ) {
				$comment_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
					$wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_post_ID = %d LIMIT 1 FOR UPDATE", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				);
			} else {
				$comment_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
					$wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_post_ID = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				);
			}
			if ( $comment_id ) {
				$blockers[] = array(
					'code'    => 'external_comment',
					'id'      => $post_id,
					'message' => __( 'A publication object now has comment data that automatic undo must preserve.', 'pufferpdf' ),
				);
			}

			if ( $lock_related ) {
				$children = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
					$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_parent = %d AND post_status NOT IN ('trash', 'auto-draft') AND post_type <> 'revision' FOR UPDATE", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				);
			} else {
				$children = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
					$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_parent = %d AND post_status NOT IN ('trash', 'auto-draft') AND post_type <> 'revision'", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				);
			}
			foreach ( $children as $child_id ) {
				if ( ! in_array( (int) $child_id, $owned_ids, true ) ) {
					$blockers[] = array(
						'code'    => 'external_child',
						'id'      => $post_id,
						'message' => __( 'A publication object now has a child outside this batch.', 'pufferpdf' ),
					);
					break;
				}
			}
		}

		return array(
			'canUndo'  => empty( $blockers ),
			'objects'  => $objects,
			'blockers' => $blockers,
		);
	}

	/**
	 * Read exact sorted term IDs for selected taxonomies.
	 *
	 * @param int      $post_id Post ID.
	 * @param string[] $taxonomies Taxonomy names.
	 * @return array
	 */
	private function read_terms( $post_id, array $taxonomies ) {
		$terms = array();
		foreach ( $taxonomies as $taxonomy ) {
			$ids = wp_get_object_terms( $post_id, $taxonomy, array( 'fields' => 'ids' ) );
			$ids = is_wp_error( $ids ) ? array() : array_values( array_unique( array_map( 'absint', $ids ) ) );
			sort( $ids, SORT_NUMERIC );
			$terms[ $taxonomy ] = $ids;
		}
		ksort( $terms, SORT_STRING );

		return $terms;
	}

	/**
	 * Return a consistent malformed request error.
	 *
	 * @param string $message Message.
	 * @return WP_Error
	 */
	private function invalid_request( $message ) {
		return new WP_Error(
			'pufferpdf_invalid_publication_request',
			$message,
			array( 'status' => 400 )
		);
	}
}
