<?php
/**
 * File Path: includes/api/class-mbs-onix-exporter.php
 *
 * ONIX Exporter Abstract Base -- My Book Showroom
 *
 * Provides shared entry point, document structure, header, book iteration,
 * and helper writers used by both ONIX version subclasses.
 *
 * Concrete subclasses:
 *   MBS_Onix_Exporter_30  (includes/api/class-mbs-onix-exporter-30.php)
 *   MBS_Onix_Exporter_21  (includes/api/class-mbs-onix-exporter-21.php)
 *
 * O-003 / BACK-001 fixes applied Session 154.
 *
 * Spec: SPRINT-ONIX-EXPORT-IMPORT-Spec.md §7
 * Security: SECURITY.md Rules 3, 4, 5; mbs_is_pub() gate enforced by caller.
 *
 * @package    My_Book_Showroom
 * @subpackage Includes/Api
 * @since      1.1.0
 */

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

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

	// -------------------------------------------------------------------------
	// Entry point
	// -------------------------------------------------------------------------

	/**
	 * Generate ONIX XML and stream it to the browser as a file download.
	 *
	 * Caller must verify nonce, mbs_is_pub(), and current_user_can() before calling.
	 *
	 * @param bool $include_drafts Whether to include draft posts.
	 * @return void
	 */
	public function export_to_browser( bool $include_drafts = false ): void {
		$filename   = 'mbs-onix-export-' . gmdate( 'Ymd-His' ) . '.xml';
		$post_stati = array( 'publish' );
		if ( $include_drafts ) {
			$post_stati[] = 'draft';
		}

		// Drain all WordPress output buffers so their content does not prepend to
		// the XML and so no ob_start() callback (e.g. gzip) processes the XML body.
		while ( ob_get_level() > 0 ) {
			ob_end_clean();
		}

		if ( ! headers_sent() ) {
			header( 'Content-Type: application/xml; charset=utf-8' );
			header( 'Content-Disposition: attachment; filename="' . esc_attr( $filename ) . '"' );
			header( 'Cache-Control: no-store, no-cache' );
		}

		$writer = new XMLWriter();
		$writer->openMemory();
		$writer->setIndent( true );
		$writer->setIndentString( '  ' );

		$this->write_document( $writer, $post_stati );

		echo $writer->outputMemory(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- structured XML from XMLWriter; not user-supplied content
	}

	// -------------------------------------------------------------------------
	// Document structure
	// -------------------------------------------------------------------------

	/**
	 * Write the full ONIX document.
	 *
	 * Calls abstract open_message() / close_message() so subclasses control
	 * the root element and its attributes without duplicating the book loop.
	 *
	 * @param XMLWriter $w
	 * @param string[]  $post_stati
	 */
	private function write_document( XMLWriter $w, array $post_stati ): void {
		$w->startDocument( '1.0', 'UTF-8' );

		$this->open_message( $w );
		$this->write_header( $w );

		$query = new WP_Query( array(
			'post_type'      => MBS_POST_TYPE,
			'post_status'    => $post_stati,
			'posts_per_page' => -1,
			'no_found_rows'  => true,
		) );

		if ( $query->have_posts() ) {
			foreach ( $query->posts as $post ) {
				$this->write_book( $w, $post );
			}
		}

		$this->close_message( $w );
		$w->endDocument();
	}

	// -------------------------------------------------------------------------
	// Abstract: root element + product composite
	// -------------------------------------------------------------------------

	/**
	 * Open the root ONIXmessage element with version-appropriate attributes.
	 *
	 * @param XMLWriter $w
	 */
	abstract protected function open_message( XMLWriter $w ): void;

	/**
	 * Close the root ONIXmessage element.
	 *
	 * @param XMLWriter $w
	 */
	abstract protected function close_message( XMLWriter $w ): void;

	/**
	 * Write one ONIX <product> composite for a single format row.
	 *
	 * @param XMLWriter $w
	 * @param WP_Post   $post
	 * @param object    $row       Format row from mbs_format_rows.
	 * @param array     $all_isbns All ISBN-13 values for this book (for cross-links).
	 */
	abstract protected function write_product( XMLWriter $w, WP_Post $post, object $row, array $all_isbns ): void;

	/**
	 * Prepare the shared product context array used at the top of write_product()
	 * in both the ONIX 3.0 and 2.1 subclasses.
	 *
	 * Returns an array with keys:
	 *   'format_map'    — slug_to_product_form() result (form + detail strings).
	 *   'known_format'  — bool; false when the format slug is unrecognized.
	 *   'is_audiobook'  — bool; true when format === 'audiobook'.
	 *   'sibling_isbns' — ISBN-13 values for same book, excluding current row.
	 *
	 * Extracts: DUP-009 (Session 136).
	 *
	 * @param object $row      Format row from mbs_format_rows.
	 * @param array  $all_isbns All ISBN-13 values for this book.
	 * @return array{format_map: array, known_format: bool, is_audiobook: bool, sibling_isbns: array}
	 */
	protected function prepare_product_context( object $row, array $all_isbns ): array {
		return array(
			'format_map'    => MBS_Onix_Field_Map::slug_to_product_form( (string) $row->format ),
			'known_format'  => MBS_Onix_Field_Map::slug_is_known( (string) $row->format ),
			'is_audiobook'  => ( 'audiobook' === $row->format ),
			'sibling_isbns' => array_filter( $all_isbns, fn( $isbn ) => $isbn !== $row->isbn13 ),
		);
	}

	// -------------------------------------------------------------------------
	// Header (identical in ONIX 2.1 and 3.0)
	// -------------------------------------------------------------------------

	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->startElement( 'sender' );
		$w->writeElement( 'x298', esc_html( $sender_name ) ); // SenderName
		if ( '' !== $sender_contact ) {
			$w->writeElement( 'x299', esc_html( $sender_contact ) ); // ContactName
		}
		if ( '' !== $sender_email ) {
			$w->writeElement( 'j272', esc_html( $sender_email ) ); // EmailAddress
		}
		$w->endElement(); // sender
		$w->writeElement( 'x307', gmdate( 'Ymd\THis\Z' ) ); // SentDateTime (ISO 8601 UTC)
		$w->endElement(); // header
	}

	// -------------------------------------------------------------------------
	// Book iteration (version-neutral)
	// -------------------------------------------------------------------------

	private function write_book( XMLWriter $w, WP_Post $post ): void {
		$format_rows = MBS_Format_Row_DAO::get_by_post( $post->ID );

		if ( empty( $format_rows ) ) {
			return;
		}

		// Collect all ISBNs for this book (for RelatedProduct cross-links).
		$all_isbns = array();
		foreach ( $format_rows as $row ) {
			if ( ! empty( $row->isbn13 ) ) {
				$all_isbns[] = $row->isbn13;
			}
		}

		foreach ( $format_rows as $row ) {
			if ( empty( $row->isbn13 ) ) {
				continue; // Skip rows without an ISBN -- cannot form a valid ONIX record.
			}
			$this->write_product( $w, $post, $row, $all_isbns );
		}
	}

	// -------------------------------------------------------------------------
	// Shared helpers (identical output in both versions)
	// -------------------------------------------------------------------------

	protected function write_product_identifier( XMLWriter $w, string $id_type, string $value ): void {
		$w->startElement( 'productidentifier' );
		$w->writeElement( 'productidtype', $id_type );
		$w->writeElement( 'idvalue', $value );
		$w->endElement();
	}

	/**
	 * Biographical note for the current contributor composite.
	 *
	 * Set in write_contributors() before the author loop from mbs_contributor_statement.
	 * Emitted once (for the first contributor only) then reset to '' in
	 * write_contributor_element() to prevent duplication across contributors.
	 *
	 * @var string
	 */
	protected string $contributor_bio = '';

	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( 'contributorrole', esc_html( $role ) );
		if ( '' !== $names_before ) {
			$w->writeElement( 'namesbeforekey', esc_html( $names_before ) );
		}
		$w->writeElement( 'keynames', esc_html( $key_names ) );
		if ( '' !== $this->contributor_bio ) {
			$w->writeElement( 'biographicalnote', esc_html( $this->contributor_bio ) ); // BiographicalNote
			$this->contributor_bio = '';
		}
		$w->endElement(); // contributor
	}

	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;
		}

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

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

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

		$w->startElement( 'price' );
		$w->writeElement( 'x462', '01' );               // PriceType: RRP excluding tax
		$w->writeElement( 'j151', esc_html( $price ) ); // PriceAmount
		$w->writeElement( 'j152', esc_html( strtoupper( $currency ) ) ); // CurrencyCode
		$w->endElement(); // price

		$w->endElement(); // supplydetail
		$w->endElement(); // productsupply
	}
	// -------------------------------------------------------------------------
	// Shared helpers lifted from subclasses (D-011, Session 117)
	// -------------------------------------------------------------------------

	/**
	 * Write all contributor elements for a book.
	 *
	 * Handles: primary authors (A01), audiobook narrators (E07, from format row),
	 * and any additional contributors from MBS_Contributor_Book_DAO.
	 * Method body is identical between ONIX 2.1 and 3.0.
	 *
	 * @param XMLWriter $w
	 * @param WP_Post   $post
	 * @param object    $row         Format row (provides narrator if audiobook).
	 * @param bool      $is_audiobook Whether the format is an audiobook.
	 */
	protected function write_contributors( XMLWriter $w, WP_Post $post, object $row, bool $is_audiobook ): void {
		// Load biographical note once — emitted inside the first A01 contributor composite.
		$this->contributor_bio = (string) get_post_meta( $post->ID, 'mbs_contributor_statement', true );

		// Authors (A01)
		$author_terms = wp_get_object_terms( $post->ID, MBS_TAX_AUTHOR, array( 'fields' => 'all' ) );
		if ( ! is_wp_error( $author_terms ) ) {
			foreach ( $author_terms as $term ) {
				$names_before = (string) get_term_meta( $term->term_id, 'mbs_name_before_key', true );
				$key_names    = (string) get_term_meta( $term->term_id, 'mbs_name_key', true );
				if ( '' === $key_names ) {
					$key_names = esc_html( $term->name );
				}
				$this->write_contributor_element( $w, 'A01', $names_before, $key_names );
			}
		}

		// Audiobook narrator (E07) -- stored on format row
		if ( $is_audiobook && ! empty( $row->narrator ) ) {
			$this->write_contributor_element( $w, 'E07', '', (string) $row->narrator );
		}

		// Non-author, non-narrator contributors via MBS_Contributor_Book_DAO
		$contributor_rows = MBS_Contributor_Book_DAO::get_by_post( $post->ID );
		foreach ( $contributor_rows as $cr ) {
			$role = (string) ( $cr->onix_role_code ?? '' );
			if ( '' === $role || 'A01' === $role || 'E07' === $role ) {
				continue;
			}
			$term_id      = (int) ( $cr->term_id ?? 0 );
			$names_before = $term_id > 0 ? (string) get_term_meta( $term_id, 'mbs_name_before_key', true ) : '';
			$key_names    = $term_id > 0 ? (string) get_term_meta( $term_id, 'mbs_name_key', true )        : '';
			if ( '' === $key_names ) {
				$key_names = (string) ( $cr->contributor_name ?? '' );
			}
			if ( '' === $key_names ) {
				continue;
			}
			$this->write_contributor_element( $w, $role, $names_before, $key_names );
		}
	}

	/**
	 * Write a language element for a book.
	 *
	 * Falls back to the global mbs_setting_onix_default_language option when no
	 * per-book language postmeta is set. Method body is identical between
	 * ONIX 2.1 and 3.0.
	 *
	 * @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( 'languagerole', '01' ); // Language of text
		$w->writeElement( 'languagecode', esc_html( $lang ) );
		$w->endElement();
	}

	/**
	 * Write an extent element for a book.
	 *
	 * For audiobooks: duration in HHMM format (ExtentType 09, ExtentUnit 15).
	 * For all other formats: page length in pages (ExtentType 00, ExtentUnit 03).
	 * Method body is identical between ONIX 2.1 and 3.0.
	 *
	 * @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( 'extenttype', '09' );         // Duration
				$w->writeElement( 'extentvalue', $duration_hhmm );
				$w->writeElement( 'extentunit', '15' );         // Hours and minutes (HHMM)
				$w->endElement();
			}
		} elseif ( ! empty( $row->page_length ) && (int) $row->page_length > 0 ) {
			$w->startElement( 'extent' );
			$w->writeElement( 'extenttype', '00' );             // Main content page count
			$w->writeElement( 'extentvalue', absint( $row->page_length ) );
			$w->writeElement( 'extentunit', '03' );             // Pages
			$w->endElement();
		}
	}

	/**
	 * Write BISAC and Thema subject elements for a book.
	 *
	 * BISAC codes (SubjectSchemeIdentifier 10) from mbs_bisac_codes postmeta JSON.
	 * Thema codes (SubjectSchemeIdentifier 93) from mbs_thema_codes postmeta JSON.
	 *
	 * O-003: The first non-empty BISAC code (index 0 by convention -- primary) is
	 * emitted as a <mainsubject> composite in ONIX 2.1. Subsequent BISAC codes use
	 * the standard <subject> composite. Overridden in MBS_Onix_Exporter_30 which
	 * uses a <MainSubject/> empty flag element inside <subject> instead.
	 *
	 * @param XMLWriter $w
	 * @param int       $post_id
	 */
	protected function write_subjects( XMLWriter $w, int $post_id ): void {
		$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;
			}
			if ( ! $bisac_primary_emitted ) {
				// O-003: first code is primary -- use <mainsubject> composite (ONIX 2.1).
				$w->startElement( 'mainsubject' );
				$w->writeElement( 'mainsubjectschemeidentifier', '10' ); // BISAC
				$w->writeElement( 'subjectcode', esc_html( $code ) );
				$w->endElement(); // mainsubject
				$bisac_primary_emitted = true;
			} else {
				$w->startElement( 'subject' );
				$w->writeElement( 'subjectschemeidentifier', '10' ); // BISAC
				$w->writeElement( 'subjectcode', esc_html( $code ) );
				$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( 'subjectschemeidentifier', '93' ); // Thema
			$w->writeElement( 'subjectcode', esc_html( $tcode ) );
			$w->endElement();
		}

		// Bowker subject codes (SubjectSchemeIdentifier 23).
		$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( 'subjectschemeidentifier', '23' ); // Bowker
			$w->writeElement( 'subjectschemename', 'Bowker' );
			$w->writeElement( 'subjectcode', esc_html( $bcode ) );
			$w->endElement();
		}

		// BIC subject codes -- derived from stored Thema codes at export time.
		// No-op in base: overridden by MBS_Onix_Exporter_21 only (BIC needed for
		// ONIX 2.1 aggregators; not required for ONIX 3.0).
		$this->write_bic_from_thema( $w, $post_id );
	}

	/**
	 * Write AudienceRange composite(s) for age and grade ranges.
	 *
	 * BACK-001: emits one <audiencerange> per defined range type.
	 *   Qualifier 17 = interest age in years  (List 30; mbs_age_range_from/to).
	 *   Qualifier 11 = US school grade        (List 30; mbs_grade_range_from/to).
	 *   Precision 03 = From, precision 04 = To. When the TO field is empty ('')
	 *   the range is open-ended -- only a precision-03 pair is emitted.
	 *
	 * Overridden in MBS_Onix_Exporter_30 to use short tags (b074/b075/b076).
	 * This base implementation uses descriptive long-form names for ONIX 2.1.
	 *
	 * @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( 'audiencerangequalifier', '17' ); // Interest age, years
			$w->writeElement( 'audiencerangeprecision', '03' ); // From
			$w->writeElement( 'audiencerangevalue', esc_html( $age_from ) );
			if ( '' !== $age_to ) {
				$w->writeElement( 'audiencerangeprecision', '04' ); // To
				$w->writeElement( 'audiencerangevalue', esc_html( $age_to ) );
			}
			$w->endElement(); // audiencerange
		}

		// Grade range (qualifier 11 -- US school grade, List 77).
		if ( '' !== $grade_from ) {
			$w->startElement( 'audiencerange' );
			$w->writeElement( 'audiencerangequalifier', '11' ); // US school grade
			$w->writeElement( 'audiencerangeprecision', '03' ); // From
			$w->writeElement( 'audiencerangevalue', esc_html( $grade_from ) );
			if ( '' !== $grade_to ) {
				$w->writeElement( 'audiencerangeprecision', '04' ); // To
				$w->writeElement( 'audiencerangevalue', esc_html( $grade_to ) );
			}
			$w->endElement(); // audiencerange
		}
	}

	/**
	 * Write BIC subject elements derived from stored Thema codes.
	 *
	 * No-op in base class. Overridden by MBS_Onix_Exporter_21 to emit
	 * SubjectSchemeIdentifier 12 (BIC) blocks for aggregators and library
	 * systems still on ONIX 2.1.
	 *
	 * @param XMLWriter $w
	 * @param int       $post_id
	 * @since 1.5.0
	 */
	protected function write_bic_from_thema( XMLWriter $w, int $post_id ): void {
		// No-op: overridden in MBS_Onix_Exporter_21.
	}
}
