<?php
/**
 * File Path: includes/export/class-mbs-press-kit-generator.php
 *
 * Press Kit Generator (INSIGHTS-3)
 *
 * Generates a DOCX press kit for a single book using PHP ZipArchive (the same
 * technique used by MBS_Book_Bible_Builder for XLSX). No Python or server-side
 * shell exec required.
 *
 * DOCX structure created:
 *   [Content_Types].xml, _rels/.rels, word/document.xml,
 *   word/_rels/document.xml.rels, word/styles.xml, word/settings.xml
 *   word/media/cover.{ext}  (when a cover image is available)
 *
 * Cover ratio enforcement:
 *   - Portrait books: 2:3 (width:height). Audiobook / board_book: 1:1.
 *   - Skipped when mbs_press_kit_test_mode is enabled.
 *   - On failure: placeholder block used; admin notice stored as transient.
 *
 * @package MyBookShowroom
 * @since   4.4.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class MBS_Press_Kit_Generator
 *
 * @since 4.4.0
 */
class MBS_Press_Kit_Generator {

	/** Ratio tolerance (±10%). */
	const RATIO_TOLERANCE = 0.10;

	/**
	 * Register the Press Kit AJAX hook.
	 *
	 * @return void
	 * @since 1.3.5
	 */
	public static function register_hooks(): void {
		add_action( 'wp_ajax_mbs_generate_press_kit', array( __CLASS__, 'ajax_generate' ) );
	}

	/**
	 * AJAX handler: generate and stream the Press Kit DOCX for a single book.
	 *
	 * Nonce: mbs_generate_press_kit_{post_id}
	 * Query param: post_id (int)
	 *
	 * @return void
	 * @since 4.4.0
	 */
	public static function ajax_generate(): void {
		$post_id = isset( $_GET['post_id'] ) ? (int) wp_unslash( $_GET['post_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- input sanitized via sanitize_* helpers or validated before use; see inline sanitization
		check_ajax_referer( 'mbs_generate_press_kit_' . $post_id, 'nonce' );

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			wp_die( esc_html__( 'Insufficient permissions.', 'my-book-showroom' ), '', array( 'response' => 403 ) );
		}
		if ( ! mbs_is_pro() ) {
			// MBS: Press Kit requires PRO license
			wp_die( esc_html__( 'Press Kit requires a PRO license.', 'my-book-showroom' ), '', array( 'response' => 403 ) );
		}
		try {
			$file = self::generate( $post_id );
		} catch ( \RuntimeException $e ) {
			wp_die( esc_html( $e->getMessage() ), '', array( 'response' => 500 ) );
		}
		if ( ! file_exists( $file ) ) {
			wp_die( esc_html__( 'Press Kit file could not be created.', 'my-book-showroom' ), '', array( 'response' => 500 ) );
		}
		$post = get_post( $post_id );
		$slug = $post
			? sanitize_file_name( $post->post_name ?: sanitize_title( get_the_title( $post_id ) ) )
			: 'press-kit';
		header( 'Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document' );
		header( 'Content-Disposition: attachment; filename="' . $slug . '-press-kit.docx"' );
		header( 'Content-Length: ' . (string) filesize( $file ) );
		header( 'Pragma: public' );
		header( 'Cache-Control: must-revalidate' );
		readfile( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_readfile, WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- WP_Filesystem migration deferred to PC-FILESYS sprint; PHP native file ops are safe here (temp files in get_temp_dir(), never inside wp-content)
		exit;
	}

	/**
	 * Register the press kit ratio error admin notice hook.
	 *
	 * @return void
	 * @since 1.3.5
	 */
	public static function register_notice_hooks(): void {
		add_action( 'admin_notices', array( __CLASS__, 'show_ratio_error_notice' ) );
	}

	/**
	 * INSIGHTS-3: Cover ratio error notice for Press Kit generation.
	 *
	 * @return void
	 */
	public static function show_ratio_error_notice(): void {
		if ( function_exists( 'get_current_screen' ) ) {
			$screen = get_current_screen();
			if ( $screen && 'post' === $screen->base && MBS_POST_TYPE === $screen->post_type ) {
				$post_id     = isset( $_GET['post'] ) ? (int) wp_unslash( $_GET['post'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- input sanitized via sanitize_* helpers or validated before use; see inline sanitization
				$ratio_error = $post_id ? get_transient( 'mbs_press_kit_ratio_error_' . $post_id ) : false;
				if ( ! empty( $ratio_error ) ) {
					?>
			<div class="notice notice-warning is-dismissible">
				<p><strong><?php esc_html_e( 'Press Kit:', 'my-book-showroom' ); ?></strong> <?php echo esc_html( $ratio_error ); ?></p>
			</div>
				<?php
					delete_transient( 'mbs_press_kit_ratio_error_' . $post_id );
				}
			}
		}
	}

	/**
	 * Generate a press kit DOCX for the given post and return the file path.
	 *
	 * @param int $post_id WP post ID of an mbs_book post.
	 * @return string Absolute path to the generated temp DOCX file.
	 * @throws RuntimeException On ZipArchive failure or write error.
	 * @since 4.4.0
	 */
	public static function generate( int $post_id ): string {
		$post = get_post( $post_id );
		if ( ! $post || MBS_POST_TYPE !== $post->post_type ) {
			throw new \RuntimeException( 'Invalid book post ID.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message is internal error string, never rendered as HTML
		}
		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new \RuntimeException( 'ZipArchive is not available on this server.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message is internal error string, never rendered as HTML
		}

		// ── Gather data ────────────────────────────────────────────────────────
		$title = get_the_title( $post_id );
		$teaser = (string) get_post_meta( $post_id, 'mbs_teaser', true );

		// Primary author.
		$author_terms = get_the_terms( $post_id, MBS_TAX_AUTHOR );
		$author_name  = '';
		$author_bio   = '';
		if ( is_array( $author_terms ) && ! empty( $author_terms ) ) {
			$primary_term = $author_terms[0];
			$author_name  = $primary_term->name;
			$author_bio   = (string) get_term_meta( $primary_term->term_id, 'mbs_author_bio', true );
		}

		// Format rows.
		$format_rows = MBS_Format_Row_DAO::get_by_post( $post_id );

		// BISAC codes (Phase 1: read from custom table).
		$bisac_codes = MBS_Classification_DAO::get_book_codes( $post_id, 'bisac' ); // Phase 1: read from custom table
		$bisac_label = ! empty( $bisac_codes ) ? (string) $bisac_codes[0] : '';

		$currency = (string) mbs_get_setting( 'mbs_setting_general_currency', 'USD' );

		// Cover image.
		$cover_url   = (string) get_the_post_thumbnail_url( $post_id, 'full' ); // R5-exempt: press kit DOCX generation context
		$cover_bytes = '';
		$cover_ext   = 'png';
		$cover_ok    = false;
		$test_mode   = (bool) mbs_get_setting( 'mbs_press_kit_test_mode', '0' );

		if ( ! empty( $cover_url ) ) {
			$cover_path = self::url_to_path( $cover_url );
			if ( $cover_path && file_exists( $cover_path ) ) {
				$cover_bytes = file_get_contents( $cover_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
				$cover_ext   = strtolower( pathinfo( $cover_path, PATHINFO_EXTENSION ) ) ?: 'jpg';
				// Ratio check.
				$dims = getimagesize( $cover_path );
				if ( $dims && ! $test_mode ) {
					$w             = (int) $dims[0];
					$h             = (int) $dims[1];
					$primary_fmt   = ! empty( $format_rows ) ? (string) $format_rows[0]->format : 'paperback';
					$square_types  = array( 'audiobook', 'audio', 'board_book' );
					$is_square     = in_array( $primary_fmt, $square_types, true );
					$target_ratio  = $is_square ? 1.0 : ( 2.0 / 3.0 );
					$actual_ratio  = $h > 0 ? ( $w / $h ) : 0;
					$diff          = abs( $actual_ratio - $target_ratio );
					if ( $diff > ( $target_ratio * self::RATIO_TOLERANCE ) ) {
						// Store admin notice transient and use placeholder.
						set_transient(
							'mbs_press_kit_ratio_error_' . $post_id,
							sprintf(
								'Press Kit cover ratio mismatch for "%s": got %.2f, expected %.2f (±10%%).',
								esc_html( $title ),
								$actual_ratio,
								$target_ratio
							),
							DAY_IN_SECONDS
						);
						$cover_bytes = '';
					} else {
						$cover_ok = true;
					}
				} else {
					$cover_ok = ( ! empty( $cover_bytes ) );
				}
			}
		}

		// ── Build DOCX XML ─────────────────────────────────────────────────────
		$doc = new self();
		$xml = $doc->build_document_xml(
			$title,
			$author_name,
			$author_bio,
			$teaser,
			$bisac_label,
			$format_rows,
			$currency,
			! empty( $cover_bytes )
		);

		// ── Create DOCX (ZIP) ──────────────────────────────────────────────────
		$tmp_dir = trailingslashit( get_temp_dir() ) . 'mbs-presskit/';
		wp_mkdir_p( $tmp_dir );

		$slug    = sanitize_file_name( $post->post_name ?: sanitize_title( $title ) );
		$tmp     = $tmp_dir . $slug . '-press-kit-' . wp_generate_password( 8, false ) . '.docx'; // V5-B-001: unique path prevents race conditions

		$zip = new \ZipArchive();
		if ( true !== $zip->open( $tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
			throw new \RuntimeException( 'ZipArchive could not create temp file: ' . $tmp ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message is internal error string, never rendered as HTML
		}

		$zip->addFromString( '[Content_Types].xml', $doc->build_content_types( $cover_ext, ! empty( $cover_bytes ) ) );
		$zip->addFromString( '_rels/.rels',          $doc->build_dot_rels() );
		$zip->addFromString( 'word/document.xml',    $xml );
		$zip->addFromString( 'word/_rels/document.xml.rels', $doc->build_doc_rels( ! empty( $cover_bytes ), $cover_ext ) );
		$zip->addFromString( 'word/styles.xml',      $doc->build_styles() );
		$zip->addFromString( 'word/settings.xml',    $doc->build_settings() );
		if ( ! empty( $cover_bytes ) ) {
			$zip->addFromString( 'word/media/cover.' . $cover_ext, $cover_bytes );
		}

		$zip->close();
		return $tmp;
	}

	// ── XML builders ──────────────────────────────────────────────────────────

	/**
	 * Build word/document.xml.
	 *
	 * @param string $title        Book title.
	 * @param string $author_name  Primary author name.
	 * @param string $author_bio   Author bio text.
	 * @param string $teaser       One-sentence marketing copy.
	 * @param string $bisac_label  Primary BISAC label string.
	 * @param array  $format_rows  Format row objects from MBS_Format_Row_DAO.
	 * @param string $currency     Currency code (e.g. USD).
	 * @param bool   $has_cover    Whether a cover image is included.
	 * @return string
	 * @since 4.4.0
	 */
	public function build_document_xml(
		string $title,
		string $author_name,
		string $author_bio,
		string $teaser,
		string $bisac_label,
		array  $format_rows,
		string $currency,
		bool   $has_cover
	): string {
		$esc = array( $this, 'x' );
		$out = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n";
		$out .= '<w:document xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas"';
		$out .= ' xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex"';
		$out .= ' xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"';
		$out .= ' xmlns:o="urn:schemas-microsoft-com:office:office"';
		$out .= ' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
		$out .= ' xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"';
		$out .= ' xmlns:v="urn:schemas-microsoft-com:vml"';
		$out .= ' xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"';
		$out .= ' xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"';
		$out .= ' xmlns:w10="urn:schemas-microsoft-com:office:word"';
		$out .= ' xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
		$out .= ' xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"';
		$out .= ' xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"';
		$out .= ' xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid"';
		$out .= ' xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex"';
		$out .= ' xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"';
		$out .= ' xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"';
		$out .= ' xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"';
		$out .= ' xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"';
		$out .= ' mc:Ignorable="w14 w15 w16se w16cid wp14">';
		$out .= '<w:body>';

		// ── Page 1: Title page ───────────────────────────────────────────────
		$out .= $this->heading( $title, 'Title' );
		if ( ! empty( $author_name ) ) {
			$out .= $this->para( 'By ' . $author_name, 'Subtitle' );
		}
		if ( ! empty( $teaser ) ) {
			$out .= $this->para( $teaser, 'Quote' );
		}
		if ( $has_cover ) {
			$out .= $this->cover_image_para();
		}
		$out .= $this->page_break();

		// ── Page 2: Author bio ───────────────────────────────────────────────
		$out .= $this->heading( 'About the Author', 'Heading1' );
		if ( ! empty( $author_bio ) ) {
			foreach ( explode( "\n", $author_bio ) as $line ) {
				$line = trim( $line );
				if ( '' !== $line ) {
					$out .= $this->para( $line, 'Normal' );
				}
			}
		} else {
			$out .= $this->para( 'Author biography not provided.', 'Normal' );
		}
		$out .= $this->page_break();

		// ── Page 3: Key Facts ────────────────────────────────────────────────
		$out .= $this->heading( 'Key Facts', 'Heading1' );

		$date_fmt = get_option( 'date_format' );

		// Build facts table: Label | Value.
		$out .= '<w:tbl>';
		$out .= '<w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="9360" w:type="dxa"/></w:tblPr>';
		$out .= '<w:tblGrid><w:gridCol w:w="2340"/><w:gridCol w:w="7020"/></w:tblGrid>';

		if ( ! empty( $bisac_label ) ) {
			$out .= $this->table_row( 'Genre', $bisac_label );
		}

		foreach ( $format_rows as $fr ) {
			$fmt_label = ucwords( str_replace( '_', ' ', (string) $fr->format ) );
			$isbn      = (string) ( $fr->isbn13 ?? '' );
			$price     = isset( $fr->price ) ? $currency . ' ' . number_format( (float) $fr->price, 2 ) : '';
			$pub_date  = ! empty( $fr->pub_date ) ? date_i18n( $date_fmt, strtotime( $fr->pub_date ) ) : '';
			$pages     = (int) ( $fr->page_length ?? 0 );

			$out .= $this->table_row( 'Format', $fmt_label );
			if ( ! empty( $isbn ) )     { $out .= $this->table_row( 'ISBN', $isbn ); }
			if ( ! empty( $price ) )    { $out .= $this->table_row( 'Price', $price ); }
			if ( ! empty( $pub_date ) ) { $out .= $this->table_row( 'Publication Date', $pub_date ); }
			if ( $pages > 0 )           { $out .= $this->table_row( 'Page Count', (string) $pages ); }
		}

		$out .= '</w:tbl>';

		// ── Document end ─────────────────────────────────────────────────────
		$out .= '<w:sectPr><w:pgSz w:w="12240" w:h="15840"/>';
		$out .= '<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/>';
		$out .= '</w:sectPr>';
		$out .= '</w:body></w:document>';
		return $out;
	}

	/**
	 * Build [Content_Types].xml.
	 *
	 * @param string $cover_ext Image extension (jpg, png, etc.).
	 * @param bool   $has_cover Whether a cover image is embedded.
	 * @return string
	 * @since 4.4.0
	 */
	public function build_content_types( string $cover_ext = 'jpg', bool $has_cover = false ): string {
		$ct_map = array(
			'jpeg' => 'image/jpeg',
			'jpg'  => 'image/jpeg',
			'png'  => 'image/png',
			'gif'  => 'image/gif',
			'webp' => 'image/webp',
		);
		$img_ct = $ct_map[ strtolower( $cover_ext ) ] ?? 'image/jpeg';

		$out  = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n";
		$out .= '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">';
		$out .= '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>';
		$out .= '<Default Extension="xml" ContentType="application/xml"/>';
		if ( $has_cover ) {
			$out .= '<Default Extension="' . $this->x( $cover_ext ) . '" ContentType="' . $this->x( $img_ct ) . '"/>';
		}
		$out .= '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>';
		$out .= '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>';
		$out .= '<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>';
		$out .= '</Types>';
		return $out;
	}

	/**
	 * Build _rels/.rels.
	 *
	 * @return string
	 * @since 4.4.0
	 */
	public function build_dot_rels(): string {
		$out  = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n";
		$out .= '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
		$out .= '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>';
		$out .= '</Relationships>';
		return $out;
	}

	/**
	 * Build word/_rels/document.xml.rels.
	 *
	 * @param bool $has_cover Whether a cover image relationship is needed.
	 * @return string
	 * @since 4.4.0
	 */
	public function build_doc_rels( bool $has_cover = false, string $cover_ext = 'jpg' ): string {
		$out  = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n";
		$out .= '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
		$out .= '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>';
		$out .= '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>';
		if ( $has_cover ) {
			$out .= '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/cover.' . $cover_ext . '"/>';
		}
		$out .= '</Relationships>';
		return $out;
	}

	/**
	 * Build word/styles.xml — minimal heading and body styles.
	 *
	 * @return string
	 * @since 4.4.0
	 */
	public function build_styles(): string {
		return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n"
			. '<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
			. ' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'
			. ' w:docDefaults="">'
			. '<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/>'
			. '<w:pPr><w:spacing w:after="160"/></w:pPr>'
			. '<w:rPr><w:sz w:val="24"/></w:rPr></w:style>'
			. '<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/>'
			. '<w:pPr><w:jc w:val="center"/><w:spacing w:before="480" w:after="240"/></w:pPr>'
			. '<w:rPr><w:b/><w:sz w:val="52"/></w:rPr></w:style>'
			. '<w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/>'
			. '<w:basedOn w:val="Normal"/>'
			. '<w:pPr><w:jc w:val="center"/><w:spacing w:after="200"/></w:pPr>'
			. '<w:rPr><w:i/><w:color w:val="595959"/><w:sz w:val="28"/></w:rPr></w:style>'
			. '<w:style w:type="paragraph" w:styleId="Quote"><w:name w:val="Quote"/>'
			. '<w:basedOn w:val="Normal"/>'
			. '<w:pPr><w:jc w:val="center"/><w:ind w:left="720" w:right="720"/><w:spacing w:before="240" w:after="240"/></w:pPr>'
			. '<w:rPr><w:i/><w:color w:val="404040"/></w:rPr></w:style>'
			. '<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/>'
			. '<w:pPr><w:spacing w:before="360" w:after="200"/></w:pPr>'
			. '<w:rPr><w:b/><w:color w:val="2E4057"/><w:sz w:val="36"/></w:rPr></w:style>'
			. '<w:style w:type="table" w:styleId="TableGrid"><w:name w:val="Table Grid"/>'
			. '<w:tblPr><w:tblBorders>'
			. '<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
			. '<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
			. '<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
			. '<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
			. '<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
			. '<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>'
			. '</w:tblBorders></w:tblPr></w:style>'
			. '</w:styles>';
	}

	/**
	 * Build word/settings.xml — minimal settings.
	 *
	 * @return string
	 * @since 4.4.0
	 */
	public function build_settings(): string {
		return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n"
			. '<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
			. '<w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/></w:compat>'
			. '</w:settings>';
	}

	// ── XML helpers ────────────────────────────────────────────────────────────

	/**
	 * Build a paragraph XML element.
	 *
	 * @param string $text    Plain text content.
	 * @param string $style   Paragraph style ID.
	 * @return string
	 * @since 4.4.0
	 */
	private function para( string $text, string $style = 'Normal' ): string {
		return '<w:p><w:pPr><w:pStyle w:val="' . $this->x( $style ) . '"/></w:pPr>'
			. '<w:r><w:t xml:space="preserve">' . $this->x( $text ) . '</w:t></w:r></w:p>';
	}

	/**
	 * Build a heading paragraph XML element.
	 *
	 * @param string $text  Heading text.
	 * @param string $style Paragraph style ID (Title or Heading1).
	 * @return string
	 * @since 4.4.0
	 */
	private function heading( string $text, string $style = 'Heading1' ): string {
		return $this->para( $text, $style );
	}

	/**
	 * Build a 2-column table row (label, value).
	 *
	 * @param string $label Left cell text.
	 * @param string $value Right cell text.
	 * @return string
	 * @since 4.4.0
	 */
	private function table_row( string $label, string $value ): string {
		return '<w:tr>'
			. '<w:tc><w:tcPr><w:tcW w:w="2340" w:type="dxa"/></w:tcPr>'
			. '<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>' . $this->x( $label ) . '</w:t></w:r></w:p></w:tc>'
			. '<w:tc><w:tcPr><w:tcW w:w="7020" w:type="dxa"/></w:tcPr>'
			. '<w:p><w:r><w:t>' . $this->x( $value ) . '</w:t></w:r></w:p></w:tc>'
			. '</w:tr>';
	}

	/**
	 * Build an inline image paragraph for the cover.
	 *
	 * Uses relationship rId3. Dimensions: 3 in x 4.5 in (portrait 2:3) in EMUs.
	 *
	 * @return string
	 * @since 4.4.0
	 */
	private function cover_image_para(): string {
		// 1 inch = 914400 EMU. 3 in x 4.5 in.
		$cx = 2743200; // 3 inches
		$cy = 4114800; // 4.5 inches
		return '<w:p><w:pPr><w:jc w:val="center"/></w:pPr>'
			. '<w:r><w:drawing>'
			. '<wp:inline distT="0" distB="0" distL="0" distR="0"'
			. ' xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing">'
			. '<wp:extent cx="' . $cx . '" cy="' . $cy . '"/>'
			. '<wp:effectExtent l="0" t="0" r="0" b="0"/>'
			. '<wp:docPr id="1" name="Cover"/>'
			. '<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
			. '<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">'
			. '<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">'
			. '<pic:nvPicPr><pic:cNvPr id="0" name="Cover"/><pic:cNvPicPr/></pic:nvPicPr>'
			. '<pic:blipFill>'
			. '<a:blip r:embed="rId3" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/>'
			. '<a:stretch><a:fillRect/></a:stretch>'
			. '</pic:blipFill>'
			. '<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="' . $cx . '" cy="' . $cy . '"/></a:xfrm>'
			. '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>'
			. '</pic:pic></a:graphicData></a:graphic>'
			. '</wp:inline></w:drawing></w:r></w:p>';
	}

	/**
	 * Explicit page break paragraph.
	 *
	 * @return string
	 * @since 4.4.0
	 */
	private function page_break(): string {
		return '<w:p><w:r><w:br w:type="page"/></w:r></w:p>';
	}

	/**
	 * XML-escape a string for safe embedding in XML attributes or text nodes.
	 *
	 * @param string $str Raw string.
	 * @return string XML-safe string.
	 * @since 4.4.0
	 */
	private function x( string $str ): string {
		return htmlspecialchars( $str, ENT_QUOTES | ENT_XML1, 'UTF-8' );
	}

	/**
	 * Attempt to resolve an attachment URL to an absolute filesystem path.
	 *
	 * @param string $url Attachment URL.
	 * @return string|false Absolute path, or false if not resolvable.
	 * @since 4.4.0
	 */
	private static function url_to_path( string $url ): string|false {
		$upload_dir  = wp_upload_dir();
		$base_url    = $upload_dir['baseurl'];
		$base_path   = $upload_dir['basedir'];
		if ( str_starts_with( $url, $base_url ) ) {
			return $base_path . substr( $url, strlen( $base_url ) );
		}
		// Fallback: try ABSPATH mapping.
		$site_url = trailingslashit( site_url() );
		if ( str_starts_with( $url, $site_url ) ) {
			return ABSPATH . substr( $url, strlen( $site_url ) );
		}
		return false;
	}
}
