<?php
/**
 * File Path: includes/export/class-mbs-exporter-zip.php
 *
 * Catalog + Covers ZIP Exporter (PRO/PUB)
 *
 * Builds a single ZIP archive containing:
 *   - catalog.xlsx      -- full 13-tab catalog (Books, Formats, Buy Links, Endorsements,
 *                          Contributors, Awards, Memberships, Discussion Questions,
 *                          Downloadable Files, ARC Reviewers, Taxonomy, Section Order, Manifest)
 *   - catalog.xlsx Tab 11  -- all MBS taxonomy terms and term_meta
 *   - catalog.xlsx Tab 12  -- per-book section order and visibility (Section Key, Sort Order, Is Visible)
 *   - catalog.xlsx Tab 13  -- schema_version, export_date, plugin_version, site_url, book_count
 *   - covers/           -- book cover images (human-readable initials naming)
 *   - banners/          -- banner images (mbs_banner_image_id postmeta)
 *   - trailers/         -- trailer videos (mbs_trailer_video_id postmeta)
 *   - spinning-videos/  -- per-format spinning videos
 *   - audio/            -- per-format audio samples
 *   - author-headshots/ -- author photos (mbs_author_photo_id term meta)
 *   - files/            -- book club downloadable files
 *
 * Cover naming convention:
 *   With series:    {SeriesInitials}{SeriesPos}_{AuthorInitials}_{TitleInitials}_cover.{ext}
 *   Without series: {AuthorInitials}_{TitleInitials}_cover.{ext}
 *   Stop words stripped: a an the of in on at and or for to with by from.
 *   Collision tiebreaker: append _{last4 of post_id}.
 *
 * Gates: mbs_is_registered() + mbs_is_pro() -- covers both PRO and PUB tiers
 * (mbs_is_pro() returns true for both the pro and pub license tiers).
 * Output: direct HTTP stream via readfile() -- calls exit on completion.
 * SQL: all book/format queries go through MBS_Format_Row_DAO (GP-SQL-1).
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */

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

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

	/**
	 * Stop words stripped when building initials for cover filenames.
	 *
	 * @var string[]
	 */
	private static $stop_words = array(
		'a', 'an', 'the', 'of', 'in', 'on', 'at', 'and', 'or', 'for', 'to', 'with', 'by', 'from',
	);

	/**
	 * Build and stream a Catalog + Covers ZIP archive to the browser.
	 *
	 * Called by MBS_Tab_Child_Export::handle_ajax_mbs_export_covers_zip() after
	 * nonce and capability checks have passed. This method enforces its own
	 * license gates as defense-in-depth (GP-T-1).
	 *
	 * @return void Does not return -- exits after streaming.
	 * @since  1.0.0
	 */
	public static function download(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to export books.', 'my-book-showroom' ), 403 );
		}

		if ( ! mbs_is_registered() ) {
			wp_die( esc_html__( 'A valid license is required to export books.', 'my-book-showroom' ), 403 );
		}

		if ( ! mbs_is_pro() ) {
			wp_die( esc_html__( 'Catalog export requires a PRO license.', 'my-book-showroom' ), 403 );
		}

		if ( ! class_exists( 'ZipArchive' ) ) {
			wp_send_json_error( array( 'message' => 'ZipArchive is not available on this server. Please contact your host.' ) );
		}

		$temp_dir = trailingslashit( get_temp_dir() ) . 'mbs-temp-export' . DIRECTORY_SEPARATOR;
		wp_mkdir_p( $temp_dir );
		$zip_path = $temp_dir . 'mbs-catalog-' . get_current_user_id() . '_' . uniqid() . '.zip';

		$zip = new ZipArchive();
		if ( true !== $zip->open( $zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
			wp_send_json_error( array( 'message' => 'Could not create ZIP archive. Check server temp directory permissions.' ) );
		}

		// ── Pass 1: Collect all media filenames and queue ZIP entries ──────────
		// Both file_map (for XLSX) and media_queue (for ZIP) are built together
		// so filename computation is shared, guaranteeing XLSX references match
		// ZIP entry paths for a correct round-trip import.
		[ $file_map, $media_queue ] = static::collect_media_queue();


		// ── Build catalog.xlsx with media filenames embedded ─────────────────
		// ZipArchive::addFile() is lazy -- files are flushed on close(), so
		// we must NOT delete $xlsx_temp until after $zip->close().
		$xlsx_temp   = $temp_dir . 'catalog_' . uniqid() . '.xlsx';
		$xlsx_result = MBS_Catalog_XLSX_Exporter::build_to_path( $xlsx_temp, $file_map );
		if ( is_wp_error( $xlsx_result ) ) {
			$zip->close();
			// phpcs:ignore WordPress.PHP.NoSilencedErrors
			wp_delete_file( $zip_path );
			// phpcs:ignore WordPress.PHP.NoSilencedErrors
			wp_delete_file( $xlsx_temp ); // Prevent partial XLSX temp file leak on build failure.
			wp_send_json_error( array( 'message' => 'XLSX build failed: ' . $xlsx_result->get_error_message() ) );
		}
		$zip->addFile( $xlsx_temp, 'catalog.xlsx' );

		// ── Add all queued media files to ZIP ─────────────────────────────────
		foreach ( $media_queue as $entry ) {
			MBS_Export_Media_Collector::add_attachment_as( $zip, $entry[0], $entry[1] );
		}

		$zip->close();
		// phpcs:ignore WordPress.PHP.NoSilencedErrors
		wp_delete_file( $xlsx_temp ); // Safe to delete now that close() has flushed addFile().

		// ── Stream ZIP to browser ─────────────────────────────────────────────
		$filename = 'mbs-catalog-' . gmdate( 'Y-m-d' ) . '.zip';

		while ( ob_get_level() ) {
			ob_end_clean();
		}

		header( 'Content-Type: application/zip' );
		header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
		header( 'Content-Length: ' . filesize( $zip_path ) );
		header( 'Pragma: no-cache' );
		header( 'Expires: 0' );

		readfile( $zip_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- WP_Filesystem migration deferred to PC-FILESYS sprint; PHP native file ops are safe here (temp files in get_temp_dir(), never inside wp-content)
		// phpcs:ignore WordPress.PHP.NoSilencedErrors
		wp_delete_file( $zip_path );

		exit;
	}

	/**
	 * Build the file_map and media_queue for a full catalog export.
	 *
	 * Queries all MBS book posts, computes a deterministic filename for every
	 * media asset (cover, banner, trailer, spinning video, audio, headshots,
	 * book-club files, term banners), and returns:
	 *   [0] file_map   — post_id => asset filename map used by the XLSX builder.
	 *   [1] media_queue — list of [attachment_id, zip_entry_path] pairs.
	 *
	 * @return array{0: array, 1: array} [ $file_map, $media_queue ]
	 * @since 1.0.0
	 */
	private static function collect_media_queue(): array {
	$file_map    = array(); // post_id => media filenames for XLSX.
	$media_queue = array(); // [ [att_id, zip_entry_path], ... ] for ZIP assembly.
	$seen_covers        = array(); // Collision guard for cover filenames.
	$seen_authors       = array(); // Deduplicate author headshots across books.
	$seen_term_banners  = array(); // Deduplicate term banners across books.

	$query = new WP_Query( MBS_Book_Export_Data::get_all_books_query_args() );

	foreach ( $query->posts as $post ) {
		$post_id   = (int) $post->ID;
		$book_slug = sanitize_title( $post->post_title );

		$file_map[ $post_id ] = array(
			'cover'             => '',
			'banner'            => '',
			'trailer'           => '',
			'spin'              => '',
			'series_banner'     => '',
			'collection_banner' => '',
			'universe_banner'   => '',
			'overflow_banners'  => array( 'series' => array(), 'collection' => array(), 'universe' => array() ),
			'formats'           => array(),
			'files'             => array(),
			'headshots'         => array(), // contributor_name => zip-relative filename (Tab 5 col 7).
		);

		// ── Featured cover ────────────────────────────────────────────────
		$thumbnail_id = (int) get_post_thumbnail_id( $post_id ); // R5-exempt: ZIP export context
		if ( $thumbnail_id > 0 ) {
			$book_data      = MBS_Book_Export_Data::get_book_data( $post_id );
			$ext            = MBS_Export_Media_Collector::get_attachment_ext( $thumbnail_id );
			$cover_filename = static::build_cover_filename(
				$book_data['title'],
				$book_data['author'],
				$book_data['series'],
				$book_data['series_number'],
				$post_id,
				$ext,
				$seen_covers
			);
			$file_map[ $post_id ]['cover'] = $cover_filename;
			$media_queue[] = array( $thumbnail_id, 'covers/' . $cover_filename );
		}

		// ── Format-row media ──────────────────────────────────────────────
		$format_rows = MBS_Format_Row_DAO::get_by_post( $post_id );
		foreach ( $format_rows as $fr ) {
			$format_label = $fr->format_label ?? '';
			$format_slug  = sanitize_title( '' !== $format_label ? $format_label : ( $fr->format ?? 'format' ) );
			$isbn13       = $fr->isbn13 ?? '';
			// Key by isbn13 when available (XLSX importer matches on isbn13);
			// fall back to format_label for formats without an ISBN.
			$fkey = '' !== $isbn13 ? $isbn13 : $format_label;

			$file_map[ $post_id ]['formats'][ $fkey ] = array(
				'cover' => '',
				'audio' => '',
				'spin'  => '',
			);

			// Per-format cover (only when different from the featured image).
			$fmt_cover_id = (int) ( $fr->cover_image_id ?? 0 );
			if ( $fmt_cover_id > 0 && $fmt_cover_id !== $thumbnail_id ) {
				$ext         = MBS_Export_Media_Collector::get_attachment_ext( $fmt_cover_id );
				$fmt_cv_name = $book_slug . '-' . $format_slug . '.' . $ext;
				$file_map[ $post_id ]['formats'][ $fkey ]['cover'] = $fmt_cv_name;
				$media_queue[] = array( $fmt_cover_id, 'covers/' . $fmt_cv_name );
			}

			// Spinning video.
			$spin_id = (int) ( $fr->spinning_video_id ?? 0 );
			if ( $spin_id > 0 ) {
				$ext       = MBS_Export_Media_Collector::get_attachment_ext( $spin_id );
				$spin_name = $book_slug . '-' . $format_slug . '-spin.' . $ext;
				$file_map[ $post_id ]['formats'][ $fkey ]['spin'] = $spin_name;
				$media_queue[] = array( $spin_id, 'spinning-videos/' . $spin_name );
			}

			// Audio sample.
			$audio_id = (int) ( $fr->audio_sample_id ?? 0 );
			if ( $audio_id > 0 ) {
				$ext        = MBS_Export_Media_Collector::get_attachment_ext( $audio_id );
				$audio_name = $book_slug . '-' . $format_slug . '-audio.' . $ext;
				$file_map[ $post_id ]['formats'][ $fkey ]['audio'] = $audio_name;
				$media_queue[] = array( $audio_id, 'audio/' . $audio_name );
			}
		}

		// ── Banner ────────────────────────────────────────────────────────
		$banner_id = (int) get_post_meta( $post_id, 'mbs_banner_image_id', true );
		if ( $banner_id > 0 ) {
			$ext         = MBS_Export_Media_Collector::get_attachment_ext( $banner_id );
			$banner_name = $book_slug . '-banner.' . $ext;
			$file_map[ $post_id ]['banner'] = $banner_name;
			$media_queue[] = array( $banner_id, 'banners/' . $banner_name );
		}

		// ── Trailer video ─────────────────────────────────────────────────
		$trailer_id = (int) get_post_meta( $post_id, 'mbs_trailer_video_id', true );
		if ( $trailer_id > 0 ) {
			$ext          = MBS_Export_Media_Collector::get_attachment_ext( $trailer_id );
			$trailer_name = $book_slug . '-trailer.' . $ext;
			$file_map[ $post_id ]['trailer'] = $trailer_name;
			$media_queue[] = array( $trailer_id, 'trailers/' . $trailer_name );
		}

		// ── Term banners (series / collection / universe) ───────────────────
		$banner_term_cfg = array(
			'series'     => array( 'tax' => MBS_TAX_SERIES,     'meta' => 'mbs_series_banner_id',     'prefix' => 'series-' ),
			'collection' => array( 'tax' => MBS_TAX_COLLECTION, 'meta' => 'mbs_collection_banner_id', 'prefix' => 'collection-' ),
			'universe'   => array( 'tax' => MBS_TAX_UNIVERSE,   'meta' => 'mbs_universe_banner_id',   'prefix' => 'universe-' ),
		);
		foreach ( $banner_term_cfg as $btype => $bcfg ) {
			$bterms = wp_get_post_terms( $post_id, $bcfg['tax'], array( 'fields' => 'all' ) );
			if ( is_wp_error( $bterms ) ) { continue; }
			$primary_set = false;
			foreach ( $bterms as $bterm ) {
				$bid = (int) get_term_meta( $bterm->term_id, $bcfg['meta'], true );
				if ( $bid <= 0 ) { continue; }
				$bext  = MBS_Export_Media_Collector::get_attachment_ext( $bid );
				$bname = $bcfg['prefix'] . $bterm->slug . '-banner.' . $bext;
				$bkey  = $bcfg['tax'] . '.' . $bterm->term_id;
				if ( ! isset( $seen_term_banners[ $bkey ] ) ) {
					$seen_term_banners[ $bkey ] = true;
					$media_queue[] = array( $bid, 'banners/' . $bname );
				}
				if ( ! $primary_set ) {
					$file_map[ $post_id ][ $btype . '_banner' ] = $bname;
					$primary_set = true;
				} else {
					$file_map[ $post_id ]['overflow_banners'][ $btype ][ $bterm->name ] = $bname;
				}
			}
		}

		// ── Book-level spinning video ───────────────────────────────────────
		$book_spin_id = (int) get_post_meta( $post_id, 'mbs_spinning_video_id', true );
		if ( $book_spin_id > 0 ) {
			$ext       = MBS_Export_Media_Collector::get_attachment_ext( $book_spin_id );
			$spin_name = $book_slug . '-spin.' . $ext;
			$file_map[ $post_id ]['spin'] = $spin_name;
			$media_queue[] = array( $book_spin_id, 'spinning-videos/' . $spin_name );
		}

		// ── Author headshots ──────────────────────────────────────────────
		// Deduplicated by author slug for the ZIP queue. The filename is also
		// written to file_map so the XLSX builder can populate Tab 5 col 7
		// (Headshot File) — the column the importer uses to sideload headshots.
		$author_terms = wp_get_post_terms( $post_id, 'mbs_author', array( 'fields' => 'all' ) );
		if ( ! is_wp_error( $author_terms ) ) {
			foreach ( $author_terms as $term ) {
				$photo_id    = (int) get_term_meta( $term->term_id, 'mbs_author_photo_id', true );
				$author_slug = sanitize_title( $term->name );
				if ( $photo_id > 0 ) {
					$ext           = MBS_Export_Media_Collector::get_attachment_ext( $photo_id );
					$headshot_name = $author_slug . '.' . $ext;
					// Queue for ZIP (deduplicated — one file per author across all books).
					if ( ! isset( $seen_authors[ $author_slug ] ) ) {
						$seen_authors[ $author_slug ] = true;
						$media_queue[] = array( $photo_id, 'author-headshots/' . $headshot_name );
					}
					// Register filename in file_map for XLSX Tab 5 col 7.
					$file_map[ $post_id ]['headshots'][ $term->name ] = $headshot_name;
				}
			}
		}

		// ── Book club downloadable files ─────────────────────────────────────
		// Reads from MBS_Book_Club_DAO, builds {slug}-file-{n}.{ext} filenames,
		// registers in file_map (for XLSX Tab 9 col C) and queues for ZIP files/.
		$bc_files = MBS_Book_Club_DAO::get_files( $post_id );
		foreach ( $bc_files as $n => $bc_file ) {
			$att_id = (int) ( $bc_file['id'] ?? 0 );
			if ( $att_id <= 0 ) { continue; }
			$ext      = MBS_Export_Media_Collector::get_attachment_ext( $att_id );
			$zip_name = $book_slug . '-file-' . ( $n + 1 ) . '.' . $ext;
			$file_map[ $post_id ]['files'][] = array(
				'zip_name' => $zip_name,
				'label'    => (string) ( $bc_file['label'] ?? '' ),
			);
			$media_queue[] = array( $att_id, 'files/' . $zip_name );
		}
	}
	wp_reset_postdata();
		return array( $file_map, $media_queue );

	}

		/**
	 * Generate a human-readable cover filename for a book.
	 *
	 * Pattern with series:    {SeriesInitials}{SeriesPos}_{AuthorInitials}_{TitleInitials}_cover.{ext}
	 * Pattern without series: {AuthorInitials}_{TitleInitials}_cover.{ext}
	 *
	 * Collision tiebreaker: append _{last 4 digits of post_id} before the extension.
	 *
	 * @param string   $title         Book title.
	 * @param string   $author        Primary author name.
	 * @param string   $series        Series name, or empty string if standalone.
	 * @param string   $series_number Series position, or empty string.
	 * @param int      $post_id       Book post ID (for collision tiebreaker).
	 * @param string   $ext           File extension without dot (e.g. jpg).
	 * @param string[] $seen          Reference array tracking generated filenames.
	 * @return string                 Filename including extension.
	 * @since  1.0.0
	 */
	public static function build_cover_filename(
		string $title,
		string $author,
		string $series,
		string $series_number,
		int $post_id,
		string $ext,
		array &$seen
	): string {
		$title_initials  = static::make_initials( $title );
		$author_initials = static::make_initials( $author );

		if ( '' !== $series ) {
			$series_initials = static::make_initials( $series );
			$series_pos      = static::normalize_series_position( $series_number );
			$base            = $series_initials . $series_pos . '_' . $author_initials . '_' . $title_initials . '_cover';
		} else {
			$base = $author_initials . '_' . $title_initials . '_cover';
		}

		$candidate = $base . '.' . $ext;
		if ( ! isset( $seen[ $candidate ] ) ) {
			$seen[ $candidate ] = true;
			return $candidate;
		}

		// Collision: append last 4 digits of post ID as tiebreaker.
		$suffix    = '_' . substr( str_pad( (string) $post_id, 4, '0', STR_PAD_LEFT ), -4 );
		$candidate = $base . $suffix . '.' . $ext;
		$seen[ $candidate ] = true;
		return $candidate;
	}

	/**
	 * Extract initials from a string, stripping stop words.
	 *
	 * Splits on spaces and hyphens, removes stop words, takes the first uppercase
	 * letter of each remaining word. Falls back to the first letter of the original
	 * first word if all words are stop words.
	 *
	 * @param string $text Input string (title, author name, or series name).
	 * @return string      Uppercase initials. Empty string if input is empty.
	 * @since  1.0.0
	 */
	public static function make_initials( string $text ): string {
		$text = trim( $text );
		if ( '' === $text ) {
			return '';
		}

		$words       = preg_split( '/[\s\-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
		$lower_words = array_map( 'strtolower', $words );
		$significant = array_values( array_filter(
			$lower_words,
			function( $w ) { return ! in_array( $w, static::$stop_words, true ); }
		) );

		if ( empty( $significant ) ) {
			// All words are stop words -- fall back to first word's first letter.
			return strtoupper( mb_substr( $lower_words[0], 0, 1 ) );
		}

		$initials = '';
		foreach ( $significant as $w ) {
			$initials .= strtoupper( mb_substr( $w, 0, 1 ) );
		}
		return $initials;
	}

	/**
	 * Normalize a series position value for use in a filename.
	 *
	 * Decimal positions (e.g. 1.5) are converted to hyphenated form (1-5).
	 * Integer positions and empty strings are returned unchanged.
	 *
	 * @param string $position Raw series position meta value.
	 * @return string          Filename-safe position string.
	 * @since  1.0.0
	 */
	public static function normalize_series_position( string $position ): string {
		$position = trim( $position );
		if ( '' === $position ) {
			return '';
		}
		return str_replace( '.', '-', $position );
	}

	// ─────────────────────────────────────────────────────────────────────────
	// Taxonomy helpers (shared logic for ZIP round-trip; mirrored from Archive_Builder)
	// ─────────────────────────────────────────────────────────────────────────
}
