<?php
/**
 * File Path: includes/export/class-mbs-book-serializer.php
 *
 * MBS Book Serializer
 *
 * Reads one post_id from the database via DAOs and WordPress functions and
 * returns a complete MBS Book Object array. No file I/O. No sanitization.
 * The transport layer carries unsanitized values; sanitization happens in
 * MBS_Book_Hydrator before any DB write (Rule 3 / Sprint Plan §3.1).
 *
 * This class is the canonical export data layer. It supersedes the
 * data-gathering logic in MBS_Book_Export_Data::get_book_data(), which is
 * refactored in Phase 9 to delegate to this class and flatten the result to
 * the 42-column shape required for CSV/XLSX exports.
 *
 * Buy link note (IEF-A-013):
 *   Do NOT delegate buy link assembly to MBS_Book_Export_Data. That method
 *   has a known live bug where all buy links are exported jumbled together
 *   without format association. This class reads DAOs directly:
 *     book-level links  : MBS_Buy_Link_DAO::get_for_book()   (format_row_id IS NULL)
 *     per-format links  : MBS_Buy_Link_DAO::get_for_format() (keyed by format_row_id)
 *
 * Endorsement note (IEF-A-007):
 *   Use MBS_Endorsement_DAO::get_all_for_post() -- NOT get_by_post().
 *   get_by_post() filters to is_visible = 1 only; get_all_for_post() returns
 *   all non-deleted rows, which is correct for a full backup.
 *
 * @package    My_Book_Showroom
 * @since      1.0.0
 */

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

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

	/**
	 * Schema version stamped onto every MBS Book Object.
	 *
	 * Increment this constant when the canonical shape (Sprint Plan §4) changes
	 * in a backward-incompatible way so that MBS_Archive_Reader can detect
	 * and reject or migrate stale archives.
	 */
	const SCHEMA_VERSION = '2.0.0';

	/**
	 * Serialize a single book post into the canonical MBS Book Object array.
	 *
	 * All reads go through DAOs and core WordPress functions. The returned
	 * array matches the shape documented in Sprint Plan §4 exactly.
	 *
	 * @param int $post_id Book post ID.
	 * @return array MBS Book Object, or an empty array if the post does not exist
	 *               or is not an MBS book post type.
	 */
	public static function serialize( int $post_id ): array {
		$post = get_post( $post_id );
		if ( ! $post || MBS_POST_TYPE !== $post->post_type ) {
			return array();
		}

		return array(
			'schema_version'     => self::SCHEMA_VERSION,
			'post_title'         => $post->post_title,
			'post_content'       => $post->post_content,
			'post_status'        => $post->post_status,
			'post_meta'          => self::build_post_meta( $post_id ),
			'taxonomies'         => self::build_taxonomies( $post_id ),
			'collection_positions' => self::build_collection_positions( $post_id ),
			'classifications'    => self::build_classifications( $post_id ),
			'format_rows'        => self::build_format_rows( $post_id ),
			'book_buy_links'     => self::build_book_buy_links( $post_id ),
			'endorsements'       => self::build_endorsements( $post_id ),
			'awards'             => self::build_awards( $post_id ),
			'contributors'       => self::build_contributors( $post_id ),
			'related_products'   => self::build_related_products( $post_id ),
			'images'                  => self::build_images( $post_id ),
			'reading_order_positions' => self::build_reading_order_positions( $post_id ),
			'book_club'               => self::build_book_club( $post_id ),
			'arc'                     => self::build_arc( $post_id ),
			'section_order'           => self::build_section_order( $post_id ),
		);
	}

	// -------------------------------------------------------------------------
	// Private section builders
	// -------------------------------------------------------------------------

	/**
	 * Build the post_meta section.
	 *
	 * Reads every key listed in Sprint Plan §4 directly from wp_postmeta via
	 * get_post_meta(). No sanitization applied -- transport only.
	 *
	 * @param int $post_id
	 * @return array
	 */
	private static function build_post_meta( int $post_id ): array {
		$int_keys = array(
			'mbs_word_count',
			'mbs_illustration_count',
		);
		$bool_keys = array(
			'mbs_coming_soon',
			'mbs_featured',
			'mbs_show_word_count',
			'mbs_enable_reader_reviews',
		);
		$string_keys = array(
			'mbs_teaser',
			'mbs_subtitle',
			'mbs_tagline',
			'mbs_synopsis',
			'mbs_publisher',
			'mbs_publisher_url',
			'mbs_pub_date',
			'mbs_lifecycle_state',
			'mbs_series_position',
			'mbs_look_inside_text',
			'mbs_toc',
			'mbs_lccn',
			'mbs_audience_code',
			'mbs_age_range_from',
			'mbs_age_range_to',
			'mbs_edition',
			'mbs_goodreads_url',
			'mbs_storygraph_url',
			'mbs_bookbub_url',
			'mbs_publication_time',
			'mbs_grade_range_from',
			'mbs_grade_range_to',
			'mbs_copyright_year',
			'_mbs_admin_notes',
			'mbs_language',
			'mbs_share_text',
		);

		$meta = array();

		foreach ( $string_keys as $key ) {
			$meta[ $key ] = (string) get_post_meta( $post_id, $key, true );
		}
		foreach ( $int_keys as $key ) {
			$meta[ $key ] = (int) get_post_meta( $post_id, $key, true );
		}
		foreach ( $bool_keys as $key ) {
			$meta[ $key ] = (bool) get_post_meta( $post_id, $key, true );
		}

		return $meta;
	}

	/**
	 * Build the taxonomies section.
	 *
	 * Returns term names (not slugs or IDs) for each MBS taxonomy, keyed by
	 * taxonomy name. Missing or empty taxonomies produce an empty array for
	 * that key.
	 *
	 * @param int $post_id
	 * @return array<string, string[]>
	 */
	private static function build_taxonomies( int $post_id ): array {
		$tax_keys = array(
			'mbs_author',
			'mbs_genre',
			'mbs_series',
			'mbs_tag',
			'mbs_publisher',
			'mbs_imprint',
			'mbs_universe',
			'mbs_collection',
			'mbs_theme',
			'mbs_content_warning',
		);

		$taxonomies = array();
		foreach ( $tax_keys as $tax ) {
			$terms = get_the_terms( $post_id, $tax );
			if ( is_array( $terms ) ) {
				$taxonomies[ $tax ] = wp_list_pluck( $terms, 'name' );
			} else {
				$taxonomies[ $tax ] = array();
			}
		}

		return $taxonomies;
	}

	/**
	 * Build the collection_positions section.
	 *
	 * Maps collection term names to their position integers for this book.
	 * Uses MBS_Book_Collection_Position_DAO::get_positions_for_book(), which
	 * returns array<term_id, position>. Term names are resolved via get_term().
	 *
	 * @param int $post_id
	 * @return array<string, int>
	 */
	private static function build_collection_positions( int $post_id ): array {
		$positions = MBS_Book_Collection_Position_DAO::get_positions_for_book( $post_id );
		$result    = array();

		foreach ( $positions as $term_id => $position ) {
			$term = get_term( (int) $term_id, 'mbs_collection' );
			if ( $term instanceof WP_Term ) {
				$result[ $term->name ] = (int) $position;
			}
		}

		return $result;
	}

	/**
	 * Build the classifications section.
	 *
	 * Groups classification codes by scheme: bisac, thema, bic, bowker.
	 *
	 * @param int $post_id
	 * @return array{bisac: string[], thema: string[], bic: string[], bowker: string[], illustration: string[]}
	 */
	private static function build_classifications( int $post_id ): array {
		$schemes = array( 'bisac', 'thema', 'bic', 'bowker', 'illustration' );
		$result  = array();
		foreach ( $schemes as $scheme ) {
			$result[ $scheme ] = MBS_Classification_DAO::get_book_codes( $post_id, $scheme );
		}
		return $result;
	}

	/**
	 * Build the format_rows section.
	 *
	 * Each row maps directly to the mbs_format_rows DB columns. Per-format buy
	 * links are nested under each row's 'buy_links' key, read via
	 * MBS_Buy_Link_DAO::get_for_format(). Media attachment IDs are resolved to
	 * archive-relative paths where attachments exist; null otherwise (Phase 5
	 * MBS_Export_Media_Collector updates these paths after adding files to ZIP).
	 *
	 * @param int $post_id
	 * @return array[]
	 */
	private static function build_format_rows( int $post_id ): array {
		$rows   = MBS_Format_Row_DAO::get_by_post( $post_id );
		$result = array();

		foreach ( $rows as $row ) {
			$row_id = (int) $row->id;

			// Resolve cover attachment to archive-relative path (placeholder --
			// MBS_Export_Media_Collector updates this in Phase 5).
			$cover_path         = self::attachment_to_archive_path( (int) ( $row->cover_image_id ?? 0 ), 'covers' );
			$spinning_path      = self::attachment_to_archive_path( (int) ( $row->spinning_video_id ?? 0 ), 'media' );
			$audio_sample_path  = self::attachment_to_archive_path( (int) ( $row->audio_sample_id ?? 0 ), 'media' );

			$result[] = array(
				'format'         => (string) $row->format,
				'format_label'   => (string) ( $row->format_label ?? '' ),
				'isbn13'         => (string) ( $row->isbn13 ?? '' ),
				'asin'           => (string) ( $row->asin ?? '' ),
				'price'          => (string) ( $row->price ?? '' ),
				'page_length'    => (int) ( $row->page_length ?? 0 ),
				'edition_number' => (int) ( $row->edition_number ?? 0 ),
				'edition_type'   => (string) ( $row->edition_type ?? '' ),
				'pub_date'       => (string) ( $row->pub_date ?? '' ),
				'cover_image'    => $cover_path,
				'spinning_video' => $spinning_path,
				'audio_sample'   => $audio_sample_path,
				'buy_links'      => self::build_buy_links_for_format( $row_id ),
				'price_type'     => (string) ( $row->price_type ?? '' ),
				'currency_code'  => (string) ( $row->currency_code ?? '' ),
				'supplier_name'  => (string) ( $row->supplier_name ?? '' ),
				'discount_code'  => (string) ( $row->discount_code ?? '' ),
				'on_sale_date'   => (string) ( $row->on_sale_date ?? '' ),
				'returnability'  => (string) ( $row->returnability ?? '' ),
				'narrator'       => (string) ( $row->narrator ?? '' ),
				'duration_hh'    => (int) ( $row->duration_hh ?? 0 ),
				'duration_mm'    => (int) ( $row->duration_mm ?? 0 ),
				'audio_sample_url' => $row->audio_sample_id
					? (string) wp_get_attachment_url( (int) $row->audio_sample_id ) : '',
			);
		}

		return $result;
	}

	/**
	 * Build per-format buy links for one format row.
	 *
	 * @param int $format_row_id
	 * @return array[]
	 */
	private static function build_buy_links_for_format( int $format_row_id ): array {
		$links  = MBS_Buy_Link_DAO::get_for_format( $format_row_id );
		$result = array();
		foreach ( $links as $link ) {
			$result[] = array(
				'retailer_key'    => (string) $link->retailer_key,
				'url'             => (string) $link->url,
				'link_label'      => (string) ( $link->link_label ?? '' ),
				'sort_order'      => (int) ( $link->sort_order ?? 0 ),
				'is_direct_author' => (bool) ( $link->is_direct_author ?? false ),
				'locale_us'       => (string) ( $link->locale_us ?? '' ),
				'locale_uk'       => (string) ( $link->locale_uk ?? '' ),
				'locale_ca'       => (string) ( $link->locale_ca ?? '' ),
				'locale_au'       => (string) ( $link->locale_au ?? '' ),
				'locale_de'       => (string) ( $link->locale_de ?? '' ),
				'locale_jp'       => (string) ( $link->locale_jp ?? '' ),
				'link_type'       => (string) ( $link->link_type ?? 'buy' ),
			);
		}
		return $result;
	}

	/**
	 * Build the book_buy_links section.
	 *
	 * Reads book-level buy links only (format_row_id IS NULL) via
	 * MBS_Buy_Link_DAO::get_for_book(). Per DA-001 compliance, format_row_id
	 * is intentionally absent from each returned array (not null -- absent).
	 *
	 * @param int $post_id
	 * @return array[]
	 */
	private static function build_book_buy_links( int $post_id ): array {
		$links  = MBS_Buy_Link_DAO::get_for_book( $post_id );
		$result = array();
		foreach ( $links as $link ) {
			// DA-001: format_row_id is deliberately absent -- do not include it.
			$result[] = array(
				'retailer_key'    => (string) $link->retailer_key,
				'url'             => (string) $link->url,
				'link_label'      => (string) ( $link->link_label ?? '' ),
				'sort_order'      => (int) ( $link->sort_order ?? 0 ),
				'is_direct_author' => (bool) ( $link->is_direct_author ?? false ),
				'locale_us'       => (string) ( $link->locale_us ?? '' ),
				'locale_uk'       => (string) ( $link->locale_uk ?? '' ),
				'locale_ca'       => (string) ( $link->locale_ca ?? '' ),
				'locale_au'       => (string) ( $link->locale_au ?? '' ),
				'locale_de'       => (string) ( $link->locale_de ?? '' ),
				'locale_jp'       => (string) ( $link->locale_jp ?? '' ),
				'link_type'       => (string) ( $link->link_type ?? 'buy' ),
			);
		}
		return $result;
	}

	/**
	 * Build the endorsements section.
	 *
	 * Uses get_all_for_post() to return ALL non-deleted rows (not just visible
	 * ones) -- correct for a full backup (IEF-A-007).
	 *
	 * @param int $post_id
	 * @return array[]
	 */
	private static function build_endorsements( int $post_id ): array {
		$rows   = MBS_Endorsement_DAO::get_all_for_post( $post_id );
		$result = array();
		foreach ( $rows as $row ) {
			$result[] = array(
				'quote_text'        => (string) $row->quote_text,
				'attributed_to'     => (string) ( $row->attributed_to ?? '' ),
				'attribution_title' => (string) ( $row->attribution_title ?? '' ),
				'is_visible'        => (bool) $row->is_visible,
				'sort_order'        => (int) ( $row->sort_order ?? 0 ),
			);
		}
		return $result;
	}

	/**
	 * Build the awards section.
	 *
	 * MBS_Book_Awards_DAO::get() returns arrays with keys 'name', 'year',
	 * 'status'. The 'name' key maps internally to the 'title' DB column.
	 *
	 * @param int $post_id
	 * @return array[]
	 */
	private static function build_awards( int $post_id ): array {
		$rows   = MBS_Book_Awards_DAO::get( $post_id );
		$result = array();
		foreach ( $rows as $award ) {
			$result[] = array(
				'name'       => (string) $award['name'],
				'year'       => (int) $award['year'],
				'status'     => (string) $award['status'],
				'sort_order' => (int) ( $award['sort_order'] ?? 0 ),
			);
		}
		return $result;
	}

	/**
	 * Build the contributors section.
	 *
	 * MBS_Contributor_Book_DAO::get_by_post() JOINs the terms table and
	 * returns 'contributor_name' alongside the role columns.
	 *
	 * @param int $post_id
	 * @return array[]
	 */
	private static function build_contributors( int $post_id ): array {
		$rows   = MBS_Contributor_Book_DAO::get_by_post( $post_id );
		$result = array();
		foreach ( $rows as $row ) {
			// Author profile term meta -- only present when the contributor maps to an
			// mbs_author taxonomy term. Non-author contributors get empty strings.
			$term_id       = (int) ( $row->term_id ?? 0 );
			$headshot_file = '';
			$author_bio    = '';
			$author_url    = '';
			if ( $term_id > 0 ) {
				$photo_id = (int) get_term_meta( $term_id, 'mbs_author_photo_id', true );
				if ( $photo_id > 0 ) {
					$att_url = wp_get_attachment_url( $photo_id );
					$term    = get_term( $term_id, MBS_TAX_AUTHOR );
					if ( $att_url && $term instanceof WP_Term ) {
						$ext           = pathinfo( $att_url, PATHINFO_EXTENSION );
						$headshot_file = $term->slug . '.' . $ext;
					}
				}
				$author_bio = (string) get_term_meta( $term_id, 'mbs_author_bio', true );
				$author_url = (string) get_term_meta( $term_id, 'mbs_author_website_url', true );
			}
			$result[] = array(
				'contributor_name'    => (string) ( $row->contributor_name ?? '' ),
				'onix_role_code'      => (string) ( $row->onix_role_code ?? 'A01' ),
				'role_label'          => (string) ( $row->role_label ?? '' ),
				'sort_order'          => (int) ( $row->sort_order ?? 0 ),
				'public'              => (bool) $row->public,
				'statement'           => (string) ( $row->statement ?? '' ),
				'author_headshot_file' => $headshot_file,
				'author_bio'          => $author_bio,
				'author_website_url'  => $author_url,
			);
		}
		return $result;
	}

	/**
	 * Build the related_products section.
	 *
	 * MBS_Related_Products_DAO::get_for_post() returns stdClass rows with
	 * 'related_isbn' and 'relation_code' keys (plus others we ignore here).
	 *
	 * @param int $post_id
	 * @return array[]
	 */
	private static function build_related_products( int $post_id ): array {
		$rows   = MBS_Related_Products_DAO::get_for_post( $post_id );
		$result = array();
		foreach ( $rows as $row ) {
			$result[] = array(
				'related_isbn'  => (string) $row->related_isbn,
				'relation_code' => (string) $row->relation_code,
			);
		}
		return $result;
	}

	/**
	 * Build the images section.
	 *
	 * Resolves book-level image postmeta (banner, trailer video) to
	 * archive-relative paths. cover_main is the featured image (thumbnail).
	 * Phase 5 (MBS_Export_Media_Collector) updates these paths after adding
	 * files to the ZIP archive.
	 *
	 * @param int $post_id
	 * @return array
	 */
	private static function build_images( int $post_id ): array {
		$thumbnail_id  = (int) get_post_thumbnail_id( $post_id ); // R5-exempt: IEF serialization context
		$trailer_id    = (int) get_post_meta( $post_id, 'mbs_trailer_video_id', true );
		$banner_id     = (int) get_post_meta( $post_id, 'mbs_banner_image_id', true );
		$spinning_id   = (int) get_post_meta( $post_id, 'mbs_spinning_video_id', true );

		return array(
			'cover_main'          => self::attachment_to_archive_path( $thumbnail_id, 'covers' ),
			'banner'              => self::attachment_to_archive_path( $banner_id, 'banners' ),
			'trailer_video_id'    => $trailer_id > 0 ? $trailer_id : null,
			'cover_url'           => $thumbnail_id > 0
				? (string) wp_get_attachment_url( $thumbnail_id ) : '',
			'banner_url'          => $banner_id > 0
				? (string) wp_get_attachment_url( $banner_id ) : '',
			'trailer_video_url'   => $trailer_id > 0
				? (string) wp_get_attachment_url( $trailer_id ) : '',
			'spinning_video_url'  => $spinning_id > 0
				? (string) wp_get_attachment_url( $spinning_id ) : '',
		);
	}

	/**
	 * Resolve a WP attachment ID to an archive-relative path string.
	 *
	 * Returns a path like 'images/covers/filename.jpg' when the attachment
	 * exists, or an empty string when the ID is zero or the file cannot be
	 * resolved. MBS_Export_Media_Collector (Phase 5) uses these paths to add
	 * the files to the ZIP and may update them; the value here is the
	 * best-effort pre-ZIP reference.
	 *
	 * @param int    $attachment_id WP attachment post ID. Zero means no attachment.
	 * @param string $subdir        Subdirectory within images/ (e.g. 'covers', 'media').
	 * @return string Archive-relative path or empty string.
	 */
	private static function attachment_to_archive_path( int $attachment_id, string $subdir ): string {
		if ( $attachment_id <= 0 ) {
			return '';
		}
		$file = get_attached_file( $attachment_id );
		if ( ! $file ) {
			return '';
		}
		return 'images/' . trim( $subdir, '/' ) . '/' . basename( $file );
	}
	/**
	 * Reading order positions for this book across all series and universe terms.
	 *
	 * Series: mbs_book_meta.series_reading_order_position (DECIMAL, DB 4.34).
	 *         NOTE: schema has ONE position column per book (not per book+series). The same float
	 *         is emitted for every series term the book belongs to. See R4-C-002.
	 * Universe: term_meta JSON (META_KEY = 'mbs_universe_reading_order') -- stored as ORDERED ARRAY
	 *           [42,127,33], NOT a keyed map {post_id: pos}. Position derived via array_search().
	 *           See R4-C-001.
	 *
	 * @param int $post_id
	 * @return array{ series: array<int,float>, universe: array<int,float> }
	 */
	private static function build_reading_order_positions( int $post_id ): array {
		$positions = array( 'series' => array(), 'universe' => array() );

		// Series -- read position from mbs_book_meta via DAO (one column per book, not per book+series).
		// MBS_Book_Meta_DAO::get_series_ro_position() provides a typed single-book lookup (DAT-014).
		$series_terms = get_the_terms( $post_id, MBS_TAX_SERIES );
		if ( is_array( $series_terms ) ) {
			$raw_pos = MBS_Book_Meta_DAO::get_series_ro_position( $post_id );
			$db_pos  = ( null !== $raw_pos ) ? $raw_pos : 0.0;
			foreach ( $series_terms as $term ) {
				$positions['series'][ $term->term_id ] = $db_pos;
			}
		}

		// Universe -- use DAO for DB-table-primary / term_meta-fallback pattern (DAT-029).
		// MBS_Universe_Reading_Order_DAO::get_ordered_book_ids() returns an ordered array of
		// post IDs, handling migrated installs where term_meta holds '1' (a presence flag)
		// rather than a JSON array. array_search() derives the 1-based position from the result.
		$universe_terms = get_the_terms( $post_id, MBS_TAX_UNIVERSE );
		if ( is_array( $universe_terms ) ) {
			foreach ( $universe_terms as $term ) {
				$ordered_ids = MBS_Universe_Reading_Order_DAO::get_ordered_book_ids( $term->term_id );
				$idx         = array_search( $post_id, $ordered_ids, true );
				$positions['universe'][ $term->term_id ] = ( false !== $idx ) ? (float) ( $idx + 1 ) : 0.0;
			}
		}

		return $positions;
	}

	/**
	 * Build the book_club section.
	 *
	 * Reads from the three Book Club DAO tables (header, questions, files)
	 * and returns the combined record for transport in the MBS Book Object.
	 * The ZIP exporter uses files[].id to resolve attachment filenames;
	 * the XLSX exporter uses enabled/guide/questions directly.
	 *
	 * @param int $post_id Book post ID.
	 * @return array{enabled:bool,guide:string,questions:string[],files:array}
	 */
	private static function build_book_club( int $post_id ): array {
		$header    = MBS_Book_Club_DAO::get_header( $post_id );
		$questions = MBS_Book_Club_DAO::get_questions( $post_id );
		$files     = MBS_Book_Club_DAO::get_files( $post_id );
		return array(
			'enabled'   => $header['enabled'],
			'guide'     => $header['guide'],
			'questions' => $questions,
			'files'     => $files,
		);
	}

	/**
	 * Build the arc section.
	 *
	 * Reads the campaign header from mbs_arc_campaigns and all reviewer rows
	 * from mbs_arc_recipients + mbs_arc_review_urls, and returns the combined
	 * record for transport in the MBS Book Object.
	 *
	 * @param int $post_id Book post ID.
	 * @return array{
	 *   status: string,
	 *   copy_count: int,
	 *   distribution_date: string,
	 *   close_date: string,
	 *   target_review_date: string,
	 *   reviewers: array
	 * }
	 */
	private static function build_arc( int $post_id ): array {
		$campaign      = MBS_Arc_Recipient_DAO::get_campaign( $post_id );
		$reviewers_raw = MBS_Arc_Recipient_DAO::get_by_book( $post_id );
		$url_map       = MBS_Arc_Recipient_DAO::get_urls_for_book( $post_id );

		$reviewers = array();
		foreach ( $reviewers_raw as $row ) {
			$rid         = (int) $row->id;
			// ARC reviewer contact data intentionally included in ZIP
			// export to support backup/restore of campaign data. Contact data is only accessible to
			// authorized administrators via the Import/Export tab.
			$reviewers[] = array(
				'reviewer_name'  => (string) ( $row->reviewer_name  ?? '' ),
				'contact_method' => (string) ( $row->contact_method ?? '' ),
				'contact_type'   => (string) ( $row->contact_type   ?? '' ),
				'date_sent'      => (string) ( $row->date_sent      ?? '' ),
				'review_posted'  => (int)    ( $row->review_posted  ?? 0 ),
				'review_urls'    => $url_map[ $rid ] ?? array(),
			);
		}

		return array(
			'status'             => $campaign ? (string) $campaign->status                    : 'not_started',
			'copy_count'         => $campaign ? (int)    $campaign->copy_count                : 0,
			'distribution_date'  => $campaign ? (string) ( $campaign->distribution_date  ?? '' ) : '',
			'close_date'         => $campaign ? (string) ( $campaign->close_date         ?? '' ) : '',
			'target_review_date' => $campaign ? (string) ( $campaign->target_review_date ?? '' ) : '',
			'reviewers'          => $reviewers,
		);
	}

	/**
	 * Build the section order and visibility for a book.
	 *
	 * Calls MBS_Section_Order_DAO::get_order_admin() which returns the fully
	 * resolved order (per-book rows when they exist, or global default rows
	 * when the book has no per-book customization). Exporting the resolved
	 * order ensures the import can pin the display order on the destination
	 * site, regardless of what that site's global defaults happen to be.
	 *
	 * @param int $post_id Book post ID.
	 * @return array[] Each item: ['key' => string, 'is_visible' => bool, 'sort_order' => int].
	 * @since  1.0.0
	 */
	private static function build_section_order( int $post_id ): array {
		return MBS_Section_Order_DAO::get_order_admin( $post_id );
	}

}
