<?php
/**
 * File Path: includes/import/class-mbs-catalog-xlsx-importer.php
 *
 * XLSX Catalog Importer
 *
 * Orchestrates the full multi-tab XLSX import: reads sheets via
 * MBS_XLSX_Reader::get_sheets(), maps via MBS_Catalog_XLSX_Mapper::map(),
 * hydrates each book via MBS_Book_Hydrator::hydrate(), and runs the
 * post-import reading-order pass.
 *
 * Extends MBS_Importer_Base (PLAN-R2-H-004). The base class abstract method
 * import(array $rows) is implemented as a stub that delegates to
 * import_from_path(). Return shape aligns with base class contract.
 *
 * Rule 1: Orchestration only -- no mapping logic, no XML parsing.
 * Rule 2: All writes via MBS_Book_Hydrator -> DAOs.
 * Rule 3: Validates file path before reading. Nonce enforced by caller.
 *
 * @package MyBookShowroom
 * @since   1.3.0
 */

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

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

	// -- Base class stub --------------------------------------------------------------

	/**
	 * Implement abstract MBS_Importer_Base::import() as a stub.
	 *
	 * XLSX import operates on a file path, not an array of rows. This method
	 * exists only to satisfy the abstract contract. Direct callers should use
	 * import_from_path() instead.
	 *
	 * Return shape aligns with base class: imported, updated, skipped, errors.
	 * (PLAN-R2-H-004: 'updated' added, 'warnings' folded into 'errors' with prefix.)
	 *
	 * @param array $rows Not used.
	 * @return array
	 */
	public function import( array $rows ): array {
		return array( 'imported' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => array(
			'Use import_from_path() for XLSX catalog import.',
		) );
	}

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

	/**
	 * Import a 7-tab catalog XLSX file.
	 *
	 * @param string $xlsx_path  Absolute path to the validated XLSX file.
	 * @param array  $options {
	 *   @type string $images_dir      Absolute path to ZIP-extracted images directory.
	 *                                 Empty string if no images bundled.
	 *   @type bool   $force_overwrite When true, passes skip_duplicate_check => true to
	 *                                 MBS_Book_Hydrator::hydrate(), allowing re-import
	 *                                 to overwrite duplicate books. Default false.
	 *                                 (Added R6-M-001; documented R7-L-001)
	 *   @type string $post_status     Post status for imported books. Default: value in
	 *                                 book object ('draft' when set by the XLSX mapper).
	 *                                 Pass 'publish' for tutorial or demo imports.
	 * }
	 * @return array {
	 *   @type int      $imported        Books successfully imported.
	 *   @type int      $updated         Books updated (always 0 -- no update path yet).
	 *   @type int      $skipped         Books skipped as duplicates.
	 *   @type string[] $errors          Fatal or row-level error strings.
	 *                                   Non-fatal warnings prefixed with "Warning:".
	 *   @type int[]    $post_ids        Post IDs of all imported books (Task 30).
	 *   @type int[]    $attachment_ids  Attachment IDs sideloaded during import.
	 * }
	 * @since 1.3.0
	 */
	public static function import_from_path( string $xlsx_path, array $options = array() ): array {
		$result = array( 'imported' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => array(), 'post_ids' => array(), 'attachment_ids' => array() );

		// Read all sheets.
		$sheets = MBS_XLSX_Reader::get_sheets( $xlsx_path );
		if ( is_wp_error( $sheets ) ) {
			$result['errors'][] = $sheets->get_error_message();
			return $result;
		}

		// Map sheets to book objects.
		$mapped = MBS_Catalog_XLSX_Mapper::map( $sheets );
		$result['errors'] = array_merge( $result['errors'], $mapped['errors'] );

		if ( empty( $mapped['books'] ) ) {
			$result['errors'][] = 'No importable book rows found after mapping.';
			return $result;
		}

		$images_dir = $options['images_dir'] ?? ''; // Trusted internal path — no sanitization needed.
		// R6-M-001: Forward force_overwrite so re-importing a corrected catalog can
		// overwrite duplicates. Without this, every re-import silently skips all books.
		$force      = ! empty( $options['force_overwrite'] );

		// Reading-order accumulator.
		// Keyed by term SLUG for uniqueness (PLAN-R2-M-004 -- term names are not unique).
		// Structure: [type][term_slug][post_id] = position.
		$ro_map = array( 'series' => array(), 'universe' => array() );

		// Hydrate each book.
		$progress_callback = is_callable( $options['progress_callback'] ?? null )
			? $options['progress_callback'] : null;
		$total_books = count( $mapped['books'] );
		$book_num    = 0;
		foreach ( $mapped['books'] as $book_object ) {
			// Pre-hydrate: merge overflow memberships from extra_memberships into
			// taxonomies[] and collection_positions[] before passing to hydrator.
			// R3-C-005/R3-C-006: The hydrator only reads 'taxonomies' and
			// 'collection_positions' keys -- extra_memberships is not hydrator-aware.
			foreach ( $book_object['extra_memberships'] ?? array() as $m ) {
				$type = $m['membership_type'] ?? '';
				$name = $m['term_name']       ?? '';
				if ( '' === $name ) { continue; }
				if ( 'series' === $type ) {
					$book_object['taxonomies'][ MBS_TAX_SERIES ][] = $name;
				} elseif ( 'universe' === $type ) {
					$book_object['taxonomies'][ MBS_TAX_UNIVERSE ][] = $name;
				} elseif ( 'collection' === $type ) {
					$book_object['taxonomies'][ MBS_TAX_COLLECTION ][] = $name;
					if ( '' !== ( $m['position'] ?? '' ) && (int) $m['position'] > 0 ) {
						$book_object['collection_positions'][ $name ] = (int) $m['position'];
					}
				}
			}
			$hydrate_opts = array( 'skip_duplicate_check' => $force );
		if ( isset( $options['post_status'] ) ) {
			$hydrate_opts['post_status'] = sanitize_key( $options['post_status'] );
		}
		$post_id = MBS_Book_Hydrator::hydrate( $book_object, $hydrate_opts );  // R6-M-001

			if ( is_wp_error( $post_id ) ) {
				if ( 'duplicate' === $post_id->get_error_code() ) {
					$result['skipped']++;
				} else {
					$result['errors'][] = $post_id->get_error_message();
				}
				continue;
			}

			$result['imported']++;
			$result['post_ids'][] = $post_id;
			++$book_num;
			if ( null !== $progress_callback ) {
				call_user_func( $progress_callback, $book_num, $total_books, $post_id,
					$book_object['post_title'] ?? '' );
			}

			// Accumulate reading order positions for post-import pass.
			self::accumulate_reading_orders( $post_id, $book_object, $ro_map );

			// Import book club text fields unconditionally (no PRO gate: data is
			// always imported; license gates rendering on the front end only).
			self::import_book_club_text( $post_id, $book_object );

			// Import ARC campaign and reviewer data unconditionally (no PRO gate).
			self::import_arc_data( $post_id, $book_object );

			// Sideload any media files.
			if ( '' !== $images_dir ) {
				$result['attachment_ids'] = array_merge(
					$result['attachment_ids'],
					MBS_Catalog_Media_Importer::sideload_all( $post_id, $book_object, $images_dir )
				);
			}
		}

		// Write reading order term_meta after all post_ids are known.
		self::run_reading_order_pass( $ro_map );

		return $result;
	}

	// -- Private helpers --------------------------------------------------------------

	/**
	 * Accumulate reading order positions during the hydrate loop.
	 *
	 * Corrections:
	 * - PLAN-R2-M-004: accumulate by term SLUG (not name) for uniqueness.
	 * - PLAN-R2-H-003: universe_pos from col AT is used for universe position.
	 * - PLAN-R2-M-003: taxonomy constants used.
	 *
	 * @param int   $post_id     Newly created post ID.
	 * @param array $book_object Sanitized MBS Book Object.
	 * @param array &$ro_map     Accumulator by reference.
	 * @since 1.3.0
	 */
	private static function accumulate_reading_orders( int $post_id, array $book_object, array &$ro_map ): void {
		$ro = $book_object['reading_orders'] ?? array();

		// Primary series from Tab 1 -- resolve slug at accumulation time (PLAN-R2-M-004).
		$series_terms = $book_object['taxonomies'][ MBS_TAX_SERIES ] ?? array();
		$series_ro    = (float) ( $ro['series_ro'] ?? 0 );
		if ( ! empty( $series_terms ) && $series_ro > 0 ) {
			$term = get_term_by( 'name', $series_terms[0], MBS_TAX_SERIES );
			if ( $term instanceof WP_Term ) {
				$ro_map['series'][ $term->slug ][ $post_id ] = $series_ro;
			}
		}

		// Primary universe from Tab 1.
		// universe_pos from col AT (PLAN-R2-H-003); universe_ro from col AU.
		$universe_terms = $book_object['taxonomies'][ MBS_TAX_UNIVERSE ] ?? array();
		$universe_pos   = (float) ( $ro['universe_pos'] ?? 0 );  // PLAN-R2-H-003
		if ( ! empty( $universe_terms ) && $universe_pos > 0 ) {
			$term = get_term_by( 'name', $universe_terms[0], MBS_TAX_UNIVERSE );
			if ( $term instanceof WP_Term ) {
				$ro_map['universe'][ $term->slug ][ $post_id ] = $universe_pos;
			}
		}

		// Overflow memberships from Tab 7.
		foreach ( $book_object['extra_memberships'] ?? array() as $m ) {
			$type      = $m['membership_type'] ?? '';
			$term_name = $m['term_name']       ?? '';
			$ro_v      = (float) ( $m['reading_order'] ?? 0 );
			if ( ! in_array( $type, array( 'series', 'universe' ), true ) || '' === $term_name || $ro_v <= 0 ) {
				continue;
			}
			$tax  = 'series' === $type ? MBS_TAX_SERIES : MBS_TAX_UNIVERSE;
			$term = get_term_by( 'name', $term_name, $tax );  // resolve slug
			if ( $term instanceof WP_Term ) {
				$ro_map[ $type ][ $term->slug ][ $post_id ] = $ro_v;
			}
		}
	}

	/**
	 * Write reading order arrays to term_meta after all books are hydrated.
	 *
	 * Corrections:
	 * - PLAN-C-001: Series DAO method is save_reading_order() (not save()).
	 * - PLAN-C-002: Universe DAO method is save_reading_order() (not save()).
	 * - PLAN-R2-M-004: ro_map is now keyed by slug; lookup uses 'slug' not 'name'.
	 * - PLAN-R2-M-003: taxonomy constants used.
	 *
	 * Series reading order stored in mbs_book_meta DB column (Sprint 4.34).
	 * The DAO accepts $ordered_post_ids as an ordered array; position derived
	 * from array index internally (confirmed by reading save_reading_order() in
	 * class-mbs-series-reading-order-dao.php).
	 *
	 * @param array $ro_map ['series' => [slug => [post_id => pos]], 'universe' => [...]]
	 * @since 1.3.0
	 */
	private static function run_reading_order_pass( array $ro_map ): void {
		// Series (PLAN-C-001: save_reading_order(), not save()).
		foreach ( $ro_map['series'] as $term_slug => $post_positions ) {
			$term = get_term_by( 'slug', $term_slug, MBS_TAX_SERIES );  // PLAN-R2-M-004: slug lookup
			if ( ! $term instanceof WP_Term ) {
				continue;
			}
			asort( $post_positions );  // Sort by position value ascending.
			$ordered_post_ids = array_keys( $post_positions );
			MBS_Series_Reading_Order_DAO::save_reading_order( $term->term_id, $ordered_post_ids );
		}

		// Universe (PLAN-C-002: save_reading_order(), not save()).
		foreach ( $ro_map['universe'] as $term_slug => $post_positions ) {
			$term = get_term_by( 'slug', $term_slug, MBS_TAX_UNIVERSE );  // PLAN-R2-M-004
			if ( ! $term instanceof WP_Term ) {
				continue;
			}
			asort( $post_positions );
			$ordered_post_ids = array_keys( $post_positions );
			MBS_Universe_Reading_Order_DAO::save_reading_order( $term->term_id, $ordered_post_ids );
		}
	}

	/**
	 * Write book club text fields from the XLSX mapper to the DAO.
	 *
	 * Writes the enabled flag, discussion guide HTML, and ordered discussion
	 * questions. Downloadable file attachments are handled separately by
	 * sideload_downloadable_files(). No PRO gate -- data is always imported;
	 * the license gates rendering on the front end only.
	 *
	 * @param int   $post_id     Book post ID.
	 * @param array $book_object Sanitized MBS Book Object.
	 * @return void
	 * @since 1.3.3
	 */
	private static function import_book_club_text( int $post_id, array $book_object ): void {
		$bc = $book_object['book_club'] ?? array();
		if ( empty( $bc ) ) {
			return;
		}

		// '1' or non-empty string = enabled; '0' or empty = disabled.
		$enabled = ! empty( $bc['enabled'] ) && '0' !== (string) $bc['enabled'];
		$guide   = wp_kses_post( $bc['discussion_guide'] ?? '' );
		MBS_Book_Club_DAO::upsert_header( $post_id, $enabled, $guide );

		$questions = array_values( array_filter( (array) ( $bc['questions'] ?? array() ) ) );
		MBS_Book_Club_DAO::replace_questions( $post_id, $questions );
	}

	/**
	 * Write ARC campaign and reviewer data from the XLSX mapper to the DAOs.
	 *
	 * Wipes all existing ARC data for the book (recipients, review URLs, and
	 * the campaign row) then reinserts from the book object. No PRO gate —
	 * data is always imported; the license gates ARC rendering on the front end.
	 *
	 * Sequence:
	 *   1. MBS_Arc_Recipient_DAO::delete_all_for_book() — clear everything.
	 *   2. MBS_Arc_Recipient_DAO::save_campaign()       — upsert header row.
	 *   3. MBS_Arc_Recipient_DAO::insert()              — one row per reviewer.
	 *   4. MBS_Arc_Recipient_DAO::save_urls()           — review URLs per recipient.
	 *
	 * @param int   $post_id     Book post ID.
	 * @param array $book_object Sanitized MBS Book Object.
	 * @return void
	 * @since 1.3.4
	 */
	private static function import_arc_data( int $post_id, array $book_object ): void {
		$arc = $book_object['arc'] ?? array();
		if ( empty( $arc ) ) {
			return;
		}

		// Wipe all existing ARC data for this book before reinserting.
		MBS_Arc_Recipient_DAO::delete_all_for_book( $post_id );

		// Upsert the campaign header row.
		MBS_Arc_Recipient_DAO::save_campaign( $post_id, array(
			'status'             => $arc['status']             ?? 'not_started',
			'copy_count'         => $arc['copy_count']         ?? 0,
			'distribution_date'  => $arc['distribution_date']  ?? '',
			'close_date'         => $arc['close_date']         ?? '',
			'target_review_date' => $arc['target_review_date'] ?? '',
		) );

		// Insert reviewer rows and their review URLs.
		foreach ( $arc['reviewers'] ?? array() as $r ) {
			// Skip blank reviewer rows (reviewer_name is the required field).
			if ( '' === ( $r['reviewer_name'] ?? '' ) ) {
				continue;
			}
			$recipient_id = MBS_Arc_Recipient_DAO::insert( array(
				'post_id'        => $post_id,
				'reviewer_name'  => $r['reviewer_name'],
				'contact_method' => $r['contact_method'] ?? '',
				'contact_type'   => $r['contact_type']   ?? '',
				// Pass null for empty date so the DB column stores NULL, not '0000-00-00'.
				'date_sent'      => '' !== ( $r['date_sent'] ?? '' ) ? $r['date_sent'] : null,
				'review_posted'  => $r['review_posted']  ?? 0,
			) );
			if ( false !== $recipient_id && ! empty( $r['review_urls'] ) ) {
				MBS_Arc_Recipient_DAO::save_urls( $recipient_id, $r['review_urls'] );
			}
		}
	}
}
