<?php
/**
 * File Path: includes/import/class-mbs-cover-sideloader.php
 *
 * Cover Image Sideloader
 *
 * Processes books that have a pending remote cover URL stored in the
 * mbs_import_cover_url postmeta key. Downloads each URL, validates the
 * image, sideloads it into the WordPress Media Library, and sets it as
 * the post thumbnail.
 *
 * Called as a deferred second AJAX pass after CSV / API imports complete,
 * so the main import result is never blocked by slow image downloads.
 *
 * Dimension rules (COVERS-VALIDATION, 2026-05-01):
 *
 *   Hard floor (all formats):
 *     Width OR height below MIN_HARD_FLOOR px -> red error, image discarded.
 *
 *   Audio mode (format_type slug contains "audio"):
 *     Ratio not within AUDIO_SQUARE_TOLERANCE of 1:1 -> yellow warning, image kept.
 *
 *   Variable mode (boardbook / special / other):
 *     Hard floor only — no orientation or size warnings.
 *
 *   Portrait mode (all other formats, default):
 *     Hard floor only — image accepted if both dimensions >= MIN_HARD_FLOOR.
 *
 *   MIME type not in ALLOWED_MIME_TYPES            -> red error, image discarded.
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */

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

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

	/** Postmeta key that holds the pending remote cover URL. */
	const PENDING_META_KEY = 'mbs_import_cover_url';

	/**
	 * Hard floor: reject any cover where either dimension is below this value.
	 * Applies to every format mode.
	 */
	const MIN_HARD_FLOOR = 500;

	/**
	 * Audio mode: maximum allowed deviation from a 1:1 square ratio.
	 * A ratio within this tolerance is treated as square.
	 */
	const AUDIO_SQUARE_TOLERANCE = 0.05;

	/**
	 * Format slugs treated as variable mode (no orientation check, hard floor only).
	 * These formats legitimately use non-portrait aspect ratios.
	 */
	const VARIABLE_FORMAT_SLUGS = array( 'boardbook', 'special', 'other' );

	/** Maximum covers to process in a single AJAX pass (timeout guard). */
	const MAX_PER_PASS = 20;

	/** Only these MIME types are accepted as cover images. */
	const ALLOWED_MIME_TYPES = array( 'image/jpeg', 'image/png', 'image/webp' );

	/**
	 * Process pending cover URLs for all books that have mbs_import_cover_url set.
	 *
	 * Queries up to MAX_PER_PASS posts so that a single call never times out
	 * on large catalogs. The JS caller checks the `remaining` count in the
	 * response and, if non-zero, calls again — simple polling loop.
	 *
	 * Includes draft, future, private, and pending posts so that covers are
	 * resolved regardless of publication state.
	 *
	 * @param int $limit Maximum number of covers to process. Defaults to MAX_PER_PASS.
	 * @return array{
	 *   processed: int,
	 *   succeeded: int,
	 *   warnings:  array,
	 *   errors:    array,
	 *   remaining: int
	 * }
	 */
	public static function process_pending( int $limit = self::MAX_PER_PASS ): array {
		$result = array(
			'processed' => 0,
			'succeeded' => 0,
			'warnings'  => array(),
			'errors'    => array(),
			'remaining' => 0,
		);

		$statuses = array( 'publish', 'draft', 'future', 'private', 'pending' );

		$post_ids = get_posts( array(
			'post_type'      => MBS_POST_TYPE,
			'post_status'    => $statuses,
			'posts_per_page' => $limit,
			'meta_key'       => self::PENDING_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- standard WP query pattern; VIP rule does not apply to this plugin
			'fields'         => 'ids',
		) );

		foreach ( $post_ids as $post_id ) {
			$url = get_post_meta( $post_id, self::PENDING_META_KEY, true );
			if ( empty( $url ) ) {
				continue;
			}

			$title = get_the_title( $post_id );

			$sideload = self::sideload_from_url( $post_id, (string) $url );

			$result['processed']++;

			if ( $sideload['success'] ) {
				$result['succeeded']++;
				if ( $sideload['warning'] ) {
					$result['warnings'][] = esc_html( $title ) . ': ' . $sideload['warning'];
				}
			} else {
				$result['errors'][] = esc_html( $title ) . ': ' . $sideload['error'];
			}
		}

		// Count how many still remain after this pass.
		$result['remaining'] = (int) count( get_posts( array(
			'post_type'      => MBS_POST_TYPE,
			'post_status'    => $statuses,
			'posts_per_page' => -1,
			'meta_key'       => self::PENDING_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- standard WP query pattern; VIP rule does not apply to this plugin
			'fields'         => 'ids',
		) ) );

		return $result;
	}

	/**
	 * Sideload a single cover image from a local file path (ZIP import).
	 *
	 * Used when a ZIP bundle provides cover images alongside the data file.
	 * The local file is passed directly to media_handle_sideload() so no
	 * download step is needed. The same MIME and dimension checks that apply
	 * to URL sideloads are performed here.
	 *
	 * Unlike sideload_from_url(), this method does NOT use postmeta for
	 * coordination — the call is synchronous inside insert_book().
	 *
	 * @param int    $post_id     Book post ID to attach the image to.
	 * @param string $file_path   Absolute local filesystem path to the image.
	 * @param string $format_type Format type key (e.g. 'audiobook', 'paperback').
	 *                            Used to select the correct dimension-validation mode.
	 *                            Pass '' (default) for portrait mode.
	 * @return array{success: bool, warning: string|null, error: string|null}
	 */
	public static function sideload_from_local_file( int $post_id, string $file_path, string $format_type = '' ): array {
		// Phase 4: delegate to MBS_Import_Media_Sideloader (Sprint Plan §Phase 4).
		// No new sideload logic may live in this method.
		$result = MBS_Import_Media_Sideloader::sideload( $post_id, $file_path, 'cover' );

		if ( is_wp_error( $result ) ) {
			return array( 'success' => false, 'warning' => null, 'error' => $result->get_error_message() );
		}

		return array( 'success' => true, 'warning' => null, 'error' => null );
	}

	/**
	 * Sideload a single cover image from a remote URL.
	 *
	 * On success the image is attached to $post_id and set as its featured
	 * image. The mbs_import_cover_url postmeta is deleted so the book is not
	 * processed again on subsequent passes.
	 *
	 * On any error the postmeta is also deleted to prevent infinite retry loops
	 * — the error is surfaced to the admin via the result panel.
	 *
	 * @param int    $post_id     Book post ID to attach the image to.
	 * @param string $url         Absolute URL of the remote image.
	 * @param string $format_type Format type key (e.g. 'audiobook', 'paperback').
	 *                            Used to select the correct dimension-validation mode.
	 *                            Pass '' (default) for portrait mode.
	 * @return array{success: bool, warning: string|null, error: string|null}
	 */
	public static function sideload_from_url( int $post_id, string $url, string $format_type = '' ): array {
		// PLG-NEW-003: Reject non-http(s) URL schemes before any filesystem or
		// network operation to prevent file_exists() on ftp://, data://, etc.
		$scheme = wp_parse_url( $url, PHP_URL_SCHEME );
		if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
			delete_post_meta( $post_id, self::PENDING_META_KEY );
			return array(
				'success' => false,
				'warning' => null,
				'error'   => sprintf(
					'Cover URL rejected: unsupported scheme "%s". Only http and https are allowed.',
					(string) $scheme
				),
			);
		}

		// Phase 4: delegate to MBS_Import_Media_Sideloader (Sprint Plan §Phase 4).
		// No new sideload logic may live in this method.
		$result = MBS_Import_Media_Sideloader::sideload( $post_id, $url, 'cover' );

		if ( is_wp_error( $result ) ) {
			delete_post_meta( $post_id, self::PENDING_META_KEY );
			return array( 'success' => false, 'warning' => null, 'error' => $result->get_error_message() );
		}

		// Clear the pending URL so this book is not processed again.
		delete_post_meta( $post_id, self::PENDING_META_KEY );

		return array( 'success' => true, 'warning' => null, 'error' => null );
	}

	/**
	 * Validate cover image dimensions against format-aware rules.
	 *
	 * Three modes are selected based on $format_type:
	 *
	 *   Audio mode   — slug contains "audio": square-ratio check only.
	 *   Variable mode — slug in VARIABLE_FORMAT_SLUGS: hard floor only.
	 *   Portrait mode — all other slugs (default): hard floor only.
	 *
	 * The hard floor (MIN_HARD_FLOOR) is always checked first and results in
	 * a rejection (not a warning) regardless of mode.
	 *
	 * @param int    $width       Image width in pixels.
	 * @param int    $height      Image height in pixels.
	 * @param string $format_type Format type key from the format repeater row.
	 * @return array{ reject: string|false, warning: string|null }
	 *           reject  — non-empty error string if the image should be discarded,
	 *                     false if it should be kept.
	 *           warning — non-null warning string if a soft issue was detected,
	 *                     null if the image is clean.
	 */
	private static function validate_dimensions( int $width, int $height, string $format_type ): array {
		$clean = array( 'reject' => false, 'warning' => null );
		$slug  = strtolower( trim( $format_type ) );

		// Step 1: Hard floor — applies to every mode.
		if ( $width < self::MIN_HARD_FLOOR || $height < self::MIN_HARD_FLOOR ) {
			return array(
				'reject'  => sprintf(
					/* translators: 1: width, 2: height, 3: minimum pixels */
					'Cover rejected: image is too small (%1$d×%2$d px). Both dimensions must be at least %3$d px.',
					$width,
					$height,
					self::MIN_HARD_FLOOR
				),
				'warning' => null,
			);
		}

		// Step 2: Select mode.

		// Audio mode: format slug contains "audio".
		if ( false !== strpos( $slug, 'audio' ) ) {
			// Ratio check — audiobook covers must be square (within tolerance).
			$ratio = ( $height > 0 ) ? ( $width / $height ) : 0;
			if ( abs( $ratio - 1.0 ) > self::AUDIO_SQUARE_TOLERANCE ) {
				return array(
					'reject'  => false,
					'warning' => sprintf(
						/* translators: 1: width, 2: height */
						'Audiobook cover is not square (%1$d×%2$d px) — distributors require a 1:1 aspect ratio.',
						$width,
						$height
					),
				);
			}

			return $clean;
		}

		// Variable mode: boardbook, special, other — no orientation/size warnings.
		if ( in_array( $slug, self::VARIABLE_FORMAT_SLUGS, true ) ) {
			return $clean;
		}

		// Portrait mode (default): ebook, paperback, hardcover, and any unknown slug.
		// Hard floor already applied above — accept silently.
		return $clean;
	}
}
