<?php
/**
 * File Path: includes/api/class-mbs-onix-exporter-30.php
 *
 * ONIX 3.0 Exporter -- My Book Showroom
 *
 * Concrete exporter for ONIX 3.0 short-tag XML.
 * Extends MBS_Onix_Exporter (abstract base: includes/api/class-mbs-onix-exporter.php).
 *
 * All product-level write methods produce nested ONIX 3.0 composites:
 *   <descriptivedetail>, <collateraldetail>, <relatedmaterial>, <publishingdetail>
 *
 * Sprint D: all scalar elements use true ONIX 3.0 alphanumeric short tags.
 * Shared base-class helpers are overridden here so the 2.1 subclass output
 * is completely unaffected by these changes.
 *
 * Key scalar short-tag mapping (ONIX 3.0 rev.08):
 *   a001 RecordReference   a002 NotificationType
 *   b012 ProductForm       b333 ProductFormDetail   x314 ProductComposition
 *   b202 TitleType         x409 TitleLevelCode      b203 TitleText      b029 Subtitle
 *   b056 EditionType       b057 EditionNumber       b058 EditionStatement
 *   x329 CollectionType    x479 CollectionSequenceType  x480 CollectionSequenceNumber
 *   b035 ContributorRole   b039 NamesBeforeKey      b040 KeyNames
 *   b253 LanguageRole      b252 LanguageCode
 *   b218 ExtentType        b219 ExtentValue         b220 ExtentUnit
 *   b067 SubjectSchemeIdentifier  b171 SubjectSchemeName  b069 SubjectCode
 *   b204 AudienceCodeType  b206 AudienceCodeValue
 *   x426 TextType          x427 ContentAudience     d104 Text
 *   x455 ProductRelationCode
 *   b291 PublishingRole    b081 PublisherName        b394 PublishingStatus
 *   x448 PublishingDateRole  b306 Date
 *   b221 ProductIDType     b244 IDValue
 *   x439 SupplierRole      b241 SupplierName        x431 ProductAvailability
 *   x462 PriceType         j151 PriceAmount         j152 CurrencyCode
 *
 * Spec: SPRINT-ONIX-EXPORT-IMPORT-Spec.md §7
 *
 * @package    My_Book_Showroom
 * @subpackage Includes/Api
 * @since      1.1.0
 */

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

class MBS_Onix_Exporter_30 extends MBS_Onix_Exporter { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	// -------------------------------------------------------------------------
	// Root element (3.0: release attribute + namespace)
	// -------------------------------------------------------------------------

	protected function open_message( XMLWriter $w ): void {
		$w->startElement( 'ONIXmessage' );
		$w->writeAttribute( 'release', '3.0' );
		$w->writeAttribute( 'xmlns', 'http://ns.editeur.org/onix/3.0/short' );
	}

	protected function close_message( XMLWriter $w ): void {
		$w->endElement(); // ONIXmessage
	}

	// -------------------------------------------------------------------------
	// Product composite (3.0 nested structure, true short tags)
	// -------------------------------------------------------------------------

	protected function write_product( XMLWriter $w, WP_Post $post, object $row, array $all_isbns ): void {
		$ctx           = $this->prepare_product_context( $row, $all_isbns ); // DUP-009
		$format_map    = $ctx['format_map'];
		$known_format  = $ctx['known_format'];
		$is_audiobook  = $ctx['is_audiobook'];
		$sibling_isbns = $ctx['sibling_isbns'];

		$w->startElement( 'product' );

		// a001 RecordReference, a002 NotificationType
		$w->writeElement( 'a001', 'MBS-' . absint( $post->ID ) . '-' . absint( $row->id ) );
		$w->writeElement( 'a002', MBS_Onix_Field_Map::notification_type() );

		// Product identifiers -- both GTIN-13 (03) and ISBN-13 (15) required by Bowker.
		$isbn = esc_html( $row->isbn13 );
		$this->write_product_identifier( $w, '03', $isbn ); // GTIN-13
		$this->write_product_identifier( $w, '15', $isbn ); // ISBN-13

		// DescriptiveDetail
		$w->startElement( 'descriptivedetail' );
		$w->writeElement( 'x314', '00' ); // ProductComposition: Single-item retail product
		$w->writeElement( 'b012', esc_html( $format_map['form'] ) ); // ProductForm
		if ( '' !== $format_map['detail'] ) {
			$w->writeElement( 'b333', esc_html( $format_map['detail'] ) ); // ProductFormDetail
		}
		if ( ! $known_format ) {
			$w->writeComment( ' MBS: unknown format slug "' . esc_html( (string) $row->format ) . '" -- defaulted to BB (hardback) ' );
		}

		$this->write_title( $w, $post );
		$this->write_edition( $w, $row );
		$this->write_collection( $w, $post->ID );
		$this->write_contributors( $w, $post, $row, $is_audiobook );
		$this->write_language( $w, $post->ID );
		$this->write_extent( $w, $row, $is_audiobook );
		$this->write_subjects( $w, $post->ID );

		// Audience composite (F-001: proper b204/b206 structure; not deprecated flat audiencecode).
		$audience_code = (string) get_post_meta( $post->ID, 'mbs_audience_code', true );
		if ( '' !== $audience_code ) {
			$w->startElement( 'audience' );
			$w->writeElement( 'b204', '01' ); // AudienceCodeType: ONIX audience codes (List 28)
			$w->writeElement( 'b206', esc_html( $audience_code ) ); // AudienceCodeValue
			$w->endElement(); // audience
		}

		// BACK-001: AudienceRange composites (age/grade ranges).
		$this->write_audience_range( $w, $post->ID );

			// Prize composites (inside <descriptivedetail> in ONIX 3.0)
			$this->write_prizes( $w, $post->ID );

		$w->endElement(); // descriptivedetail

		// CollateralDetail (text content)
		$this->write_collateral_detail( $w, $post );

		// RelatedMaterial (sibling formats)
		if ( ! empty( $sibling_isbns ) ) {
			$this->write_related_material( $w, $sibling_isbns );
		}

		// PublishingDetail
		$this->write_publishing_detail( $w, $post, $row );

		// ProductSupply (pricing) -- base class method, overridden in 2.1 subclass.
		$this->write_product_supply( $w, $row );

		$w->endElement(); // product
	}

	// -------------------------------------------------------------------------
	// Base-class shared helper overrides (Sprint D: true ONIX 3.0 short tags)
	//
	// The base class uses descriptive lowercase element names compatible with
	// ONIX 2.1 round-trip import. These overrides replace every scalar element
	// name in the 3.0 output with the correct alphanumeric short tag so that
	// exported files validate against the ONIX 3.0 short-tag schema.
	// -------------------------------------------------------------------------

	// --- ProductIdentifier ---

	/**
	 * Override: write ProductIdentifier composite with 3.0 short tags.
	 *
	 * b221 = ProductIDType, b244 = IDValue.
	 *
	 * @param XMLWriter $w
	 * @param string    $id_type  ONIX product ID type code (e.g. '03', '15').
	 * @param string    $value    Identifier value.
	 */
	protected function write_product_identifier( XMLWriter $w, string $id_type, string $value ): void {
		$w->startElement( 'productidentifier' );
		$w->writeElement( 'b221', $id_type );  // ProductIDType
		$w->writeElement( 'b244', $value );    // IDValue
		$w->endElement();
	}

	// --- Contributor ---

	/**
	 * Override: write a single Contributor composite with 3.0 short tags.
	 *
	 * b035 = ContributorRole, b039 = NamesBeforeKey, b040 = KeyNames.
	 *
	 * @param XMLWriter $w
	 * @param string    $role         ONIX contributor role code (e.g. 'A01').
	 * @param string    $names_before Names before key (given name / forename).
	 * @param string    $key_names    Key names (surname / family name).
	 */
	protected function write_contributor_element( XMLWriter $w, string $role, string $names_before, string $key_names ): void {
		if ( '' === $key_names ) {
			return;
		}
		$w->startElement( 'contributor' );
		$w->writeElement( 'b035', esc_html( $role ) );         // ContributorRole
		if ( '' !== $names_before ) {
			$w->writeElement( 'b039', esc_html( $names_before ) ); // NamesBeforeKey
		}
		$w->writeElement( 'b040', esc_html( $key_names ) );    // KeyNames
		if ( '' !== $this->contributor_bio ) {
			$w->writeElement( 'b044', esc_html( $this->contributor_bio ) ); // BiographicalNote
			$this->contributor_bio = '';
		}
		$w->endElement(); // contributor
	}

	// --- Language ---

	/**
	 * Override: write Language composite with 3.0 short tags.
	 *
	 * b253 = LanguageRole, b252 = LanguageCode.
	 *
	 * @param XMLWriter $w
	 * @param int       $post_id
	 */
	protected function write_language( XMLWriter $w, int $post_id ): void {
		$lang = (string) get_post_meta( $post_id, 'mbs_language', true );
		if ( '' === $lang ) {
			$lang = (string) get_option( 'mbs_setting_onix_default_language', 'eng' );
		}
		$lang = MBS_Onix_Field_Map::sanitize_language_code( $lang );

		$w->startElement( 'language' );
		$w->writeElement( 'b253', '01' );              // LanguageRole: Language of text
		$w->writeElement( 'b252', esc_html( $lang ) ); // LanguageCode
		$w->endElement();
	}

	// --- Extent ---

	/**
	 * Override: write Extent composite with 3.0 short tags.
	 *
	 * b218 = ExtentType, b219 = ExtentValue, b220 = ExtentUnit.
	 *
	 * @param XMLWriter $w
	 * @param object    $row          Format row.
	 * @param bool      $is_audiobook Whether the format is an audiobook.
	 */
	protected function write_extent( XMLWriter $w, object $row, bool $is_audiobook ): void {
		if ( $is_audiobook ) {
			$hh = (int) ( $row->duration_hh ?? 0 );
			$mm = (int) ( $row->duration_mm ?? 0 );
			if ( $hh > 0 || $mm > 0 ) {
				$duration_hhmm = sprintf( '%02d%02d', $hh, $mm );
				$w->startElement( 'extent' );
				$w->writeElement( 'b218', '09' );           // ExtentType: Duration
				$w->writeElement( 'b219', $duration_hhmm ); // ExtentValue
				$w->writeElement( 'b220', '15' );           // ExtentUnit: Hours and minutes (HHMM)
				$w->endElement();
			}
		} elseif ( ! empty( $row->page_length ) && (int) $row->page_length > 0 ) {
			$w->startElement( 'extent' );
			$w->writeElement( 'b218', '00' );                          // ExtentType: Main content page count
			$w->writeElement( 'b219', absint( $row->page_length ) );   // ExtentValue
			$w->writeElement( 'b220', '03' );                          // ExtentUnit: Pages
			$w->endElement();
		}
	}

	// --- Subject ---

	/**
	 * Override: write Subject composites with 3.0 short tags.
	 *
	 * b067 = SubjectSchemeIdentifier, b171 = SubjectSchemeName, b069 = SubjectCode.
	 * BIC (SubjectSchemeIdentifier 12) is emitted via write_subjects() directly,
	 * is a no-op in the base class and is not overridden here).
	 *
	 * @param XMLWriter $w
	 * @param int       $post_id
	 */
	protected function write_subjects( XMLWriter $w, int $post_id ): void {
		// BISAC codes (SubjectSchemeIdentifier 10).
		// O-003: first non-empty code gets <MainSubject/> flag inside its <subject>.
		$codes = MBS_Classification_DAO::get_book_codes( $post_id, 'bisac' ); // Phase 1: read from custom table
		$bisac_primary_emitted = false;
		foreach ( $codes as $code ) {
			$code = sanitize_text_field( (string) $code );
			if ( '' === $code ) {
				continue;
			}
			$w->startElement( 'subject' );
			if ( ! $bisac_primary_emitted ) {
				// O-003: mark first code as primary subject (ONIX 3.0 flag element).
				$w->startElement( 'MainSubject' );
				$w->endElement(); // MainSubject (empty flag)
				$bisac_primary_emitted = true;
			}
			$w->writeElement( 'b067', '10' );              // SubjectSchemeIdentifier: BISAC
			$w->writeElement( 'b069', esc_html( $code ) ); // SubjectCode
			$w->endElement(); // subject
		}

		// Thema subject codes (SubjectSchemeIdentifier 93).
		$thema_codes = MBS_Classification_DAO::get_book_codes( $post_id, 'thema' ); // Phase 1: read from custom table
		foreach ( $thema_codes as $tcode ) {
			$tcode = sanitize_text_field( (string) $tcode );
			if ( '' === $tcode ) {
				continue;
			}
			$w->startElement( 'subject' );
			$w->writeElement( 'b067', '93' );               // SubjectSchemeIdentifier: Thema
			$w->writeElement( 'b069', esc_html( $tcode ) ); // SubjectCode
			$w->endElement();
		}

		// Bowker proprietary codes (SubjectSchemeIdentifier 23 = Proprietary;
		// subjectschemename identifies the scheme as Bowker).
		$bowker_codes = MBS_Classification_DAO::get_book_codes( $post_id, 'bowker' ); // R2-003
		foreach ( $bowker_codes as $bcode ) {
			$bcode = sanitize_text_field( (string) $bcode );
			if ( '' === $bcode ) {
				continue;
			}
			$w->startElement( 'subject' );
			$w->writeElement( 'b067', '23' );               // SubjectSchemeIdentifier: Proprietary
			$w->writeElement( 'b171', 'Bowker' );           // SubjectSchemeName
			$w->writeElement( 'b069', esc_html( $bcode ) ); // SubjectCode
			$w->endElement();
		}

		// BIC Subject Category codes (SubjectSchemeIdentifier 12 — O-001 fix).
		// Phase 1: read from mbs_book_classification_codes (derived from mbs_thema_codes at save).
		$bic_codes = MBS_Classification_DAO::get_book_codes( $post_id, 'bic' ); // Phase 1: read from custom table
		if ( ! empty( $bic_codes ) ) {
			foreach ( $bic_codes as $bic_code ) {
				$bic_code = sanitize_text_field( (string) $bic_code );
				if ( '' === $bic_code ) {
					continue;
				}
				$w->startElement( 'subject' );
				$w->writeElement( 'b067', '12' );                  // SubjectSchemeIdentifier: BIC Subject Category (List 27)
				$w->writeElement( 'b069', esc_html( $bic_code ) ); // SubjectCode
				$w->endElement();
			}
		}
	}

	/**
	 * Override: write AudienceRange composites using ONIX 3.0 short tags.
	 *
	 * BACK-001: emits one <audiencerange> per defined range type.
	 *   b074 = AudienceRangeQualifier  (qualifier 17 = age years, 11 = US grade)
	 *   b075 = AudienceRangePrecision  (03 = From, 04 = To)
	 *   b076 = AudienceRangeValue
	 * Open-ended ranges (empty TO field) emit precision 03 only.
	 *
	 * @param XMLWriter $w
	 * @param int       $post_id
	 */
	protected function write_audience_range( XMLWriter $w, int $post_id ): void {
		$age_from   = (string) get_post_meta( $post_id, 'mbs_age_range_from',   true );
		$age_to     = (string) get_post_meta( $post_id, 'mbs_age_range_to',     true );
		$grade_from = (string) get_post_meta( $post_id, 'mbs_grade_range_from', true );
		$grade_to   = (string) get_post_meta( $post_id, 'mbs_grade_range_to',   true );

		// Age range (qualifier 17 -- interest age, years).
		if ( '' !== $age_from ) {
			$w->startElement( 'audiencerange' );
			$w->writeElement( 'b074', '17' ); // AudienceRangeQualifier: interest age, years
			$w->writeElement( 'b075', '03' ); // AudienceRangePrecision: From
			$w->writeElement( 'b076', esc_html( $age_from ) ); // AudienceRangeValue
			if ( '' !== $age_to ) {
				$w->writeElement( 'b075', '04' ); // AudienceRangePrecision: To
				$w->writeElement( 'b076', esc_html( $age_to ) ); // AudienceRangeValue
			}
			$w->endElement(); // audiencerange
		}

		// Grade range (qualifier 11 -- US school grade, List 77).
		if ( '' !== $grade_from ) {
			$w->startElement( 'audiencerange' );
			$w->writeElement( 'b074', '11' ); // AudienceRangeQualifier: US school grade
			$w->writeElement( 'b075', '03' ); // AudienceRangePrecision: From
			$w->writeElement( 'b076', esc_html( $grade_from ) ); // AudienceRangeValue
			if ( '' !== $grade_to ) {
				$w->writeElement( 'b075', '04' ); // AudienceRangePrecision: To
				$w->writeElement( 'b076', esc_html( $grade_to ) ); // AudienceRangeValue
			}
			$w->endElement(); // audiencerange
		}
	}

	// -------------------------------------------------------------------------
	// Version-specific helper writers (3.0 nested composites, true short tags)
	// -------------------------------------------------------------------------

	// --- TitleDetail ---

	/**
	 * Write the TitleDetail composite with 3.0 short tags.
	 *
	 * b202 = TitleType, x409 = TitleLevelCode, b203 = TitleText, b029 = Subtitle.
	 *
	 * @param XMLWriter $w
	 * @param WP_Post   $post
	 */
	private function write_title( XMLWriter $w, WP_Post $post ): void {
		$subtitle = (string) get_post_meta( $post->ID, 'mbs_subtitle', true );

		$w->startElement( 'titledetail' );
		$w->writeElement( 'b202', '01' ); // TitleType: Distinctive title
		$w->startElement( 'titleelement' );
		$w->writeElement( 'x409', '01' ); // TitleLevelCode: Product level
		$w->writeElement( 'b203', esc_html( $post->post_title ) ); // TitleText
		if ( '' !== $subtitle ) {
			$w->writeElement( 'b029', esc_html( $subtitle ) ); // Subtitle
		}
		$w->endElement(); // titleelement
		$w->endElement(); // titledetail
	}

	// --- Edition ---

	/**
	 * Write edition scalar elements with 3.0 short tags.
	 *
	 * b056 = EditionType (renamed from EditionTypeCode in 2.1, short tag unchanged),
	 * b057 = EditionNumber, b058 = EditionStatement.
	 *
	 * @param XMLWriter $w
	 * @param object    $row Format row.
	 */
	private function write_edition( XMLWriter $w, object $row ): void {
		$edition_type      = (string) ( $row->edition_type   ?? '' );
		$edition_number    = (int)    ( $row->edition_number ?? 0 );
		$edition_statement = (string) ( $row->edition        ?? '' );

		if ( '' !== $edition_type && MBS_ONIX_Lists::is_valid_edition_type( $edition_type ) ) {
			$w->writeElement( 'b056', esc_html( $edition_type ) ); // EditionType
		}
		if ( $edition_number > 0 ) {
			$w->writeElement( 'b057', (string) $edition_number ); // EditionNumber
		}
		if ( '' !== $edition_statement ) {
			$w->writeElement( 'b058', esc_html( $edition_statement ) ); // EditionStatement
		}
	}

	// --- Collection ---

	/**
	 * Write Collection composite with 3.0 short tags.
	 *
	 * x329 = CollectionType, x479 = CollectionSequenceType,
	 * x480 = CollectionSequenceNumber.
	 *
	 * @param XMLWriter $w
	 * @param int       $post_id
	 */
	private function write_collection( XMLWriter $w, int $post_id ): void {
		$terms = get_the_terms( $post_id, MBS_TAX_SERIES );
		if ( empty( $terms ) || is_wp_error( $terms ) ) {
			return;
		}
		$term          = reset( $terms ); // Books belong to at most one series.
		$series_number = MBS_Formatting::format_series_number( (string) get_post_meta( $post_id, 'mbs_series_position', true ) );

		$w->startElement( 'collection' );
		$w->writeElement( 'x329', '10' ); // CollectionType: Publisher's own series
		$w->startElement( 'titledetail' );
		$w->writeElement( 'b202', '01' ); // TitleType: Distinctive title
		$w->startElement( 'titleelement' );
		$w->writeElement( 'x409', '02' ); // TitleLevelCode: Collection level
		$w->writeElement( 'b203', esc_html( $term->name ) ); // TitleText
		$w->endElement(); // titleelement
		$w->endElement(); // titledetail
		if ( '' !== $series_number ) {
			$w->startElement( 'collectionsequence' );
			$w->writeElement( 'x479', '03' ); // CollectionSequenceType: Title order in collection
			$w->writeElement( 'x480', esc_html( $series_number ) ); // CollectionSequenceNumber
			$w->endElement(); // collectionsequence
		}
		$w->endElement(); // collection
	}

	// --- CollateralDetail ---

	/**
	 * Write CollateralDetail composite with 3.0 short tags.
	 *
	 * x426 = TextType, x427 = ContentAudience ('00' = Unrestricted per List 154),
	 * d104 = Text.
	 *
	 * @param XMLWriter $w
	 * @param WP_Post   $post
	 */
	private function write_collateral_detail( XMLWriter $w, WP_Post $post ): void {
		$tagline = (string) get_post_meta( $post->ID, 'mbs_tagline', true );
		$fields  = array(
			'post_content' => $post->post_content,
			'mbs_teaser'   => (string) get_post_meta( $post->ID, 'mbs_teaser', true ),
			'mbs_toc'      => (string) get_post_meta( $post->ID, 'mbs_toc', true ),
			// Back cover copy -- PRO only; empty string is skipped by the loop below.
			'mbs_synopsis' => mbs_is_pro() ? (string) get_post_meta( $post->ID, 'mbs_synopsis', true ) : '',
		);

		$has_text = '' !== trim( $tagline );
		if ( ! $has_text ) {
			foreach ( $fields as $val ) {
				if ( '' !== trim( $val ) ) {
					$has_text = true;
					break;
				}
			}
		}
		if ( ! $has_text ) {
			return;
		}

		$w->startElement( 'collateraldetail' );

		// Tagline -- ONIX 3.0 TextType 02 (Short description / annotation).
		// Emitted only when mbs_tagline is non-empty.
		if ( '' !== trim( $tagline ) ) {
			$w->startElement( 'textcontent' );
			$w->writeElement( 'x426', '02' ); // TextType: Short description / annotation
			$w->writeElement( 'x427', '00' ); // ContentAudience: Unrestricted (List 154)
			$w->writeElement( 'd104', wp_strip_all_tags( $tagline ) ); // Text
			$w->endElement(); // textcontent
		}

		foreach ( $fields as $field_key => $value ) {
			if ( '' === trim( $value ) ) {
				continue;
			}
			$text_type = MBS_Onix_Field_Map::text_type_for_field( $field_key );
			if ( '' === $text_type ) {
				continue;
			}
			$w->startElement( 'textcontent' );
			$w->writeElement( 'x426', $text_type ); // TextType
			$w->writeElement( 'x427', '00' );        // ContentAudience: Unrestricted (List 154)
			$w->writeElement( 'd104', wp_strip_all_tags( $value ) ); // Text
			$w->endElement(); // textcontent
		}
		$w->endElement(); // collateraldetail
	}

	// --- Prize ---

	/**
	 * Write ONIX 3.0 Prize composites inside <descriptivedetail>.
	 *
	 * In ONIX 3.0, <prize> is a child of <descriptivedetail>. Short tags:
	 *   b274 = PrizeName, b275 = PrizeYear, b277 = PrizeCode (List 41).
	 *
	 * mbs_awards status -> ONIX List 41 prize code:
	 *   winner            => 01 (Winner)
	 *   finalist          => 02 (Runner-up)
	 *   nominee           => 03 (Shortlisted)
	 *   longlist          => 04 (Longlisted)
	 *   honorable_mention => 05 (Commended)
	 *
	 * Inherited by MBS_Onix_Exporter_31 -- no override needed.
	 *
	 * @param XMLWriter $w
	 * @param int       $post_id
	 */
	protected function write_prizes( XMLWriter $w, int $post_id ): void {
		$awards = MBS_Book_Awards_DAO::get( $post_id ); // Phase 2: read from custom table
		if ( empty( $awards ) ) {
			return;
		}

		$status_map = array(
			'winner'            => '01',
			'finalist'          => '02',
			'nominee'           => '03',
			'longlist'          => '04',
			'honorable_mention' => '05',
		);

		foreach ( $awards as $award ) {
			$prize_name = (string) ( $award['name'] ?? '' );
			if ( '' === $prize_name ) {
				continue;
			}
			$prize_year = (string) ( $award['year']   ?? '' );
			$prize_code = $status_map[ (string) ( $award['status'] ?? '' ) ] ?? '';

			$w->startElement( 'prize' );
			$w->writeElement( 'b274', esc_html( $prize_name ) ); // PrizeName
			if ( '' !== $prize_year ) {
				$w->writeElement( 'b275', esc_html( $prize_year ) ); // PrizeYear
			}
			if ( '' !== $prize_code ) {
				$w->writeElement( 'b277', $prize_code ); // PrizeCode (List 41)
			}
			$w->endElement(); // prize
		}
	}

	// --- RelatedMaterial ---

	/**
	 * Write RelatedMaterial composite with 3.0 short tags.
	 *
	 * x455 = ProductRelationCode.
	 *
	 * @param XMLWriter $w
	 * @param string[]  $sibling_isbns
	 */
	private function write_related_material( XMLWriter $w, array $sibling_isbns ): void {
		$w->startElement( 'relatedmaterial' );
		foreach ( $sibling_isbns as $sibling_isbn ) {
			$w->startElement( 'relatedproduct' );
			$w->writeElement( 'x455', '06' ); // ProductRelationCode: Alternative format
			$this->write_product_identifier( $w, '15', esc_html( $sibling_isbn ) );
			$w->endElement(); // relatedproduct
		}
		$w->endElement(); // relatedmaterial
	}

	// --- ProductSupply ---

	/**
	 * Override: ONIX 3.0 productsupply composite with PUB supply chain fields.
	 *
	 * Extends the base class version to write PUB-gated supply chain data from
	 * mbs_format_rows: supplier_name, returnability, on_sale_date, price_type,
	 * and discount_code. Non-PUB falls through to base class behavior (single
	 * price with hardcoded supplier and price type 01).
	 *
	 * Short tags: x439=SupplierRole, b241=SupplierName, j268=ReturnsCodeType,
	 * j269=ReturnsCode, x431=ProductAvailability, j166=SupplyDateRole,
	 * b306=Date, x462=PriceType, j151=PriceAmount, j152=CurrencyCode,
	 * j363=DiscountCodeType, j364=DiscountCode.
	 *
	 * @param XMLWriter $w   XMLWriter instance.
	 * @param object    $row Format row from mbs_format_rows.
	 * @return void
	 */
	protected function write_product_supply( XMLWriter $w, object $row ): void {
		$price    = (string) ( $row->price ?? '' );
		$currency = (string) ( $row->currency_code ?? '' );
		if ( '' === $price || '0' === $price || '0.00' === $price || '' === $currency ) {
			return;
		}

		$is_pub        = mbs_is_pub();
		$supplier_name = $is_pub ? (string) ( $row->supplier_name ?? '' ) : '';
		$price_type    = $is_pub ? (string) ( $row->price_type    ?? '' ) : '';
		$discount_code = $is_pub ? (string) ( $row->discount_code ?? '' ) : '';
		$on_sale_date  = $is_pub ? (string) ( $row->on_sale_date  ?? '' ) : '';
		$returnability = $is_pub ? (string) ( $row->returnability ?? '' ) : '';

		$w->startElement( 'productsupply' );
		$w->startElement( 'supplydetail' );

		$default_name = (string) get_option( 'mbs_setting_onix_publisher_name', get_bloginfo( 'name' ) );
		$w->startElement( 'supplier' );
		$w->writeElement( 'x439', '01' ); // SupplierRole: Publisher to retailers
		$w->writeElement( 'b241', esc_html( '' !== $supplier_name ? $supplier_name : $default_name ) ); // SupplierName
		$w->endElement(); // supplier

		if ( $is_pub && '' !== $returnability ) {
			$returns_code = ( 'yes' === $returnability ) ? '02' : '03';
			$w->startElement( 'returnsconditions' );
			$w->writeElement( 'j268', '02' );                      // ReturnsCodeType: ONIX returns condition code
			$w->writeElement( 'j269', esc_html( $returns_code ) ); // ReturnsCode
			$w->endElement(); // returnsconditions
		}

		$w->writeElement( 'x431', '20' ); // ProductAvailability: Available

		if ( $is_pub && '' !== $on_sale_date ) {
			$date_norm = str_replace( '-', '', sanitize_text_field( $on_sale_date ) );
			if ( 8 === strlen( $date_norm ) ) {
				$w->startElement( 'supplydate' );
				$w->writeElement( 'j166', '08' );       // SupplyDateRole: Sales embargo date
				$w->writeElement( 'b306', $date_norm ); // Date
				$w->endElement(); // supplydate
			}
		}

		$w->startElement( 'price' );
		$w->writeElement( 'x462', esc_html( '' !== $price_type ? $price_type : '01' ) ); // PriceType
		$w->writeElement( 'j151', esc_html( $price ) );                                   // PriceAmount
		$w->writeElement( 'j152', esc_html( strtoupper( $currency ) ) );                  // CurrencyCode
		if ( $is_pub && '' !== $discount_code ) {
			$w->startElement( 'discountcoded' );
			$w->writeElement( 'j363', '02' );                       // DiscountCodeType: Proprietary
			$w->writeElement( 'j364', esc_html( $discount_code ) ); // DiscountCode
			$w->endElement(); // discountcoded
		}
		$w->endElement(); // price

		$w->endElement(); // supplydetail
		$w->endElement(); // productsupply
	}

	// --- PublishingDetail ---

	/**
	 * Write PublishingDetail composite with 3.0 short tags.
	 *
	 * b291 = PublishingRole, b081 = PublisherName, b394 = PublishingStatus,
	 * x448 = PublishingDateRole, b306 = Date.
	 *
	 * @param XMLWriter $w
	 * @param WP_Post   $post
	 * @param object    $row Format row.
	 */
	private function write_publishing_detail( XMLWriter $w, WP_Post $post, object $row ): void {
		$publisher_name = (string) get_option( 'mbs_setting_onix_publisher_name', '' );
		if ( '' === $publisher_name ) {
			$publisher_name = (string) get_post_meta( $post->ID, 'mbs_publisher', true );
		}
		if ( '' === $publisher_name ) {
			$publisher_name = get_bloginfo( 'name' );
		}

		// Book-level lifecycle state drives ONIX publishing status code.
		$lifecycle_state = (string) MBS_Book_Meta_DAO::get( $post->ID, 'mbs_lifecycle_state', '' );
		$pub_status      = MBS_Onix_Field_Map::publishing_status( $post->post_status, $lifecycle_state );
		// pub_date is canonical on format row; postmeta fallback for backward compat.
		$pub_date = '' !== (string) ( $row->pub_date ?? '' )
			? (string) $row->pub_date
			: (string) get_post_meta( $post->ID, 'mbs_pub_date', true );

		$w->startElement( 'publishingdetail' );

		$w->startElement( 'publisher' );
		$w->writeElement( 'b291', '01' ); // PublishingRole: Publisher
		$w->writeElement( 'b081', esc_html( $publisher_name ) ); // PublisherName
		$w->endElement(); // publisher

		$w->writeElement( 'b394', $pub_status ); // PublishingStatus

		if ( '' !== $pub_date ) {
			$pub_date_norm = str_replace( '-', '', sanitize_text_field( $pub_date ) );
			if ( 8 === strlen( $pub_date_norm ) ) {
				$w->startElement( 'publishingdate' );
				$w->writeElement( 'x448', '01' ); // PublishingDateRole: Publication date
				$w->writeElement( 'b306', $pub_date_norm ); // Date
				$w->endElement();
			}
		}

		$this->write_imprint( $w, $post->ID );

		$w->endElement(); // publishingdetail
	}

	// --- Imprint ---

	/**
	 * Write <imprint>/<g402> composite inside publishingdetail.
	 *
	 * Reads the first term from the mbs_imprint taxonomy. Skipped if no term
	 * is assigned. ONIX 3.0 short tag g402 = ImprintName.
	 *
	 * @param XMLWriter $w       XMLWriter instance.
	 * @param int       $post_id Post ID.
	 * @return void
	 */
	private function write_imprint( XMLWriter $w, int $post_id ): void {
		$terms = wp_get_post_terms( $post_id, MBS_TAX_IMPRINT, array( 'fields' => 'names' ) );
		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return;
		}
		$w->startElement( 'imprint' );
		$w->writeElement( 'g402', esc_html( (string) $terms[0] ) ); // ImprintName
		$w->endElement(); // imprint
	}
}
