<?php
/**
 * File Path: includes/admin/tabs/imp-exp/class-mbs-tab-child-import-epub.php
 *
 * Import / Export Tab -- EPUB Import Child
 *
 * Two-phase AJAX handler for the EPUB Import feature (EPUB-001):
 *   Phase 1 -- mbs_epub_parse  : validate + parse the .epub file; return preview JSON.
 *   Phase 2 -- mbs_epub_import : build an MBS Book Object and hydrate via MBS_Book_Hydrator.
 *                               Cover image sideloading is handled post-hydration.
 *
 * IEF-C-001 (Sprint IEF-XLSX Phase 6): the direct wp_insert_post / post meta methods
 * have been removed. Post creation and all meta/taxonomy/
 * classification/contributor writes are now delegated to MBS_Book_Hydrator::hydrate().
 * A single post-hydration direct write handles mbs_language (a known gap in the
 * hydrator meta whitelist, documented in the Phase 6A field-mapping analysis).
 *
 * PRO-gated (mbs_is_pro()). Uses MBS_Ajax_Import_Export_Guard for nonce + capability.
 *
 * @package MyBookShowroom
 * @since   1.2.0
 */

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

/**
 * Class MBS_Tab_Child_Import_EPUB
 *
 * @since 1.2.0
 */
// phpcs:disable WordPress.Security.NonceVerification -- nonce verified in parent class verify_save() / check_admin_referer() before $_POST access; PHPCS cannot trace cross-method nonce verification
// phpcs:disable WordPress.Security.ValidatedSanitizedInput -- input sanitized at point of use (sanitize_text_field, absint, wp_kses_post, etc.); false positives from PHPCS not tracing sanitization helpers called before this method
class MBS_Tab_Child_Import_EPUB {

	use MBS_Ajax_Import_Export_Guard;

	// __ Phase 1: Parse ___________________________________________________________

	/**
	 * AJAX handler: validate and parse an uploaded .epub file.
	 *
	 * Accepts a multipart POST with action=mbs_epub_parse and a file field named
	 * 'file'. Returns a JSON preview payload matching EPUB-001 SS13 Phase 1.
	 *
	 * Tier: PRO / PUB only.
	 *
	 * @since  1.2.0
	 * @return void  Sends JSON and terminates.
	 */
	public function handle_ajax_mbs_epub_parse(): void {
		$this->verify_import_export_request();

		if ( ! mbs_is_pro() ) {
			wp_send_json_error( [ 'message' => 'EPUB Import requires a PRO license.' ], 403 );
		}

		$uploaded = $_FILES['file'] ?? null;
		if ( ! $uploaded || UPLOAD_ERR_OK !== (int) $uploaded['error'] ) {
			$code = (int) ( $uploaded['error'] ?? -1 );
			wp_send_json_error( [ 'message' => sprintf( 'File upload failed (code %d).', $code ) ] );
		}

		$upload_name = sanitize_file_name( wp_unslash( $uploaded['name'] ?? '' ) );
		if ( 'epub' !== strtolower( pathinfo( $upload_name, PATHINFO_EXTENSION ) ) ) {
			wp_send_json_error( [ 'message' => 'Only .epub files are accepted.' ] );
		}

		if ( ! is_uploaded_file( $uploaded['tmp_name'] ) ) {
			wp_send_json_error( [ 'message' => 'Invalid file upload.' ] );
		}

		// SEC-009: Inspect file content — do not rely on extension alone.
		$check = wp_check_filetype_and_ext(
			$uploaded['tmp_name'],
			$upload_name,
			array( 'epub' => 'application/epub+zip' )
		);
		if ( empty( $check['ext'] ) || 'epub' !== $check['ext'] ) {
			wp_send_json_error( [ 'message' => 'Only .epub files are accepted.' ] );
		}

		// EPUB size gate (Sprint 171).
		if ( (int) $uploaded['size'] > MBS_LOOK_INSIDE_EPUB_MAX_BYTES ) {
			wp_send_json_error( [ 'message' => 'EPUB file exceeds the 10 MB limit.' ] );
		}

		// Stage to a persistent temp path so the parser can re-open it.
		$temp_dir  = get_temp_dir() . 'mbs-temp-import/';
		wp_mkdir_p( $temp_dir );
		$uid       = get_current_user_id();
		$rand      = wp_generate_password( 16, false );
		$temp_path = $temp_dir . 'epub_' . $uid . '_' . $rand . '.epub';

		if ( ! copy( $uploaded['tmp_name'], $temp_path ) ) {
			wp_send_json_error( [ 'message' => 'Failed to stage uploaded EPUB for processing.' ] );
		}

		$parser = new MBS_EPUB_Parser();
		$data   = $parser->parse( $temp_path );

		// Look Inside -- store candidate HTML in a transient keyed by uid only.
		// The book post does not exist yet at Phase 1 (created in Phase 2 by
		// MBS_Book_Hydrator::hydrate()), so $post_id cannot be part of the transient key.
		// Uploading a new EPUB overwrites the previous transient (one per user).
		$candidates = $data['look_inside_candidates'] ?? [];
		if ( ! empty( $candidates ) ) {
			$trans_key = 'mbs_epub_li_' . $uid;
			set_transient( $trans_key, $candidates, HOUR_IN_SECONDS );
		}
		$data['look_inside_labels'] = array_column( $candidates, 'label' );
		unset( $data['look_inside_candidates'] );

		// Sideload cover to a temp URL for the preview panel.
		$cover_temp_url = '';
		$cover_temp_key = '';
		if ( ! empty( $data['cover_bytes'] ) ) {
			$cover_key  = 'epub_cover_' . $uid . '_' . $rand;
			$cover_ext  = $data['cover_ext'] ?: 'jpg';
			$cover_path = $temp_dir . $cover_key . '.' . $cover_ext;
			global $wp_filesystem;
			if ( ! empty( $wp_filesystem ) && is_object( $wp_filesystem ) ) {
				$wrote = $wp_filesystem->put_contents( $cover_path, $data['cover_bytes'], FS_CHMOD_FILE ) ? strlen( $data['cover_bytes'] ) : false;
			} else {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- fallback when WP_Filesystem unavailable
				$wrote = @file_put_contents( $cover_path, $data['cover_bytes'] );
			}
			if ( false !== $wrote ) {
				// Expose as an admin-ajax URL so the preview can show the thumbnail.
				$cover_temp_url = admin_url(
					'admin-ajax.php?action=mbs_epub_cover_thumb'
					. '&key=' . rawurlencode( $cover_key )
					. '&ext=' . rawurlencode( $cover_ext )
					. '&nonce=' . wp_create_nonce( 'mbs_epub_cover_' . $cover_key )
				);
				$cover_temp_key = $cover_key;
			}
		}
		unset( $data['cover_bytes'] );

		// Always clean up the staged EPUB file.
		// phpcs:ignore WordPress.PHP.NoSilencedErrors
		wp_delete_file( $temp_path );

		if ( ! $data['ok'] ) {
			wp_send_json_error( [ 'message' => $data['error'] ] );
		}

		// Duplicate detection (ISBN-13 first, then title).
		$data['duplicate'] = $this->check_duplicate( $data['isbn13'], $data['title'] );

		$data['cover_temp_url'] = $cover_temp_url;
		$data['cover_temp_key'] = $cover_temp_key;

		wp_send_json_success( $data );
	}

	// __ Cover thumbnail __________________________________________________________

	/**
	 * AJAX handler: serve the staged cover thumbnail for the preview panel.
	 *
	 * Accepts GET params: key (sanitize_key), ext (file extension), nonce.
	 * Nonce action: mbs_epub_cover_{key}. Logged-in users only.
	 *
	 * @since  1.2.0
	 * @return void  Outputs image binary and exits.
	 */
	public function handle_ajax_mbs_epub_cover_thumb(): void {
		$key   = sanitize_key( wp_unslash( $_GET['key']   ?? '' ) );
		$ext   = sanitize_key( wp_unslash( $_GET['ext']   ?? '' ) );
		$nonce = sanitize_text_field( wp_unslash( $_GET['nonce'] ?? '' ) );

		if ( '' === $key || ! wp_verify_nonce( $nonce, 'mbs_epub_cover_' . $key ) ) {
			wp_die( '', '', 403 );
		}

		$allowed = [ 'jpg', 'jpeg', 'png', 'gif', 'webp' ];
		if ( ! in_array( $ext, $allowed, true ) ) {
			wp_die( '', '', 400 );
		}

		$base_dir  = realpath( get_temp_dir() . 'mbs-temp-import/' );
		$file      = get_temp_dir() . 'mbs-temp-import/' . $key . '.' . $ext;
		// SA-L13: Confine resolved path to the temp directory to prevent path traversal.
		$real_file = realpath( $file );
		if (
			false === $real_file ||
			false === $base_dir ||
			! str_starts_with( $real_file, $base_dir . DIRECTORY_SEPARATOR )
		) {
			wp_die( '', '', 403 );
		}
		if ( ! file_exists( $real_file ) ) {
			wp_die( '', '', 404 );
		}

		$mime_map = [
			'jpg'  => 'image/jpeg',
			'jpeg' => 'image/jpeg',
			'png'  => 'image/png',
			'gif'  => 'image/gif',
			'webp' => 'image/webp',
		];

		header( 'Content-Type: ' . ( $mime_map[ $ext ] ?? 'image/jpeg' ) );
		header( 'Content-Length: ' . filesize( $real_file ) );
		header( 'Cache-Control: no-store' );
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- binary image stream
		readfile( $real_file ); // 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)
		exit;
	}

	// __ Phase 2: Import __________________________________________________________

	/**
	 * AJAX handler: create the book post from the parsed EPUB data.
	 *
	 * Builds an MBS Book Object via build_book_object_from_epub() and delegates
	 * to MBS_Book_Hydrator::hydrate() (IEF-C-001). Duplicate detection is performed
	 * here before calling the hydrator; skip_duplicate_check is always passed as
	 * true to avoid a second check inside the hydrator.
	 *
	 * A post-hydration direct write handles mbs_language because that key is absent
	 * from the hydrator's meta whitelist (Phase 6A gap, flagged for resolution via
	 * the hydrator in a future session).
	 *
	 * Accepts a POST with action=mbs_epub_import and the payload fields from
	 * Phase 1. On success returns { edit_url, notes[] }.
	 *
	 * Tier: PRO / PUB only.
	 *
	 * @since  1.2.0
	 * @return void  Sends JSON and terminates.
	 */
	public function handle_ajax_mbs_epub_import(): void {
		$this->verify_import_export_request();

		if ( ! mbs_is_pro() ) {
			wp_send_json_error( [ 'message' => 'EPUB Import requires a PRO license.' ], 403 );
		}

		$post_data = wp_unslash( $_POST );
		$force     = ! empty( $post_data['force'] ) && 'true' === $post_data['force'];

		// Re-run duplicate check if not forcing.
		if ( ! $force ) {
			$isbn13 = sanitize_text_field( $post_data['isbn13'] ?? '' );
			$title  = sanitize_text_field( $post_data['title']  ?? '' );
			$dup    = $this->check_duplicate( $isbn13, $title );
			if ( $dup['found'] ) {
				wp_send_json_error( [
					'duplicate' => $dup,
					'message'   => 'A matching book already exists.',
				] );
			}
		}

		// Resolve Look Inside transient before building the book object.
		// The transient was stored in Phase 1 before the book post existed.
		// It is resolved here so build_book_object_from_epub() stays mapping-only.
		$look_inside_html   = '';
		$look_inside_choice = sanitize_text_field( $post_data['look_inside_choice'] ?? 'neither' );
		if ( 'neither' !== $look_inside_choice ) {
			$uid       = get_current_user_id();
			$trans_key = 'mbs_epub_li_' . $uid;
			$candidates = get_transient( $trans_key );
			if ( is_array( $candidates ) ) {
				foreach ( $candidates as $c ) {
					if ( ( $c['label'] ?? '' ) === $look_inside_choice && '' !== ( $c['html'] ?? '' ) ) {
						$look_inside_html = $c['html'];
						break;
					}
				}
				delete_transient( $trans_key );
			}
			// If the transient expired (more than one hour since Phase 1), Look Inside
			// is skipped silently. The user can add it manually via the Book Details metabox.
		}

		$notes       = [];
		$book_object = $this->build_book_object_from_epub( $post_data, $look_inside_html );
		// Duplicate detection was already performed above; skip the hydrator's check.
		$post_id = MBS_Book_Hydrator::hydrate( $book_object, array( 'skip_duplicate_check' => true ) );

		if ( is_wp_error( $post_id ) ) {
			wp_send_json_error( [ 'message' => 'Failed to create book: ' . $post_id->get_error_message() ] );
		}

		// Phase 6A gap: mbs_language is absent from the hydrator's meta whitelist.
		// Written directly until the hydrator whitelist is extended in a future session.
		$language = sanitize_text_field( $post_data['language'] ?? '' );
		if ( '' !== $language ) {
			update_post_meta( $post_id, 'mbs_language', $language );
		}

		$this->apply_cover( $post_id, $post_data, $notes );

		wp_send_json_success( [
			'edit_url' => get_edit_post_link( $post_id, 'raw' ),
			'notes'    => $notes,
		] );
	}

	// __ Private mapping helper ___________________________________________________

	/**
	 * Map the EPUB import POST payload to an MBS Book Object.
	 *
	 * Called by handle_ajax_mbs_epub_import() after the Look Inside transient has
	 * been resolved. Returns a complete MBS Book Object ready for
	 * MBS_Book_Hydrator::hydrate(). No DB writes performed here.
	 *
	 * Note: one DB read (MBS_Contributor_Book_DAO::get_role_options()) is made to
	 * populate role_label for each contributor row. This is acceptable because the
	 * EPUB importer is not under the zero-DB Rule 2 constraint that applies to the
	 * Catalog Mapper.
	 *
	 * Phase 6A analysis notes:
	 *   - mbs_language is not passed via this object because the hydrator's meta
	 *     whitelist does not include it; handled post-hydration in the caller.
	 *   - bisac_codes, thema_codes, bic_codes are JSON-encoded strings in the
	 *     AJAX payload and must be json_decoded before mapping (R4-M-004).
	 *   - contributors payload key 'onix' maps to hydrator key 'onix_role_code';
	 *     payload key 'name' maps to hydrator key 'contributor_name'.
	 *
	 * @param  array  $post_data        wp_unslash()'d $_POST payload.
	 * @param  string $look_inside_html Resolved Look Inside HTML, or empty string.
	 * @return array  MBS Book Object ready for MBS_Book_Hydrator::hydrate().
	 * @since  1.2.0
	 */
	private function build_book_object_from_epub( array $post_data, string $look_inside_html = '' ): array {
		// -- Scalar fields --------------------------------------------------------
		$title        = sanitize_text_field( $post_data['title']          ?? '' );
		$description  = sanitize_textarea_field( $post_data['description'] ?? '' );
		$pub_date     = sanitize_text_field( $post_data['pub_date']        ?? '' );
		$copyright_yr = sanitize_text_field( $post_data['copyright_year']  ?? '' );
		$lccn         = sanitize_text_field( $post_data['lccn']            ?? '' );
		$toc          = sanitize_textarea_field( $post_data['toc']         ?? '' );
		$publisher    = sanitize_text_field( $post_data['publisher']        ?? '' );
		$isbn13       = sanitize_text_field( $post_data['isbn13']           ?? '' );
		$page_count   = $post_data['page_count'] ?? '';

		// -- Classification codes (JSON-encoded strings per R4-M-004) -------------
		$bisac_raw = $post_data['bisac_codes'] ?? '';
		$bisac_arr = is_string( $bisac_raw ) ? json_decode( stripslashes( $bisac_raw ), true ) : array();
		$bisac_arr = is_array( $bisac_arr ) ? $bisac_arr : array();
		$bisac     = array_values( array_unique( array_filter( array_map(
			static function ( $c ) { return strtoupper( sanitize_text_field( (string) $c ) ); },
			$bisac_arr
		) ) ) );

		$thema_raw = $post_data['thema_codes'] ?? '';
		$thema_arr = is_string( $thema_raw ) ? json_decode( stripslashes( $thema_raw ), true ) : array();
		$thema_arr = is_array( $thema_arr ) ? $thema_arr : array();
		$thema     = array_values( array_unique( array_filter( array_map(
			static function ( $c ) { return strtoupper( sanitize_text_field( (string) $c ) ); },
			$thema_arr
		) ) ) );

		$bic_raw = $post_data['bic_codes'] ?? '';
		$bic_arr = is_string( $bic_raw ) ? json_decode( stripslashes( $bic_raw ), true ) : array();
		$bic_arr = is_array( $bic_arr ) ? $bic_arr : array();
		$bic     = array_values( array_unique( array_filter( array_map(
			static function ( $c ) { return strtoupper( sanitize_text_field( (string) $c ) ); },
			$bic_arr
		) ) ) );

		// -- Contributors (JSON-encoded in payload) --------------------------------
		$raw_contribs = $post_data['contributors'] ?? '';
		$contrib_list = is_string( $raw_contribs )
			? ( is_array( json_decode( stripslashes( $raw_contribs ), true ) )
				? json_decode( stripslashes( $raw_contribs ), true ) : array() )
			: ( is_array( $raw_contribs ) ? $raw_contribs : array() );

		$role_options = MBS_Contributor_Book_DAO::get_role_options();
		$contributors = array();
		$a01_names    = array();

		foreach ( $contrib_list as $idx => $c ) {
			$name = sanitize_text_field( $c['name'] ?? '' );
			$onix = sanitize_text_field( $c['onix'] ?? 'Z01' );
			if ( '' === $name ) {
				continue;
			}
			$contributors[] = array(
				'contributor_name' => $name,
				'onix_role_code'   => $onix,
				'role_label'       => $role_options[ $onix ] ?? 'Other',
				'sort_order'       => $idx,
				'public'           => 1,
			);
			if ( 'A01' === $onix ) {
				$a01_names[] = $name;
			}
		}

		// -- Post meta ------------------------------------------------------------
		$post_meta = array(
			'mbs_synopsis'       => $description,
			'mbs_pub_date'       => $pub_date,
			'mbs_copyright_year' => $copyright_yr,
			'mbs_lccn'           => $lccn,
			'mbs_toc'            => $toc,
			'mbs_publisher'      => $publisher,
		);
		if ( '' !== $look_inside_html ) {
			$post_meta['mbs_look_inside_text'] = $look_inside_html;
		}

		return array(
			'post_title'      => '' !== $title ? $title : __( '(Untitled EPUB Import)', 'my-book-showroom' ),
			'post_status'     => 'draft',
			'post_meta'       => $post_meta,
			'taxonomies'      => array(
				MBS_TAX_AUTHOR    => $a01_names,
				MBS_TAX_PUBLISHER => array( $publisher ),
			),
			'classifications' => array(
				'bisac' => $bisac,
				'thema' => $thema,
				'bic'   => $bic,
			),
			'format_rows' => array(
				array(
					'format'       => 'ebook',
					'format_label' => 'EPUB',
					'isbn13'       => $isbn13,
					'pub_date'     => $pub_date,
					'page_length'  => '' !== (string) $page_count ? (int) $page_count : 0,
					'buy_links'    => array(),
				),
			),
			'contributors'   => $contributors,
			'book_buy_links' => array(),
			'endorsements'   => array(),
			'awards'         => array(),
		);
	}

	// __ Private cover helpers ____________________________________________________

	/**
	 * Sideload the staged cover image and set it as the book featured image.
	 *
	 * @param  int   $post_id   Book post ID.
	 * @param  array $post_data Sanitized POST payload.
	 * @param  array $notes     Notes array (by reference).
	 * @return void
	 */
	private function apply_cover( int $post_id, array $post_data, array &$notes ): void {
		$key = sanitize_key( $post_data['cover_temp_key'] ?? '' );
		if ( '' === $key ) {
			return;
		}
		$files = glob( get_temp_dir() . 'mbs-temp-import/' . $key . '.*' );
		if ( empty( $files ) ) {
			$notes[] = 'Cover temp file not found; cover not set.';
			return;
		}
		$this->sideload_cover( $post_id, $files[0], $notes );
		// phpcs:ignore WordPress.PHP.NoSilencedErrors
		wp_delete_file( $files[0] );
	}

	/**
	 * Sideload a cover image temp file as a WordPress attachment and set it
	 * as the book post's featured image.
	 *
	 * Delegates to MBS_Import_Media_Sideloader::sideload() (R4-H-001: unified
	 * sideloader API; supersedes the pre-IEF sideload_from_local_file method).
	 *
	 * @param  int    $post_id    Book post ID.
	 * @param  string $file_path  Absolute path to the temp cover image file.
	 * @param  array  $notes      Notes array (passed by reference).
	 * @return void
	 * @since  1.2.0
	 */
	private function sideload_cover( int $post_id, string $file_path, array &$notes ): void {
		$result = MBS_Import_Media_Sideloader::sideload( $post_id, $file_path, 'cover' );
		if ( is_wp_error( $result ) ) {
			$notes[] = 'Cover sideload failed: ' . $result->get_error_message();
		}
	}

	// __ Private duplicate detection ______________________________________________

	/**
	 * Check for duplicate books by ISBN-13 (primary) and title (secondary).
	 *
	 * @param  string $isbn13 Normalized ISBN-13 string (digits only).
	 * @param  string $title  Book title.
	 * @return array  { found: bool, type: string, post_id: int, edit_url: string }
	 * @since  1.2.0
	 */
	private function check_duplicate( string $isbn13, string $title ): array {
		$not_found = [ 'found' => false, 'type' => '', 'post_id' => 0, 'edit_url' => '' ];

		// Check 1: ISBN-13 match in format rows table.
		if ( '' !== $isbn13 ) {
			$row = MBS_Format_Row_DAO::get_format_by_isbn( $isbn13 );
			if ( $row ) {
				$pid = (int) $row->post_id;
				return [
					'found'    => true,
					'type'     => 'isbn13',
					'post_id'  => $pid,
					'edit_url' => get_edit_post_link( $pid, 'raw' ),
				];
			}
		}

		// Check 2: Title match on mbs_book post type.
		if ( '' !== $title ) {
			$existing = get_posts( [
				'post_type'              => MBS_POST_TYPE,
				'post_status'            => [ 'publish', 'draft', 'pending', 'private', 'future' ],
				'title'                  => $title,
				'posts_per_page'         => 1,
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
			] );
			if ( ! empty( $existing ) ) {
				$pid = (int) $existing[0]->ID;
				return [
					'found'    => true,
					'type'     => 'title',
					'post_id'  => $pid,
					'edit_url' => get_edit_post_link( $pid, 'raw' ),
				];
			}
		}

		return $not_found;
	}
}
