<?php
/**
 * Media Library source and immutable conversion-record storage.
 *
 * @package PufferPDF
 */

defined( 'ABSPATH' ) || exit;

/**
 * Keeps attachment inspection and conversion history separate from importing.
 */
final class PufferPDF_Records {

	/** Private post type used for immutable conversion records. */
	public const POST_TYPE = 'pufferpdf_record';

	/** Record meta keys. */
	private const META_ATTACHMENT_ID       = '_pufferpdf_attachment_id';
	private const META_SOURCE_HASH         = '_pufferpdf_source_hash';
	private const META_HASH_VERIFICATION   = '_pufferpdf_hash_verification';
	private const META_SOURCE_BYTES        = '_pufferpdf_source_bytes';
	private const META_SOURCE_MTIME        = '_pufferpdf_source_mtime';
	private const META_ENGINE_VERSION      = '_pufferpdf_engine_version';
	private const META_PROFILE             = '_pufferpdf_profile';
	private const META_PAGE_DIVIDERS       = '_pufferpdf_page_dividers';
	private const META_PDF_TYPE            = '_pufferpdf_pdf_type';
	private const META_PAGE_COUNT          = '_pufferpdf_page_count';
	private const META_PAGES_NEEDING_OCR   = '_pufferpdf_pages_needing_ocr';
	private const META_ENCODING_ISSUES     = '_pufferpdf_has_encoding_issues';
	private const META_CONTENT_HASH        = '_pufferpdf_content_hash';
	private const META_TARGET_POST_ID      = '_pufferpdf_target_post_id';
	private const META_TARGET_MODIFIED_GMT = '_pufferpdf_target_modified_gmt';
	private const META_PREVIOUS_RECORD_ID  = '_pufferpdf_previous_record_id';
	private const META_CONVERTED_AT_GMT    = '_pufferpdf_converted_at_gmt';

	/**
	 * Whether the core post tables support transactions.
	 *
	 * @var bool|null
	 */
	private $transaction_tables_supported = null;

	/**
	 * Current attachment mutex name.
	 *
	 * @var string
	 */
	private $active_lock_name = '';

	/**
	 * Register hidden, non-editable conversion records.
	 *
	 * @return void
	 */
	public function register_post_type() {
		register_post_type(
			self::POST_TYPE,
			array(
				'labels'              => array(
					'name'          => __( 'PufferPDF conversion records', 'pufferpdf' ),
					'singular_name' => __( 'PufferPDF conversion record', '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',
					'delete_published_posts' => 'do_not_allow',
					'publish_posts'          => 'do_not_allow',
					'read_private_posts'     => 'do_not_allow',
				),
			)
		);
	}

	/**
	 * Return one bounded page of Media Library PDFs and their conversion state.
	 *
	 * @param WP_REST_Request $request REST request.
	 * @param int             $max_pdf_bytes Maximum PDF size eligible for server-side hashing.
	 * @return WP_REST_Response
	 */
	public function get_attachment_inventory( WP_REST_Request $request, $max_pdf_bytes ) {
		$page                    = max( 1, (int) $request->get_param( 'page' ) );
		$per_page                = min( 50, max( 1, (int) $request->get_param( 'perPage' ) ) );
		$search                  = trim( (string) $request->get_param( 'search' ) );
		$requested_attachment_id = absint( $request->get_param( 'attachmentId' ) );

		if ( $requested_attachment_id ) {
			$attachment = get_post( $requested_attachment_id );
			if ( ! $attachment || 'attachment' !== $attachment->post_type || 'inherit' !== $attachment->post_status ) {
				return new WP_Error(
					'pufferpdf_attachment_not_found',
					__( 'The selected Media Library file could not be found.', 'pufferpdf' ),
					array( 'status' => 404 )
				);
			}

			if ( ! current_user_can( 'edit_post', $requested_attachment_id ) ) {
				return new WP_Error(
					'pufferpdf_attachment_forbidden',
					__( 'You do not have permission to inspect this Media Library file.', 'pufferpdf' ),
					array( 'status' => 403 )
				);
			}

			if ( 'application/pdf' !== get_post_mime_type( $requested_attachment_id ) ) {
				return new WP_Error(
					'pufferpdf_attachment_not_pdf',
					__( 'The selected Media Library item is not a PDF.', 'pufferpdf' ),
					array( 'status' => 415 )
				);
			}

			return rest_ensure_response(
				array(
					'items'      => array( $this->format_attachment_inventory_item( $requested_attachment_id, $max_pdf_bytes, true ) ),
					'page'       => 1,
					'perPage'    => 1,
					'total'      => 1,
					'totalPages' => 1,
				)
			);
		}

		$args = array(
			'post_type'      => 'attachment',
			'post_status'    => 'inherit',
			'post_mime_type' => 'application/pdf',
			'posts_per_page' => $per_page,
			'paged'          => $page,
			'orderby'        => array(
				'date' => 'DESC',
				'ID'   => 'DESC',
			),
			'fields'         => 'ids',
			'no_found_rows'  => false,
		);

		if ( '' !== $search ) {
			$args['s'] = $search;
		}

		$attachment_type = get_post_type_object( 'attachment' );
		if ( $attachment_type && ! current_user_can( $attachment_type->cap->edit_others_posts ) ) {
			$args['author'] = get_current_user_id();
		}

		$query       = new WP_Query( $args );
		$items       = array();
		$total       = (int) $query->found_posts;
		$total_pages = (int) $query->max_num_pages;

		if ( $page > 1 && empty( $query->posts ) && 0 === $total ) {
			$probe_args          = $args;
			$probe_args['paged'] = 1;
			$probe               = new WP_Query( $probe_args );
			$total               = (int) $probe->found_posts;
			$total_pages         = (int) $probe->max_num_pages;
		}

		foreach ( $query->posts as $attachment_id ) {
			if ( ! current_user_can( 'edit_post', $attachment_id ) ) {
				continue;
			}

			$items[] = $this->format_attachment_inventory_item( $attachment_id, $max_pdf_bytes, false );
		}

		return rest_ensure_response(
			array(
				'items'      => $items,
				'page'       => $page,
				'perPage'    => $per_page,
				'total'      => $total,
				'totalPages' => $total_pages,
			)
		);
	}

	/**
	 * Validate a selected PDF and compare its exact server hash with the browser hash.
	 *
	 * @param int    $attachment_id Attachment ID.
	 * @param string $source_hash Browser-computed SHA-256.
	 * @param int    $max_pdf_bytes Maximum accepted PDF size.
	 * @return array|WP_Error
	 */
	public function validate_attachment_source( $attachment_id, $source_hash, $max_pdf_bytes ) {
		$attachment = get_post( $attachment_id );

		if ( ! $attachment || 'attachment' !== $attachment->post_type || 'inherit' !== $attachment->post_status ) {
			return new WP_Error(
				'pufferpdf_attachment_not_found',
				__( 'The selected Media Library file could not be found.', 'pufferpdf' ),
				array( 'status' => 404 )
			);
		}

		if ( ! current_user_can( 'upload_files' ) || ! current_user_can( 'edit_post', $attachment_id ) ) {
			return new WP_Error(
				'pufferpdf_attachment_forbidden',
				__( 'You do not have permission to convert this Media Library file.', 'pufferpdf' ),
				array( 'status' => 403 )
			);
		}

		if ( 'application/pdf' !== get_post_mime_type( $attachment_id ) ) {
			return new WP_Error(
				'pufferpdf_attachment_not_pdf',
				__( 'The selected Media Library item is not a PDF.', 'pufferpdf' ),
				array( 'status' => 415 )
			);
		}

		if ( ! is_string( $source_hash ) || ! preg_match( '/\A[a-f0-9]{64}\z/i', $source_hash ) ) {
			return new WP_Error(
				'pufferpdf_invalid_source_hash',
				__( 'A valid SHA-256 source hash is required for Media Library imports.', 'pufferpdf' ),
				array( 'status' => 400 )
			);
		}

		$file         = get_attached_file( $attachment_id );
		$hash         = strtolower( $source_hash );
		$size         = null;
		$source_mtime = null;
		$verification = 'browser';

		if ( is_string( $file ) && is_file( $file ) && is_readable( $file ) ) {
			$size = wp_filesize( $file );
			if ( false === $size || $size < 1 ) {
				return new WP_Error(
					'pufferpdf_attachment_unverifiable',
					__( 'The selected PDF size could not be verified.', 'pufferpdf' ),
					array( 'status' => 422 )
				);
			}

			if ( $size > $max_pdf_bytes ) {
				return new WP_Error(
					'pufferpdf_pdf_too_large',
					__( 'The selected PDF is too large for the browser converter.', 'pufferpdf' ),
					array( 'status' => 413 )
				);
			}

			clearstatcache( true, $file );
			$before      = stat( $file );
			$server_hash = hash_file( 'sha256', $file );
			clearstatcache( true, $file );
			$after = stat( $file );

			if (
				false === $before ||
				false === $after ||
				! is_string( $server_hash ) ||
				! hash_equals( (string) $before['size'], (string) $after['size'] ) ||
				! hash_equals( (string) $before['mtime'], (string) $after['mtime'] )
			) {
				return new WP_Error(
					'pufferpdf_source_changed',
					__( 'The selected PDF changed while it was being verified. Reload it and convert again.', 'pufferpdf' ),
					array( 'status' => 409 )
				);
			}

			$server_hash = strtolower( $server_hash );
			if ( ! hash_equals( $server_hash, $hash ) ) {
				return new WP_Error(
					'pufferpdf_source_changed',
					__( 'The selected PDF changed after conversion. Reload it before importing.', 'pufferpdf' ),
					array( 'status' => 409 )
				);
			}

			$hash         = $server_hash;
			$size         = (int) $after['size'];
			$source_mtime = (int) $after['mtime'];
			$verification = 'server';
		}

		$attachment_url = wp_get_attachment_url( $attachment_id );
		$filename       = is_string( $file ) && '' !== $file
			? wp_basename( $file )
			: wp_basename( (string) wp_parse_url( $attachment_url, PHP_URL_PATH ) );

		return array(
			'attachmentId' => (int) $attachment_id,
			'filename'     => sanitize_file_name( $filename ),
			'hash'         => $hash,
			'bytes'        => null === $size ? null : (int) $size,
			'mtime'        => $source_mtime,
			'verification' => $verification,
		);
	}

	/**
	 * Return the newest record ID for an attachment.
	 *
	 * @param int $attachment_id Attachment ID.
	 * @return int
	 */
	public function get_latest_record_id( $attachment_id ) {
		$records = get_posts(
			array(
				'post_type'              => self::POST_TYPE,
				'post_status'            => 'private',
				'post_parent'            => absint( $attachment_id ),
				'posts_per_page'         => 1,
				'orderby'                => 'ID',
				'order'                  => 'DESC',
				'fields'                 => 'ids',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
			)
		);

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

	/**
	 * Read a normalized internal record descriptor.
	 *
	 * @param int $record_id Record ID.
	 * @return array|WP_Error
	 */
	public function get_record_data( $record_id ) {
		$record = get_post( $record_id );
		if ( ! $record || self::POST_TYPE !== $record->post_type || 'private' !== $record->post_status ) {
			return new WP_Error(
				'pufferpdf_record_not_found',
				__( 'The requested conversion record could not be found.', 'pufferpdf' ),
				array( 'status' => 404 )
			);
		}

		$pages = get_post_meta( $record_id, self::META_PAGES_NEEDING_OCR, true );

		return array(
			'id'                => (int) $record_id,
			'attachmentId'      => (int) get_post_meta( $record_id, self::META_ATTACHMENT_ID, true ),
			'sourceHash'        => (string) get_post_meta( $record_id, self::META_SOURCE_HASH, true ),
			'verification'      => (string) get_post_meta( $record_id, self::META_HASH_VERIFICATION, true ),
			'sourceBytes'       => $this->get_nullable_integer_meta( $record_id, self::META_SOURCE_BYTES ),
			'sourceMtime'       => $this->get_nullable_integer_meta( $record_id, self::META_SOURCE_MTIME ),
			'engineVersion'     => (string) get_post_meta( $record_id, self::META_ENGINE_VERSION, true ),
			'profile'           => (string) get_post_meta( $record_id, self::META_PROFILE, true ),
			'pageDividers'      => (bool) get_post_meta( $record_id, self::META_PAGE_DIVIDERS, true ),
			'pdfType'           => (string) get_post_meta( $record_id, self::META_PDF_TYPE, true ),
			'pageCount'         => (int) get_post_meta( $record_id, self::META_PAGE_COUNT, true ),
			'pagesNeedingOcr'   => is_array( $pages ) ? array_values( array_map( 'absint', $pages ) ) : array(),
			'hasEncodingIssues' => (bool) get_post_meta( $record_id, self::META_ENCODING_ISSUES, true ),
			'contentHash'       => (string) get_post_meta( $record_id, self::META_CONTENT_HASH, true ),
			'targetPostId'      => (int) get_post_meta( $record_id, self::META_TARGET_POST_ID, true ),
			'targetModifiedGmt' => (string) get_post_meta( $record_id, self::META_TARGET_MODIFIED_GMT, true ),
			'previousRecordId'  => (int) get_post_meta( $record_id, self::META_PREVIOUS_RECORD_ID, true ),
			'convertedAtGmt'    => (string) get_post_meta( $record_id, self::META_CONVERTED_AT_GMT, true ),
		);
	}

	/**
	 * Compare all idempotency fields and the current target content.
	 *
	 * @param array   $record Existing normalized record.
	 * @param array   $conversion Proposed conversion descriptor.
	 * @param WP_Post $target Current target post.
	 * @return bool
	 */
	public function conversion_is_unchanged( array $record, array $conversion, WP_Post $target ) {
		return hash_equals( $record['sourceHash'], $conversion['sourceHash'] ) &&
			$record['engineVersion'] === $conversion['engineVersion'] &&
			$record['verification'] === $conversion['verification'] &&
			$record['profile'] === $conversion['profile'] &&
			$record['pageDividers'] === $conversion['pageDividers'] &&
			$record['pdfType'] === $conversion['pdfType'] &&
			$record['pageCount'] === $conversion['pageCount'] &&
			$record['pagesNeedingOcr'] === $conversion['pagesNeedingOcr'] &&
			$record['hasEncodingIssues'] === $conversion['hasEncodingIssues'] &&
			hash_equals( $record['contentHash'], hash( 'sha256', $conversion['content'] ) ) &&
			hash_equals( $record['contentHash'], hash( 'sha256', (string) $target->post_content ) );
	}

	/**
	 * Check that editorial content still matches the selected immutable record.
	 *
	 * @param array   $record Existing normalized record.
	 * @param WP_Post $target Current target post.
	 * @return bool
	 */
	public function record_matches_target_content( array $record, WP_Post $target ) {
		return preg_match( '/\A[a-f0-9]{64}\z/', $record['contentHash'] ) &&
			hash_equals( $record['contentHash'], hash( 'sha256', (string) $target->post_content ) );
	}

	/**
	 * Verify a newly stored record against its complete intended descriptor.
	 *
	 * @param array   $record Stored normalized record.
	 * @param array   $conversion Intended conversion descriptor.
	 * @param WP_Post $target Verified stored target.
	 * @param int     $previous_record_id Intended previous record ID.
	 * @return bool
	 */
	public function record_matches_conversion( array $record, array $conversion, WP_Post $target, $previous_record_id ) {
		$record_post = get_post( $record['id'] );

		return $record_post &&
			self::POST_TYPE === $record_post->post_type &&
			'private' === $record_post->post_status &&
			(int) $record_post->post_parent === $conversion['attachmentId'] &&
			$record['attachmentId'] === $conversion['attachmentId'] &&
			$record['sourceBytes'] === $conversion['sourceBytes'] &&
			$record['sourceMtime'] === $conversion['sourceMtime'] &&
			$record['targetPostId'] === (int) $target->ID &&
			$record['targetModifiedGmt'] === $this->get_target_modified_gmt( $target ) &&
			absint( $previous_record_id ) === $record['previousRecordId'] &&
			(bool) preg_match( '/\A\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\z/', $record['convertedAtGmt'] ) &&
			'0000-00-00 00:00:00' !== $record['convertedAtGmt'] &&
			$this->conversion_is_unchanged( $record, $conversion, $target );
	}

	/**
	 * Return a usable GMT concurrency token for drafts whose GMT field is zeroed.
	 *
	 * WordPress can leave post_modified_gmt at zero for unscheduled drafts even
	 * though post_modified contains the real site-local modification time.
	 *
	 * @param WP_Post $target Target post.
	 * @return string
	 */
	public function get_target_modified_gmt( WP_Post $target ) {
		$modified_gmt = (string) $target->post_modified_gmt;
		if ( '' !== $modified_gmt && '0000-00-00 00:00:00' !== $modified_gmt ) {
			return $modified_gmt;
		}

		$modified = (string) $target->post_modified;
		if ( '' === $modified || '0000-00-00 00:00:00' === $modified ) {
			return '';
		}

		$converted = get_gmt_from_date( $modified, 'Y-m-d H:i:s' );

		return '0000-00-00 00:00:00' === $converted ? '' : $converted;
	}

	/**
	 * Acquire a connection-scoped mutex for one attachment import.
	 *
	 * @param int $attachment_id Attachment ID.
	 * @return string|WP_Error Lock name on success.
	 */
	public function acquire_attachment_lock( $attachment_id ) {
		global $wpdb;

		if ( '' !== $this->active_lock_name ) {
			return new WP_Error(
				'pufferpdf_attachment_busy',
				__( 'A conversion is already being saved in this request.', 'pufferpdf' ),
				array( 'status' => 409 )
			);
		}

		$database_scope = defined( 'DB_NAME' ) ? DB_NAME : $wpdb->dbname;
		$lock_name      = 'pufferpdf_' . md5( $database_scope . ':' . $wpdb->prefix ) . '_' . absint( $attachment_id );
		$lock_name      = substr( $lock_name, 0, 64 );
		$acquired       = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock_name )
		);

		if ( '1' !== (string) $acquired ) {
			return new WP_Error(
				'pufferpdf_attachment_busy',
				__( 'Another conversion for this PDF is being saved. Try again in a moment.', 'pufferpdf' ),
				array( 'status' => 409 )
			);
		}

		$this->active_lock_name = $lock_name;

		return $lock_name;
	}

	/**
	 * Release a connection-scoped attachment mutex.
	 *
	 * @param string $lock_name Lock name returned by acquire_attachment_lock().
	 * @return void
	 */
	public function release_attachment_lock( $lock_name ) {
		global $wpdb;

		if ( $this->active_lock_name !== $lock_name ) {
			return;
		}

		$wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name )
		);
		$this->active_lock_name = '';
	}

	/**
	 * Start an atomic target-and-record write transaction.
	 *
	 * @return true|WP_Error
	 */
	public function begin_write_transaction() {
		global $wpdb;

		if ( ! $this->storage_supports_transactions() ) {
			return new WP_Error(
				'pufferpdf_transaction_storage_required',
				__( 'PufferPDF safe reprocessing requires transactional WordPress post storage.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$result = $wpdb->query( 'START TRANSACTION' );
		if ( false === $result ) {
			return new WP_Error(
				'pufferpdf_storage_unavailable',
				__( 'PufferPDF could not start a safe database write.', 'pufferpdf' ),
				array( 'status' => 500 )
			);
		}

		return true;
	}

	/**
	 * Commit an atomic target-and-record write transaction.
	 *
	 * @return bool
	 */
	public function commit_write_transaction() {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		return false !== $wpdb->query( 'COMMIT' );
	}

	/**
	 * Roll back an atomic target-and-record write transaction.
	 *
	 * @param array $post_ids Posts whose runtime caches must be cleared.
	 * @return void
	 */
	public function rollback_write_transaction( array $post_ids ) {
		global $wpdb;

		$wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

		foreach ( array_unique( array_filter( array_map( 'absint', $post_ids ) ) ) as $post_id ) {
			clean_post_cache( $post_id );
			wp_cache_delete( $post_id, 'post_meta' );
		}
	}

	/**
	 * Lock a target row and reload it from the database inside the transaction.
	 *
	 * @param int $target_post_id Target post ID.
	 * @return WP_Post|null
	 */
	public function lock_target_post( $target_post_id ) {
		global $wpdb;

		$locked_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SELECT ID FROM {$wpdb->posts} WHERE ID = %d FOR UPDATE",
				absint( $target_post_id )
			)
		);

		if ( ! $locked_id ) {
			return null;
		}

		clean_post_cache( $locked_id );

		return get_post( $locked_id );
	}

	/**
	 * Verify that core post writes can actually be rolled back.
	 *
	 * @return bool
	 */
	private function storage_supports_transactions() {
		global $wpdb;

		if ( null !== $this->transaction_tables_supported ) {
			return $this->transaction_tables_supported;
		}

		$database = defined( 'DB_NAME' ) ? DB_NAME : $wpdb->dbname;
		$rows     = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				'SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME IN (%s, %s)',
				$database,
				$wpdb->posts,
				$wpdb->postmeta
			),
			ARRAY_A
		);

		$engines = array();
		foreach ( (array) $rows as $row ) {
			$engines[ $row['TABLE_NAME'] ] = strtoupper( (string) $row['ENGINE'] );
		}

		$this->transaction_tables_supported =
			isset( $engines[ $wpdb->posts ], $engines[ $wpdb->postmeta ] ) &&
			'INNODB' === $engines[ $wpdb->posts ] &&
			'INNODB' === $engines[ $wpdb->postmeta ];

		return $this->transaction_tables_supported;
	}

	/**
	 * Insert a new immutable conversion record.
	 *
	 * @param array $conversion Conversion descriptor.
	 * @param int   $target_post_id Draft target ID.
	 * @param int   $previous_record_id Previous record in this explicit reprocessing chain.
	 * @return int|WP_Error
	 */
	public function create_record( array $conversion, $target_post_id, $previous_record_id ) {
		$converted_at_gmt = current_time( 'mysql', true );
		$target           = get_post( $target_post_id );
		if ( ! $target ) {
			return new WP_Error(
				'pufferpdf_target_unavailable',
				__( 'The conversion target could not be found.', 'pufferpdf' )
			);
		}
		$target_modified_gmt = $this->get_target_modified_gmt( $target );
		$record_id           = wp_insert_post(
			wp_slash(
				array(
					'post_type'   => self::POST_TYPE,
					'post_status' => 'private',
					'post_parent' => $conversion['attachmentId'],
					'post_title'  => sprintf(
						/* translators: 1: Media attachment ID, 2: target draft ID. */
						__( 'Attachment %1$d to draft %2$d', 'pufferpdf' ),
						$conversion['attachmentId'],
						$target_post_id
					),
					'post_author' => get_current_user_id(),
					'meta_input'  => array(
						self::META_ATTACHMENT_ID       => $conversion['attachmentId'],
						self::META_SOURCE_HASH         => $conversion['sourceHash'],
						self::META_HASH_VERIFICATION   => $conversion['verification'],
						self::META_SOURCE_BYTES        => $conversion['sourceBytes'],
						self::META_SOURCE_MTIME        => $conversion['sourceMtime'],
						self::META_ENGINE_VERSION      => $conversion['engineVersion'],
						self::META_PROFILE             => $conversion['profile'],
						self::META_PAGE_DIVIDERS       => $conversion['pageDividers'] ? 1 : 0,
						self::META_PDF_TYPE            => $conversion['pdfType'],
						self::META_PAGE_COUNT          => $conversion['pageCount'],
						self::META_PAGES_NEEDING_OCR   => $conversion['pagesNeedingOcr'],
						self::META_ENCODING_ISSUES     => $conversion['hasEncodingIssues'] ? 1 : 0,
						self::META_CONTENT_HASH        => hash( 'sha256', (string) $target->post_content ),
						self::META_TARGET_POST_ID      => $target_post_id,
						self::META_TARGET_MODIFIED_GMT => $target_modified_gmt,
						self::META_PREVIOUS_RECORD_ID  => absint( $previous_record_id ),
						self::META_CONVERTED_AT_GMT    => $converted_at_gmt,
					),
				)
			),
			true
		);

		return $record_id;
	}

	/**
	 * Format a latest record for the inventory API.
	 *
	 * @param int $record_id Record ID.
	 * @return array|null
	 */
	public function format_record_for_api( $record_id ) {
		if ( ! $record_id ) {
			return null;
		}

		$record = $this->get_record_data( $record_id );
		if ( is_wp_error( $record ) ) {
			return null;
		}

		$target          = get_post( $record['targetPostId'] );
		$can_edit_target = $target && current_user_can( 'edit_post', $target->ID );

		return array(
			'id'             => $record['id'],
			'sourceHash'     => $record['sourceHash'],
			'engineVersion'  => $record['engineVersion'],
			'options'        => array(
				'profile'      => $record['profile'],
				'pageDividers' => $record['pageDividers'],
			),
			'summary'        => array(
				'pdfType'           => $record['pdfType'],
				'pageCount'         => $record['pageCount'],
				'pagesNeedingOcr'   => $record['pagesNeedingOcr'],
				'hasEncodingIssues' => $record['hasEncodingIssues'],
			),
			'convertedAtGmt' => $record['convertedAtGmt'],
			'target'         => array(
				'id'          => $can_edit_target ? (int) $target->ID : 0,
				'title'       => $can_edit_target ? get_the_title( $target ) : '',
				'status'      => $can_edit_target ? (string) $target->post_status : '',
				'modifiedGmt' => $can_edit_target ? $this->get_target_modified_gmt( $target ) : '',
				'editUrl'     => $can_edit_target ? get_edit_post_link( $target->ID, 'raw' ) : '',
			),
		);
	}

	/**
	 * Format a single attachment inventory item.
	 *
	 * @param int  $attachment_id Attachment ID.
	 * @param int  $max_pdf_bytes Maximum hashable bytes.
	 * @param bool $compute_hash Whether this is a bounded exact-item lookup.
	 * @return array
	 */
	private function format_attachment_inventory_item( $attachment_id, $max_pdf_bytes, $compute_hash ) {
		$file = get_attached_file( $attachment_id );
		if ( is_string( $file ) && is_file( $file ) ) {
			clearstatcache( true, $file );
		}
		$bytes            = is_string( $file ) && is_file( $file ) ? wp_filesize( $file ) : false;
		$current_hash     = null;
		$latest_record_id = $this->get_latest_record_id( $attachment_id );
		$latest_record    = $this->format_record_for_api( $latest_record_id );
		$recorded_bytes   = $latest_record_id ? $this->get_nullable_integer_meta( $latest_record_id, self::META_SOURCE_BYTES ) : null;
		$recorded_mtime   = $latest_record_id ? $this->get_nullable_integer_meta( $latest_record_id, self::META_SOURCE_MTIME ) : null;

		if (
			$compute_hash &&
			is_string( $file ) &&
			is_readable( $file ) &&
			false !== $bytes &&
			$bytes > 0 &&
			$bytes <= $max_pdf_bytes
		) {
			$hash = hash_file( 'sha256', $file );
			if ( is_string( $hash ) ) {
				$current_hash = strtolower( $hash );
			}
		}

		if ( null === $latest_record ) {
			$state = 'never';
		} elseif ( $compute_hash && null !== $current_hash ) {
			$state = hash_equals( $latest_record['sourceHash'], $current_hash ) ? 'current' : 'changed';
		} elseif (
			! $compute_hash &&
			false !== $bytes &&
			null !== $recorded_bytes &&
			null !== $recorded_mtime &&
			is_string( $file ) &&
			is_file( $file )
		) {
			clearstatcache( true, $file );
			$current_mtime       = filemtime( $file );
			$fingerprint_matches =
				(int) $bytes === $recorded_bytes &&
				false !== $current_mtime &&
				(int) $current_mtime === $recorded_mtime;
			$state               = $fingerprint_matches ? 'unverified' : 'changed';
		} else {
			$state = 'unverified';
		}

		$attachment = get_post( $attachment_id );

		return array(
			'id'           => (int) $attachment_id,
			'title'        => get_the_title( $attachment_id ),
			'filename'     => is_string( $file ) ? wp_basename( $file ) : '',
			'url'          => wp_get_attachment_url( $attachment_id ),
			'bytes'        => false === $bytes ? null : (int) $bytes,
			'modifiedGmt'  => $attachment ? (string) $attachment->post_modified_gmt : '',
			'state'        => $state,
			'currentHash'  => $current_hash,
			'latestRecord' => $latest_record,
		);
	}

	/**
	 * Read an integer meta value while preserving a stored null sentinel.
	 *
	 * @param int    $post_id Post ID.
	 * @param string $meta_key Meta key.
	 * @return int|null
	 */
	private function get_nullable_integer_meta( $post_id, $meta_key ) {
		$value = get_post_meta( $post_id, $meta_key, true );

		return '' === $value || null === $value ? null : (int) $value;
	}
}
