<?php
/**
 * File Path: includes/import/csv/class-mbs-child-importer-csv-insert.php
 *
 * CSV Importer – Insert Child Class
 *
 * Transforms a flat CSV candidate array into an MBS Book Object and delegates
 * all database writes to MBS_Book_Hydrator::hydrate(). Phase 7 IEF refactor:
 * the former per-DAO write methods have been replaced by a single builder +
 * hydrator call so all import paths share one canonical write path.
 *
 * Remaining CSV-specific concerns (not in hydrator scope):
 *   - Cover sideloading from ZIP bundles (local file, immediate) and from
 *     remote URLs (queued in mbs_import_cover_url for the deferred pass).
 *   - _extra_formats distribution into format_rows[].
 *   - update_meta_for_existing() for the duplicate-update path.
 *
 * F-2 compliant: supports post_content and mbs_import_cover_url.
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */

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

/**
 * Child class responsible for inserting a single book candidate.
 *
 * All insertion logic delegates to MBS_Book_Hydrator::hydrate() so the
 * write path is shared with archive import. Cover sideloading remains
 * here because it is CSV/ZIP bundle specific (not in hydrator scope).
 */
class MBS_Child_Importer_CSV_Insert { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	// -------------------------------------------------------------------------
	// Public API
	// -------------------------------------------------------------------------

	/**
	 * Insert a single pre-validated book candidate.
	 *
	 * Builds an MBS Book Object from the flat candidate, delegates all DB
	 * writes to MBS_Book_Hydrator::hydrate(), then handles CSV-specific cover
	 * sideloading separately.
	 *
	 * Duplicate detection is intentionally skipped here — the parent
	 * orchestrator (MBS_Parent_Importer_CSV / process_candidates()) has already
	 * run MBS_Duplicate_Detector before dispatching to this method.
	 *
	 * @param  array $candidate Sanitised candidate row from the CSV reader.
	 * @return true|string      True on success, error message string on failure.
	 */
	public static function insert_book( array $candidate ): true|string {
		$book_object = self::build_book_object_from_candidate( $candidate );

		$result = MBS_Book_Hydrator::hydrate(
			$book_object,
			array(
				// CSV importer already ran dedup; skip the hydrator's redundant check.
				'skip_duplicate_check' => true,
				'post_status'          => 'publish',
			)
		);

		if ( is_wp_error( $result ) ) {
			return sprintf(
				'Line %d: %s',
				$candidate['_line'] ?? 0,
				$result->get_error_message()
			);
		}

		$post_id = $result;

		// Cover sideloading is CSV/ZIP-bundle specific — not in hydrator scope.
		$cover_sideloaded = self::handle_cover( $post_id, $candidate );

		// Propagate a locally-sideloaded cover to every format row's cover_image_id.
		// Only runs for synchronous local-file sideloads (ZIP bundle imports).
		// Deferred remote-URL sideloads do not set cover_image_id — the Format
		// Cover field can be assigned manually after the sideload pass completes.
		if ( $cover_sideloaded ) {
			$thumb_id = (int) get_post_thumbnail_id( $post_id ); // R5-exempt: import/insert context
			if ( $thumb_id > 0 ) {
				foreach ( MBS_Format_Row_DAO::get_by_post( $post_id ) as $row ) {
					MBS_Format_Row_DAO::update( (int) $row->id, array( 'cover_image_id' => $thumb_id ) );
				}
			}
		}

		return true;
	}

	/**
	 * Update buy links, endorsements, synopsis, and TOC on an already-existing post.
	 *
	 * Called by the duplicate path in process_candidates() so that re-importing a
	 * CSV with _buy_links, _endorsements, synopsis, or toc columns updates those
	 * fields on the existing post without creating a duplicate.
	 *
	 * Buy-link update strategy: soft-delete all existing shared (format_row_id IS NULL)
	 * links and orphaned format links, then reinsert from the candidate. Idempotent —
	 * running the import twice produces the same final set of links.
	 *
	 * Endorsement update strategy: pass the full candidate list to
	 * MBS_Endorsement_DAO::save_from_post(); the DAO soft-deletes removed rows
	 * and upserts the rest.
	 *
	 * @param int   $post_id   Existing book post ID.
	 * @param array $candidate Candidate array from the CSV reader.
	 */
	public static function update_meta_for_existing( int $post_id, array $candidate ): void {
		// --- Buy links (wipe + reinsert) ---
		if ( ! empty( $candidate['_buy_links'] ) ) {
			MBS_Buy_Link_DAO::soft_delete_missing_shared_for_post( $post_id, array() );
			MBS_Buy_Link_DAO::soft_delete_orphaned_for_post( $post_id, array() );

			$buy_links = json_decode( $candidate['_buy_links'], true );
			if ( is_array( $buy_links ) ) {
				// Build format→format_row_id map for routing per-format links.
				$format_row_map = array();
				foreach ( MBS_Format_Row_DAO::get_by_post( $post_id ) as $row ) {
					if ( ! empty( $row->format ) && ! isset( $format_row_map[ $row->format ] ) ) {
						$format_row_map[ $row->format ] = (int) $row->id;
					}
				}

				foreach ( $buy_links as $sort_order => $link ) {
					$url = esc_url_raw( (string) ( $link['url'] ?? '' ) );
					if ( '' === $url ) {
						continue;
					}
					$format_row_id = null;
					if ( ! empty( $link['format'] ) && isset( $format_row_map[ $link['format'] ] ) ) {
						$format_row_id = $format_row_map[ $link['format'] ];
					}
					MBS_Buy_Link_DAO::save( array(
						'post_id'          => $post_id,
						'format_row_id'    => $format_row_id,
						'retailer_key'     => $link['retailer_key'] ?? null,
						'url'              => $url,
						'is_direct_author' => (int) ( $link['is_direct_author'] ?? 0 ),
						'sort_order'       => (int) $sort_order,
					) );
				}
			}
		}

		// --- Endorsements (delete-then-insert via save_from_post) ---
		if ( ! empty( $candidate['_endorsements'] ) ) {
			$endorsements = json_decode( $candidate['_endorsements'], true );
			if ( is_array( $endorsements ) && ! empty( $endorsements ) ) {
				$post_data = array(
					'mbs_endorsement_quote'             => array(),
					'mbs_endorsement_attributed_to'     => array(),
					'mbs_endorsement_attribution_title' => array(),
					'mbs_endorsement_visible'           => array(),
				);
				foreach ( $endorsements as $i => $e ) {
					$post_data['mbs_endorsement_quote'][ $i ]             = $e['quote'] ?? '';
					$post_data['mbs_endorsement_attributed_to'][ $i ]     = $e['attributed_to'] ?? '';
					$post_data['mbs_endorsement_attribution_title'][ $i ] = $e['attribution_title'] ?? '';
					if ( ! empty( $e['is_visible'] ) ) {
						$post_data['mbs_endorsement_visible'][ $i ] = '1';
					}
				}
				MBS_Endorsement_DAO::save_from_post( $post_id, $post_data );
			}
		}

		// --- Back Cover / Flap Copy (PRO) ---
		if ( mbs_is_pro() && ! empty( $candidate['synopsis'] ) ) {
			$synopsis_text = str_replace( '\\n', "\n", $candidate['synopsis'] );
			update_post_meta( $post_id, 'mbs_synopsis', sanitize_textarea_field( $synopsis_text ) );
		}

		// --- Table of Contents ---
		if ( ! empty( $candidate['toc'] ) ) {
			$toc_text = str_replace( '\\n', "\n", $candidate['toc'] );
			update_post_meta( $post_id, 'mbs_toc', sanitize_textarea_field( $toc_text ) );
		}
	}

	// -------------------------------------------------------------------------
	// MBS Book Object builder
	// -------------------------------------------------------------------------

	/**
	 * Transform a flat CSV candidate array into a complete MBS Book Object.
	 *
	 * The returned array conforms to the §4 MBS Book Object shape consumed by
	 * MBS_Book_Hydrator::hydrate(). All domain-specific parsing and validation
	 * (BISAC lookup, ASIN normalization, price sanitization, ONIX list checks)
	 * is performed here so the hydrator receives pre-validated data.
	 *
	 * @param  array $candidate Flat CSV candidate row.
	 * @return array            MBS Book Object.
	 */
	private static function build_book_object_from_candidate( array $candidate ): array {
		$format_rows = self::build_format_rows_from_candidate( $candidate );

		return array(
			'post_title'       => $candidate['title'] ?? '',
			'post_content'     => $candidate['post_content'] ?? '',
			'post_status'      => 'publish',
			'post_meta'        => self::build_meta_from_candidate( $candidate ),
			'taxonomies'       => self::build_taxonomies_from_candidate( $candidate ),
			'classifications'  => self::build_classifications_from_candidate( $candidate ),
			'format_rows'      => $format_rows,
			'book_buy_links'   => self::build_book_buy_links_from_candidate( $candidate ),
			'endorsements'     => self::build_endorsements_from_candidate( $candidate ),
			'contributors'     => self::build_contributors_from_candidate( $candidate ),
			'related_products' => self::build_related_products_from_candidate( $candidate ),
		);
	}

	/**
	 * Build the post_meta section.
	 *
	 * Only keys with non-empty values are included; the hydrator gracefully
	 * skips absent keys. Pre-computes validation for audience, date, time,
	 * and numeric fields so the hydrator receives clean values.
	 *
	 * @param  array $candidate Candidate row.
	 * @return array            post_meta section of MBS Book Object.
	 */
	private static function build_meta_from_candidate( array $candidate ): array {
		$meta = array();

		if ( ! empty( $candidate['teaser'] ) ) {
			$meta['mbs_teaser'] = sanitize_text_field( $candidate['teaser'] );
		}
		// Back Cover / Flap Copy (PRO). CSV encodes newlines as literal \n.
		if ( mbs_is_pro() && ! empty( $candidate['synopsis'] ) ) {
			$meta['mbs_synopsis'] = sanitize_textarea_field( str_replace( '\\n', "\n", $candidate['synopsis'] ) );
		}
		if ( ! empty( $candidate['publisher'] ) ) {
			$meta['mbs_publisher'] = sanitize_text_field( $candidate['publisher'] );
		}
		if ( ! empty( $candidate['publisher_url'] ) ) {
			$meta['mbs_publisher_url'] = esc_url_raw( $candidate['publisher_url'] );
		}
		if ( ! empty( $candidate['pub_date'] ) ) {
			$date = sanitize_text_field( $candidate['pub_date'] );
			if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
				$meta['mbs_pub_date'] = $date;
			}
		}
		if ( isset( $candidate['featured'] ) && '' !== $candidate['featured'] ) {
			$meta['mbs_featured'] = ( '1' === (string) $candidate['featured'] ) ? 1 : 0;
		}
		if ( ! empty( $candidate['coming_soon'] ) && mbs_is_pro() ) {
			$meta['mbs_coming_soon'] = 1;
		}
		// ARCH-SERIES-001: series position is book-level, not format-level.
		if ( ! empty( $candidate['series_number'] ) ) {
			$series_num_raw = (string) $candidate['series_number'];
			if ( (float) $series_num_raw > 0 ) {
				$meta['mbs_series_position'] = $series_num_raw;
			}
		}
		if ( ! empty( $candidate['toc'] ) ) {
			// CSV encodes newlines as literal \n to avoid multi-line cell issues.
			$meta['mbs_toc'] = sanitize_textarea_field( str_replace( '\\n', "\n", $candidate['toc'] ) );
		}
		if ( ! empty( $candidate['lccn'] ) ) {
			$meta['mbs_lccn'] = sanitize_text_field( $candidate['lccn'] );
		}
		// Note: mbs_audience_code is the canonical DB key (mbs_audience is legacy).
		if ( ! empty( $candidate['audience'] ) ) {
			$audience = strtoupper( trim( $candidate['audience'] ) );
			if ( MBS_ONIX_Lists::is_valid_audience( $audience ) ) {
				$meta['mbs_audience_code'] = $audience;
			}
		}
		if ( isset( $candidate['age_range_from'] ) && '' !== $candidate['age_range_from'] ) {
			$meta['mbs_age_range_from'] = absint( $candidate['age_range_from'] );
		}
		if ( isset( $candidate['age_range_to'] ) && '' !== $candidate['age_range_to'] ) {
			$meta['mbs_age_range_to'] = absint( $candidate['age_range_to'] );
		}
		if ( ! empty( $candidate['grade_range_from'] ) ) {
			$meta['mbs_grade_range_from'] = sanitize_text_field( $candidate['grade_range_from'] );
		}
		if ( ! empty( $candidate['grade_range_to'] ) ) {
			$meta['mbs_grade_range_to'] = sanitize_text_field( $candidate['grade_range_to'] );
		}
		if ( isset( $candidate['illustration_count'] ) && '' !== $candidate['illustration_count'] ) {
			$meta['mbs_illustration_count'] = absint( $candidate['illustration_count'] );
		}
		if ( ! empty( $candidate['edition_statement'] ) ) {
			$meta['mbs_edition'] = sanitize_text_field( $candidate['edition_statement'] );
		}
		if ( ! empty( $candidate['publication_time'] ) ) {
			$pub_time = sanitize_text_field( trim( $candidate['publication_time'] ) );
			if ( preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $pub_time ) ) {
				$meta['mbs_publication_time'] = $pub_time;
			}
		}
		if ( ! empty( $candidate['look_inside_text'] ) ) {
			$meta['mbs_look_inside_text'] = MBS_Look_Inside_Sanitizer::clean(
				$candidate['look_inside_text'],
				false,
				MBS_LOOK_INSIDE_WORD_LIMIT
			);
		}

		return $meta;
	}

	/**
	 * Build the taxonomies section.
	 *
	 * @param  array $candidate Candidate row.
	 * @return array            taxonomies section (taxonomy_slug => string[]).
	 */
	private static function build_taxonomies_from_candidate( array $candidate ): array {
		$taxonomies = array();

		if ( ! empty( $candidate['author'] ) ) {
			$taxonomies[ MBS_TAX_AUTHOR ] = array( sanitize_text_field( $candidate['author'] ) );
		}
		if ( ! empty( $candidate['series'] ) ) {
			$taxonomies[ MBS_TAX_SERIES ] = array( sanitize_text_field( $candidate['series'] ) );
		}
		// publisher goes into both post_meta and taxonomy (F-020 Phase 4).
		if ( ! empty( $candidate['publisher'] ) ) {
			$taxonomies[ MBS_TAX_PUBLISHER ] = array( sanitize_text_field( $candidate['publisher'] ) );
		}
		if ( ! empty( $candidate['imprint'] ) ) {
			$taxonomies[ MBS_TAX_IMPRINT ] = array( sanitize_text_field( $candidate['imprint'] ) );
		}
		// Genre — pipe-delimited so commas inside genre names are preserved.
		if ( ! empty( $candidate['genre'] ) ) {
			$raw       = sanitize_text_field( $candidate['genre'] );
			$delimiter = str_contains( $raw, '|' ) ? '|' : '';
			$names     = $delimiter
				? array_filter( array_map( 'trim', explode( '|', $raw ) ) )
				: array( trim( $raw ) );
			if ( ! empty( $names ) ) {
				$taxonomies[ MBS_TAX_GENRE ] = array_values( $names );
			}
		}
		// Tags — pipe-delimited; falls back to comma for legacy data.
		if ( ! empty( $candidate['tags'] ) ) {
			$raw       = sanitize_text_field( $candidate['tags'] );
			$delimiter = str_contains( $raw, '|' ) ? '|' : ',';
			$names     = array_filter( array_map( 'trim', explode( $delimiter, $raw ) ) );
			if ( ! empty( $names ) ) {
				$taxonomies[ MBS_TAX_TAG ] = array_values( $names );
			}
		}

		return $taxonomies;
	}

	/**
	 * Build the classifications section.
	 *
	 * BISAC codes are validated against the classification DB before inclusion.
	 * Thema, BIC, Bowker, and illustration codes are included as-is (no DB lookup).
	 *
	 * @param  array $candidate Candidate row.
	 * @return array            classifications section (scheme => string[]).
	 */
	private static function build_classifications_from_candidate( array $candidate ): array {
		$classifications = array();

		if ( ! empty( $candidate['bisac_codes'] ) ) {
			$raw_codes = array_filter( array_map( 'trim', explode( '|', $candidate['bisac_codes'] ) ) );
			if ( ! empty( $raw_codes ) ) {
				$class_dao  = new MBS_Classification_DAO();
				$desc_map   = $class_dao->get_descriptions_batch( 'bisac', array_values( $raw_codes ) );
				$valid_codes = array();
				foreach ( $raw_codes as $code ) {
					if ( isset( $desc_map[ $code ] ) ) {
						$valid_codes[] = $code;
					}
				}
				if ( ! empty( $valid_codes ) ) {
					$classifications['bisac'] = $valid_codes;
				}
			}
		}
		if ( ! empty( $candidate['thema_codes'] ) ) {
			$codes = array_values( array_filter( array_map( 'trim', explode( '|', $candidate['thema_codes'] ) ) ) );
			if ( ! empty( $codes ) ) {
				$classifications['thema'] = $codes;
			}
		}
		if ( ! empty( $candidate['bic_codes'] ) ) {
			$codes = array_values( array_filter( array_map( 'trim', explode( '|', $candidate['bic_codes'] ) ) ) );
			if ( ! empty( $codes ) ) {
				$classifications['bic'] = $codes;
			}
		}
		if ( ! empty( $candidate['bowker_codes'] ) ) {
			$codes = array_values( array_filter( array_map( 'trim', explode( '|', $candidate['bowker_codes'] ) ) ) );
			if ( ! empty( $codes ) ) {
				$classifications['bowker'] = $codes;
			}
		}
		// Illustration types (ONIX List 25 codes).
		if ( ! empty( $candidate['illustration_types'] ) ) {
			$raw_ill    = array_filter( array_map( 'trim', explode( '|', $candidate['illustration_types'] ) ) );
			$valid_ill  = array();
			foreach ( $raw_ill as $code ) {
				$code = sanitize_key( $code );
				if ( MBS_ONIX_Lists::is_valid_illustration_type( $code ) ) {
					$valid_ill[] = $code;
				}
			}
			if ( ! empty( $valid_ill ) ) {
				$classifications['illustration'] = $valid_ill;
			}
		}

		return $classifications;
	}

	/**
	 * Build format_rows[], including _extra_formats and nested per-format buy links.
	 *
	 * Format-level buy links (entries in _buy_links JSON that carry a "format" key)
	 * are distributed into the matching format row's buy_links[] sub-array here.
	 * Book-level buy links (no "format" key) are handled by
	 * build_book_buy_links_from_candidate().
	 *
	 * IMPORTANT: call build_book_buy_links_from_candidate() after this method to
	 * collect the remaining book-level links — the two methods share the same
	 * _buy_links JSON but produce non-overlapping subsets.
	 *
	 * @param  array $candidate Candidate row.
	 * @return array            format_rows[] section of MBS Book Object.
	 */
	private static function build_format_rows_from_candidate( array $candidate ): array {
		$format_rows = array();

		// Primary format row from the candidate's own format fields.
		$format_rows[] = self::build_single_format_row( $candidate );

		// Extra format rows from MBS_Import_Book_Grouper::merge_to_primary().
		// Each entry carries only FORMAT_FIELDS; book-level meta already written.
		if ( ! empty( $candidate['_extra_formats'] ) ) {
			foreach ( $candidate['_extra_formats'] as $extra_format ) {
				$format_rows[] = self::build_single_format_row( $extra_format );
			}
		}

		// Distribute format-keyed buy links into their matching format row.
		if ( ! empty( $candidate['_buy_links'] ) ) {
			$buy_links = json_decode( $candidate['_buy_links'], true );
			if ( is_array( $buy_links ) ) {
				// Map format slug → format_rows[] index (first occurrence wins).
				$format_to_idx = array();
				foreach ( $format_rows as $idx => $row ) {
					if ( ! empty( $row['format'] ) && ! isset( $format_to_idx[ $row['format'] ] ) ) {
						$format_to_idx[ $row['format'] ] = $idx;
					}
				}
				foreach ( $buy_links as $sort_order => $link ) {
					if ( empty( $link['format'] ) ) {
						continue; // Book-level — handled by build_book_buy_links_from_candidate().
					}
					$url = esc_url_raw( (string) ( $link['url'] ?? '' ) );
					if ( '' === $url ) {
						continue;
					}
					if ( ! isset( $format_to_idx[ $link['format'] ] ) ) {
						continue; // Unknown format slug — skip silently.
					}
					$idx = $format_to_idx[ $link['format'] ];
					$format_rows[ $idx ]['buy_links'][] = array(
						'url'          => $url,
						'retailer_key' => $link['retailer_key'] ?? '',
						'link_label'   => $link['link_label'] ?? '',
						'sort_order'   => (int) $sort_order,
					);
				}
			}
		}

		return $format_rows;
	}

	/**
	 * Build a single format row array from a candidate or extra-format sub-array.
	 *
	 * Validates ASIN, price, edition type, and page_length before including them.
	 * Series position is intentionally absent — it is a book-level key written via
	 * build_meta_from_candidate() (ARCH-SERIES-001).
	 *
	 * @param  array $candidate Candidate or extra-format sub-array.
	 * @return array            Single format row (no buy_links key; added by caller).
	 */
	private static function build_single_format_row( array $candidate ): array {
		$row = array(
			'format'     => ! empty( $candidate['format'] )
				? sanitize_key( $candidate['format'] )
				: 'paperback',
			'buy_links'  => array(), // Populated by build_format_rows_from_candidate().
			'sort_order' => 0,
		);

		// E-007: include format_label from the candidate.
		if ( ! empty( $candidate['format_label'] ) ) {
			$row['format_label'] = sanitize_text_field( $candidate['format_label'] );
		}
		if ( ! empty( $candidate['isbn13'] ) ) {
			$row['isbn13'] = $candidate['isbn13'];
		}
		if ( ! empty( $candidate['asin'] ) ) {
			// FV-012: normalize ASIN — uppercase, strip specials, validate 10 chars.
			$normalized_asin = MBS_ISBN_Helper::normalize_asin( $candidate['asin'] );
			if ( false !== $normalized_asin ) {
				$row['asin'] = $normalized_asin;
			}
		}
		if ( isset( $candidate['price'] ) && '' !== $candidate['price'] ) {
			// FV-015: pass the active currency for zero-decimal currency validation.
			$_import_currency = mbs_get_setting( 'general', 'currency', 'USD' );
			$price            = MBS_Formatting::sanitize_price( $candidate['price'], $_import_currency );
			if ( null !== $price ) {
				$row['price'] = $price;
			}
		}
		if ( ! empty( $candidate['page_length'] ) ) {
			$pages = absint( $candidate['page_length'] );
			if ( $pages > 0 && $pages <= 9999 ) {
				$row['page_length'] = $pages;
			}
		}
		if ( isset( $candidate['edition_number'] ) && '' !== $candidate['edition_number'] ) {
			$ed_num = absint( $candidate['edition_number'] );
			if ( $ed_num > 0 ) {
				$row['edition_number'] = $ed_num;
			}
		}
		if ( ! empty( $candidate['edition_type'] ) ) {
			$ed_type = sanitize_key( strtoupper( trim( $candidate['edition_type'] ) ) );
			if ( MBS_ONIX_Lists::is_valid_edition_type( $ed_type ) ) {
				$row['edition_type'] = $ed_type;
			}
		}

		return $row;
	}

	/**
	 * Build book_buy_links[] from the _buy_links JSON passthrough field.
	 *
	 * Only links WITHOUT a "format" key are returned here; they are saved as
	 * global buy links (no format_row_id). Per DA-001 the format_row_id key
	 * must be completely absent from the hydrator's write_book_buy_links() data
	 * array — this builder does not include it.
	 *
	 * Links WITH a "format" key are handled inside build_format_rows_from_candidate().
	 *
	 * IMPORTANT: call after build_format_rows_from_candidate() for consistency,
	 * though the two methods do not depend on each other.
	 *
	 * @param  array $candidate Candidate row.
	 * @return array            book_buy_links[] section (DA-001 compliant).
	 */
	private static function build_book_buy_links_from_candidate( array $candidate ): array {
		if ( empty( $candidate['_buy_links'] ) ) {
			return array();
		}
		$buy_links = json_decode( $candidate['_buy_links'], true );
		if ( ! is_array( $buy_links ) ) {
			return array();
		}

		$book_buy_links = array();
		foreach ( $buy_links as $sort_order => $link ) {
			if ( ! empty( $link['format'] ) ) {
				continue; // Format-level — handled in build_format_rows_from_candidate().
			}
			$url = esc_url_raw( (string) ( $link['url'] ?? '' ) );
			if ( '' === $url ) {
				continue;
			}
			$book_buy_links[] = array(
				'url'              => $url,
				'retailer_key'     => $link['retailer_key'] ?? '',
				'link_label'       => $link['link_label'] ?? '',
				'is_direct_author' => (int) ( $link['is_direct_author'] ?? 0 ),
				'sort_order'       => (int) $sort_order,
			);
		}

		return $book_buy_links;
	}

	/**
	 * Build endorsements[] from the _endorsements JSON passthrough field.
	 *
	 * Maps legacy "quote" key to "quote_text" for hydrator compatibility.
	 *
	 * @param  array $candidate Candidate row.
	 * @return array            endorsements[] section.
	 */
	private static function build_endorsements_from_candidate( array $candidate ): array {
		if ( empty( $candidate['_endorsements'] ) ) {
			return array();
		}
		$raw = json_decode( $candidate['_endorsements'], true );
		if ( ! is_array( $raw ) ) {
			return array();
		}

		$endorsements = array();
		foreach ( $raw as $i => $e ) {
			// JSON key is "quote" (legacy); hydrator expects "quote_text".
			$quote = $e['quote'] ?? $e['quote_text'] ?? '';
			if ( '' === $quote ) {
				continue;
			}
			$endorsements[] = array(
				'quote_text'        => $quote,
				'attributed_to'     => $e['attributed_to'] ?? '',
				'attribution_title' => $e['attribution_title'] ?? '',
				'is_visible'        => ! empty( $e['is_visible'] ) ? 1 : 0,
				'sort_order'        => $i,
			);
		}

		return $endorsements;
	}

	/**
	 * Build contributors[] from the "contributor" pipe-delimited field.
	 *
	 * CSV format: "Name:ROLE_CODE|Name2:ROLE_CODE2"
	 * Role code defaults to A01 (Author) if omitted or invalid.
	 *
	 * Note: the hydrator's write_contributors() resolves names to term IDs
	 * via term_exists() / wp_insert_term() — we pass name strings here.
	 *
	 * @param  array $candidate Candidate row.
	 * @return array            contributors[] section.
	 */
	private static function build_contributors_from_candidate( array $candidate ): array {
		if ( empty( $candidate['contributor'] ) ) {
			return array();
		}

		$raw_contributors = array_filter( array_map( 'trim', explode( '|', $candidate['contributor'] ) ) );
		$contributors     = array();
		$sort_order       = 0;
		$valid_roles      = array_keys( MBS_Contributor_Book_DAO::get_role_options() );

		foreach ( $raw_contributors as $entry ) {
			$parts     = explode( ':', $entry, 2 );
			$name      = sanitize_text_field( trim( $parts[0] ) );
			$role_code = isset( $parts[1] ) ? strtoupper( trim( $parts[1] ) ) : 'A01';
			if ( '' === $name ) {
				continue;
			}
			if ( ! in_array( $role_code, $valid_roles, true ) ) {
				$role_code = 'A01';
			}
			$contributors[] = array(
				'contributor_name' => $name,
				'onix_role_code'   => $role_code,
				'role_label'       => MBS_Contributor_Book_DAO::get_role_options()[ $role_code ] ?? '',
				'sort_order'       => $sort_order,
				'public'           => 1,
			);
			$sort_order++;
		}

		return $contributors;
	}

	/**
	 * Build related_products[] from the "related_products" pipe-delimited field.
	 *
	 * CSV format: "ISBN:RELATION_CODE|ISBN2:RELATION_CODE2". PRO-only.
	 * Invalid ISBNs and unknown relation codes are silently skipped.
	 *
	 * @param  array $candidate Candidate row.
	 * @return array            related_products[] section.
	 */
	private static function build_related_products_from_candidate( array $candidate ): array {
		if ( ! mbs_is_pro() || empty( $candidate['related_products'] ) ) {
			return array();
		}

		$raw_entries = array_filter( array_map( 'trim', explode( '|', $candidate['related_products'] ) ) );
		$products    = array();

		foreach ( $raw_entries as $entry ) {
			$parts = explode( ':', $entry, 2 );
			$isbn  = MBS_ISBN_Helper::normalize_isbn( trim( $parts[0] ) );
			$code  = isset( $parts[1] ) ? sanitize_text_field( trim( $parts[1] ) ) : '';
			if ( false === $isbn ) {
				continue;
			}
			if ( ! in_array( $code, MBS_Related_Products_DAO::ALLOWED_RELATION_CODES, true ) ) {
				continue;
			}
			$products[] = array(
				'related_isbn'  => $isbn,
				'relation_code' => $code,
			);
		}

		return $products;
	}

	// -------------------------------------------------------------------------
	// Cover sideloading (CSV-specific)
	// -------------------------------------------------------------------------

	/**
	 * Handle cover image: sideload local file immediately; queue remote URLs for
	 * the deferred MBS_Cover_Sideloader pass.
	 *
	 * Three sources, resolved in priority order:
	 *   1. cover_image_file + _images_dir → local file from ZIP bundle (sync)
	 *   2. cover_url   → remote URL from API importers (deferred sideload pass)
	 *   3. cover_image_url → remote URL from CSV column mapping (deferred pass)
	 *
	 * @param int   $post_id   Book post ID.
	 * @param array $candidate Candidate row.
	 * @return bool            True if cover was sideloaded synchronously.
	 */
	private static function handle_cover( int $post_id, array $candidate ): bool {
		// Case 1: ZIP bundle — local file already on disk.
		if ( ! empty( $candidate['cover_image_file'] ) && ! empty( $candidate['_images_dir'] ) ) {
			$img_filename = sanitize_file_name( basename( $candidate['cover_image_file'] ) );
			$img_path     = trailingslashit( $candidate['_images_dir'] ) . $img_filename;
			if ( file_exists( $img_path ) ) {
				MBS_Cover_Sideloader::sideload_from_local_file( $post_id, $img_path );
				return true;
			}
		}

		// Cases 2 & 3: Remote URL — queue for the deferred sideload pass.
		$cover_url = ! empty( $candidate['cover_url'] )
			? $candidate['cover_url']
			: ( $candidate['cover_image_url'] ?? '' );
		if ( ! empty( $cover_url ) ) {
			update_post_meta( $post_id, 'mbs_import_cover_url', esc_url_raw( $cover_url ) );
		}

		return false;
	}
}
