<?php
/**
 * File Path: includes/api/class-mbs-onix-exporter-21.php
 *
 * ONIX 2.1 Exporter -- My Book Showroom
 *
 * Concrete exporter for ONIX 2.1 short-tag XML.
 * Extends MBS_Onix_Exporter (abstract base: includes/api/class-mbs-onix-exporter.php).
 *
 * ONIX 2.1 uses a flat product structure. All fields that live inside nested
 * composites in 3.0 (<descriptivedetail>, <collateraldetail>, <relatedmaterial>,
 * <publishingdetail>) are direct children of <product> in 2.1.
 *
 * Key structural differences from 3.0:
 *   Title:        <title><titletype>01</titletype><titletext>...</titletext></title>
 *   Series:       <series><titleofseries>...</><numberwithinSeries>...</>
 *   Text content: <othertext><texttype>...</texttype><textformat>00</textformat><text>...
 *   Related:      <relatedproduct> direct on <product> (no <relatedmaterial> wrapper)
 *   Publisher:    <publisher><publishingrole>01</><publishername>...</> direct on <product>
 *   Pub date:     <publicationdate>YYYYMMDD</publicationdate> (no role wrapper)
 *
 * EDItEUR ONIX 2.1 reference: https://www.editeur.org/15/Archived-Previous-Releases/
 *
 * @package    My_Book_Showroom
 * @subpackage Includes/Api
 * @since      1.2.0
 */

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

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

	// -------------------------------------------------------------------------
	// Root element (2.1: no namespace, no release attribute)
	// -------------------------------------------------------------------------

	protected function open_message( XMLWriter $w ): void {
		$w->startElement( 'ONIXmessage' );
		// ONIX 2.1: no release attribute, no xmlns declaration.
	}

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

	// -------------------------------------------------------------------------
	// Product composite (2.1 flat structure)
	// -------------------------------------------------------------------------

	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' );

		// Record reference + notification type
		$w->writeElement( 'recordreference', 'MBS-' . absint( $post->ID ) . '-' . absint( $row->id ) );
		$w->writeElement( 'notificationtype', MBS_Onix_Field_Map::notification_type() );

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

		// ProductForm (flat -- no <descriptivedetail> wrapper in 2.1)
		$w->writeElement( 'productform', esc_html( $format_map['form'] ) );
		if ( '' !== $format_map['detail'] ) {
			$w->writeElement( 'productformdetail', esc_html( $format_map['detail'] ) );
		}
		if ( ! $known_format ) {
			$w->writeComment( ' MBS: unknown format slug "' . esc_html( (string) $row->format ) . '" -- defaulted to BB (hardback) ' );
		}

		// Title (flat 2.1 structure)
		$this->write_title( $w, $post );

		// Edition fields (flat, same tag names as 3.0)
		$this->write_edition( $w, $row );

		// Series (flat -- 2.1 uses <series> not <collection>)
		$this->write_series( $w, $post->ID );

		// Contributors (flat, same tags as 3.0)
		$this->write_contributors( $w, $post, $row, $is_audiobook );

		// Language (flat, same tags as 3.0)
		$this->write_language( $w, $post->ID );

		// Extent (flat, same tags as 3.0)
		$this->write_extent( $w, $row, $is_audiobook );

		// Subjects (flat, same tags as 3.0)
		$this->write_subjects( $w, $post->ID );

		// Audience code (flat -- no <audience> wrapper in 2.1)
		$audience_code = (string) get_post_meta( $post->ID, 'mbs_audience_code', true );
		if ( '' !== $audience_code ) {
			$w->writeElement( 'audiencecode', esc_html( $audience_code ) );
		}

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

		// Prize composites (flat -- direct children of <product> in 2.1)
		$this->write_prizes( $w, $post->ID );

		// Text content via <othertext> (flat -- replaces 3.0 <collateraldetail>/<textcontent>)
		$this->write_other_text( $w, $post );

		// Related products (flat -- no <relatedmaterial> wrapper in 2.1)
		if ( ! empty( $sibling_isbns ) ) {
			$this->write_related_products( $w, $sibling_isbns );
		}

		// Publisher block (flat -- no <publishingdetail> wrapper in 2.1)
		$this->write_publisher( $w, $post );
		$this->write_imprint( $w, $post );

		// Publishing status (flat) -- book-level lifecycle state drives ONIX code.
		$lifecycle_state = (string) MBS_Book_Meta_DAO::get( $post->ID, 'mbs_lifecycle_state', '' );
		$w->writeElement( 'publishingstatus', MBS_Onix_Field_Map::publishing_status( $post->post_status, $lifecycle_state ) );

		// Publication date (flat -- simple element, no role wrapper)
		$this->write_publication_date( $w, $row, $post->ID );

		// ProductSupply (same structure as 3.0)
		$this->write_product_supply( $w, $row );

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

	// -------------------------------------------------------------------------
	// Version-specific helpers (2.1 flat structure)
	// -------------------------------------------------------------------------

	/**
	 * Write the title block using 2.1 flat structure.
	 *
	 * 2.1: <title><titletype>01</titletype><titletext>...</titletext></title>
	 * 3.0: <titledetail><titletype>/<titleelement><titlelevelcode>/<titletext>...
	 */
	private function write_title( XMLWriter $w, WP_Post $post ): void {
		$subtitle = (string) get_post_meta( $post->ID, 'mbs_subtitle', true );

		$w->startElement( 'title' );
		$w->writeElement( 'titletype', '01' ); // Distinctive title (book)
		$w->writeElement( 'titletext', esc_html( $post->post_title ) );
		if ( '' !== $subtitle ) {
			$w->writeElement( 'subtitle', esc_html( $subtitle ) );
		}
		$w->endElement(); // title
	}

	/**
	 * Write edition fields as flat direct children of <product>.
	 * Tag names are identical to 3.0; only the parent context differs.
	 */
	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( 'editiontypecode', esc_html( $edition_type ) );
		}
		if ( $edition_number > 0 ) {
			$w->writeElement( 'editionnumber', (string) $edition_number );
		}
		if ( '' !== $edition_statement ) {
			$w->writeElement( 'editionstatement', esc_html( $edition_statement ) );
		}
	}

	/**
	 * Write series membership using 2.1 <series> element.
	 *
	 * 2.1: <series><titleofseries>...</><numberwithinSeries>...</>
	 * 3.0: <collection><collectiontype>/<titledetail>/<collectionsequence>...
	 */
	private function write_series( 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( 'series' );
		$w->writeElement( 'titleofseries', esc_html( $term->name ) );
		if ( '' !== $series_number ) {
			$w->writeElement( 'b019', esc_html( $series_number ) ); // NumberWithinSeries
		}
		$w->endElement(); // series
	}


	/**
	 * Write ONIX 2.1 Prize composites as flat children of <product>.
	 *
	 * Prize composites map mbs_awards JSON ({name, year, status}) to ONIX 2.1
	 * <prize> structure. Status values map to ONIX Code List 41 prize codes.
	 *
	 * ONIX 2.1 List 41 mapping:
	 *   winner            => 01 (Winner)
	 *   finalist          => 02 (Runner-up)
	 *   nominee           => 03 (Shortlisted)
	 *   longlist          => 04 (Longlisted)
	 *   honorable_mention => 05 (Commended)
	 *
	 * @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( 'prizename', esc_html( $prize_name ) );
			if ( '' !== $prize_year ) {
				$w->writeElement( 'prizeyear', esc_html( $prize_year ) );
			}
			if ( '' !== $prize_code ) {
				$w->writeElement( 'prizecode', $prize_code );
			}
			$w->endElement(); // prize
		}
	}

	/**
	 * Write text content using ONIX 2.1 <othertext> elements.
	 *
	 * 2.1 uses <othertext>/<texttype>/<textformat>/<text> directly on <product>
	 * in place of 3.0's <collateraldetail>/<textcontent> composite.
	 * TextFormat 00 = ASCII text; emitted as attribute on <d104> per ONIX 2.1 rev.03.
	 */
	private function write_other_text( XMLWriter $w, WP_Post $post ): void {
		// Tagline -- ONIX 2.1 OtherText TextTypeCode 02 (Short description / annotation).
		$tagline = (string) get_post_meta( $post->ID, 'mbs_tagline', true );
		if ( '' !== trim( $tagline ) ) {
			$w->startElement( 'othertext' );
			$w->writeElement( 'd102', '02' ); // OtherTextType: Short description / annotation
			$w->startElement( 'd104' ); // Text
			$w->writeAttribute( 'textformat', '00' ); // TextFormat: ASCII text (plain, no markup)
			$w->text( wp_strip_all_tags( $tagline ) );
			$w->endElement(); // d104
			$w->endElement(); // othertext
		}

		$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 ) : '',
		);

		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( 'othertext' );
			$w->writeElement( 'd102', $text_type ); // OtherTextType
			$w->startElement( 'd104' ); // Text
			$w->writeAttribute( 'textformat', '00' ); // TextFormat: ASCII text (plain, no markup)
			$w->text( wp_strip_all_tags( $value ) );
			$w->endElement(); // d104
			$w->endElement(); // othertext
		}
	}

	/**
	 * Write sibling ISBN cross-links as flat <relatedproduct> elements.
	 *
	 * 2.1 places <relatedproduct> directly on <product>. The <relatedmaterial>
	 * wrapper composite was introduced in ONIX 3.0 and does not exist in 2.1.
	 */
	private function write_related_products( XMLWriter $w, array $sibling_isbns ): void {
		foreach ( $sibling_isbns as $sibling_isbn ) {
			$w->startElement( 'relatedproduct' );
			$w->writeElement( 'productrelationcode', '06' ); // Alternative format
			$this->write_product_identifier( $w, '15', esc_html( $sibling_isbn ) );
			$w->endElement(); // relatedproduct
		}
	}

	/**
	 * Write publisher block as a flat direct child of <product>.
	 *
	 * 2.1: <publisher>/<publishingrole>/<publishername> directly on <product>.
	 * 3.0: same tags but wrapped inside <publishingdetail>.
	 */
	private function write_publisher( XMLWriter $w, WP_Post $post ): 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' );
		}

		$w->startElement( 'publisher' );
		$w->writeElement( 'publishingrole', '01' ); // Publisher
		$w->writeElement( 'publishername', esc_html( $publisher_name ) );
		$w->endElement(); // publisher
	}

	/**
	 * Write <imprint>/<imprintname> element after the publisher block.
	 *
	 * Reads the first term from the mbs_imprint taxonomy. Skipped if no term
	 * is assigned. ONIX 2.1 uses reference tag imprintname (no short-tag for imprint).
	 *
	 * @param XMLWriter $w    XMLWriter instance.
	 * @param WP_Post   $post Post object.
	 * @return void
	 */
	private function write_imprint( XMLWriter $w, WP_Post $post ): 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( 'imprintname', esc_html( (string) $terms[0] ) ); // ImprintName
		$w->endElement(); // imprint
	}

	/**
	 * Write publication date as a flat <publicationdate> element.
	 *
	 * 2.1: <publicationdate>YYYYMMDD</publicationdate> directly on <product>.
	 * 3.0: <publishingdetail>/<publishingdate>/<publishingdaterole>/<date>
	 * The role wrapper does not exist in 2.1.
	 */
	private function write_publication_date( XMLWriter $w, object $row, int $post_id ): void {
		$pub_date = '' !== (string) ( $row->pub_date ?? '' )
			? (string) $row->pub_date
			: (string) get_post_meta( $post_id, 'mbs_pub_date', true );

		if ( '' !== $pub_date ) {
			$pub_date_norm = str_replace( '-', '', sanitize_text_field( $pub_date ) );
			if ( 8 === strlen( $pub_date_norm ) ) {
				$w->writeElement( 'publicationdate', $pub_date_norm );
			}
		}
	}

	// -------------------------------------------------------------------------
	// BIC subjects -- read from stored mbs_bic_codes post meta (O-002 fix)
	// -------------------------------------------------------------------------

	/**
	 * Override: emit BIC Subject Category blocks (SubjectSchemeIdentifier 12) for
	 * ONIX 2.1 aggregators and library systems. BIC codes are read from the canonical
	 * mbs_bic_codes post meta written by the Classification metabox save handler.
	 *
	 * @param XMLWriter $w
	 * @param int       $post_id
	 * @since 1.5.0
	 */
	protected function write_bic_from_thema( XMLWriter $w, int $post_id ): void {
		$bic_codes = MBS_Classification_DAO::get_book_codes( $post_id, 'bic' ); // Phase 1: read from custom table
		if ( empty( $bic_codes ) ) {
			return;
		}
		foreach ( $bic_codes as $bic_code ) {
			$bic_code = sanitize_text_field( (string) $bic_code );
			if ( '' === $bic_code ) {
				continue;
			}
			$w->startElement( 'subject' );
			$w->writeElement( 'subjectschemeidentifier', '12' ); // BIC Subject Category (ONIX 2.1 codelist 27)
			$w->writeElement( 'subjectcode', esc_html( $bic_code ) );
			$w->endElement(); // subject
		}
	}

	// -------------------------------------------------------------------------
	// 2.1 overrides for header and supply (base class uses 3.0 element names)
	// -------------------------------------------------------------------------

	/**
	 * Override: write ONIX 2.1 message header.
	 *
	 * ONIX 2.1 header uses flat child elements inside <header>:
	 *   <fromcompany>, <fromcontact>, <fromemailadd>, <sentdate> (YYYYMMDD, 8 chars).
	 * ONIX 3.0 uses a <sender> composite with <sendername>, <contactname>,
	 * <emailaddress>, and <sentdatetime> (14 chars). Those element names are invalid in 2.1.
	 */
	protected function write_header( XMLWriter $w ): void {
		$sender_name    = (string) get_option( 'mbs_setting_onix_sender_name', '' );
		$sender_contact = (string) get_option( 'mbs_setting_onix_sender_contact', '' );
		$sender_email   = (string) get_option( 'mbs_setting_onix_sender_email', '' );

		if ( '' === $sender_name ) {
			$sender_name = get_bloginfo( 'name' );
		}

		$w->startElement( 'header' );
		$w->writeElement( 'fromcompany', esc_html( $sender_name ) );
		if ( '' !== $sender_contact ) {
			$w->writeElement( 'fromcontact', esc_html( $sender_contact ) );
		}
		if ( '' !== $sender_email ) {
			$w->writeElement( 'fromemailadd', esc_html( $sender_email ) );
		}
		$w->writeElement( 'sentdate', gmdate( 'Ymd' ) ); // YYYYMMDD (8 chars, no time component)
		$w->endElement(); // header
	}

	/**
	 * Override: write ONIX 2.1 supply/price block with PUB supply chain fields.
	 *
	 * ONIX 2.1 places <supplydetail> directly inside <product> -- there is no
	 * <productsupply> wrapper composite (that was introduced in 3.0). Within
	 * <supplydetail>, supplier name and role are flat elements (no <supplier>
	 * composite), and the price type element is <pricetypecode> not <pricetype>.
	 *
	 * PUB-gated additions: supplier_name override, on_sale_date (j163),
	 * returnscodetype/returnscode flat elements, price_type from row,
	 * and discount block (j363/j364).
	 *
	 * @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 ?? '' ) : '';

		// 2.1: <supplydetail> is a direct child of <product> -- no <productsupply> wrapper.
		$w->startElement( 'supplydetail' );

		// 2.1: supplier name/role are flat elements, not wrapped in a <supplier> composite.
		$default_name = (string) get_option( 'mbs_setting_onix_publisher_name', get_bloginfo( 'name' ) );
		$w->writeElement( 'supplierrole', '01' ); // Publisher to retailers
		$w->writeElement( 'suppliername', esc_html( '' !== $supplier_name ? $supplier_name : $default_name ) );

		// ReturnsConditions flat elements in 2.1 (PUB only).
		if ( $is_pub && '' !== $returnability ) {
			$returns_code = ( 'yes' === $returnability ) ? '02' : '03';
			$w->writeElement( 'returnscodetype', '02' );                    // ONIX returns condition code
			$w->writeElement( 'returnscode', esc_html( $returns_code ) );
		}

		$w->writeElement( 'productavailability', '20' ); // Available

		// On-sale date flat element (PUB only) — j163 = OnSaleDate in ONIX 2.1.
		if ( $is_pub && '' !== $on_sale_date ) {
			$date_norm = str_replace( '-', '', sanitize_text_field( $on_sale_date ) );
			if ( 8 === strlen( $date_norm ) ) {
				$w->writeElement( 'j163', $date_norm ); // OnSaleDate
			}
		}

		$w->startElement( 'price' );
		$w->writeElement( 'pricetypecode', esc_html( '' !== $price_type ? $price_type : '01' ) ); // 2.1: pricetypecode
		$w->writeElement( 'priceamount', esc_html( $price ) );
		$w->writeElement( 'currencycode', esc_html( strtoupper( $currency ) ) );

		// Discount coded block (PUB only).
		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
	}
}
