<?php
/**
 * File Path: includes/import/class-mbs-import-media-sideloader.php
 *
 * MBS Import Media Sideloader
 *
 * Sideloads one media file — from a local filesystem path or a remote URL —
 * and writes the resulting WP attachment ID to the correct record. Supersedes
 * MBS_Cover_Sideloader. In Phase 4, MBS_Cover_Sideloader is refactored to
 * delegate to this class (Sprint Plan §Phase 4).
 *
 * Public API:
 *   sideload( int $post_id, string $source, string $media_type,
 *             ?int $format_row_id = null ): int|WP_Error
 *
 *   $source       — Absolute local filesystem path (from extracted ZIP temp
 *                   dir) or an http/https remote URL.
 *   $media_type   — One of: cover, banner, spinning_video, audio_sample,
 *                   trailer_video, headshot, term_image.
 *                   term_image: image MIME validation only; caller writes term_meta.
 *   $format_row_id — Required for per-format types (cover, spinning_video,
 *                    audio_sample). When non-null, MBS_Format_Row_DAO::update()
 *                    is called after sideloading to write the attachment ID to
 *                    the correct column (IEF-B-004). When null for spinning_video,
 *                    writes mbs_spinning_video_id postmeta (book-level fallback).
 *
 * Local file note (Q4 resolved):
 *   Local files are copied to a temp path before sideloading to avoid
 *   media_handle_sideload() deleting the source file (it unlinks its
 *   tmp_name on completion). No dimension enforcement is applied for local
 *   files — the copy-then-sideload bypass is intentional.
 *
 * Remote URL note:
 *   download_url() is used to download to a temp file, then
 *   media_handle_sideload() sideloads into the Media Library.
 *
 * @package    My_Book_Showroom
 * @since      1.0.0
 */

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

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

	/**
	 * Map of media_type → format_row column name.
	 * Only per-format types have an entry here.
	 */
	const FORMAT_ROW_COLUMNS = array(
		'cover'         => 'cover_image_id',
		'spinning_video' => 'spinning_video_id',
		'audio_sample'  => 'audio_sample_id',
	);

	/**
	 * MIME types accepted for image media types (cover, banner, headshot).
	 */
	const ALLOWED_IMAGE_MIMES = array( 'image/jpeg', 'image/png', 'image/webp' );

	/**
	 * MIME types accepted for video media types (spinning_video, trailer_video).
	 */
	const ALLOWED_VIDEO_MIMES = array( 'video/mp4', 'video/webm', 'video/ogg' );

	/**
	 * MIME types accepted for audio media types (audio_sample).
	 */
	const ALLOWED_AUDIO_MIMES = array( 'audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/mp4' );

	/**
	 * Sideload one media file and write the attachment ID to the correct record.
	 *
	 * @param int      $post_id       Book post ID.
	 * @param string   $source        Absolute local path or http/https URL.
	 * @param string   $media_type    One of: cover, banner, spinning_video,
	 *                                audio_sample, trailer_video, headshot.
	 * @param int|null $format_row_id Required for per-format types; null for
	 *                                book-level types.
	 * @return int|WP_Error Attachment ID on success, WP_Error on failure.
	 */
	public static function sideload(
		int $post_id,
		string $source,
		string $media_type,
		?int $format_row_id = null
	): int|\WP_Error {

		// Load WordPress media helpers (safe to call multiple times).
		require_once ABSPATH . 'wp-admin/includes/media.php';
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';

		// Route to the correct sideload strategy.
		if ( self::is_local_path( $source ) ) {
			$attachment_id = self::sideload_local( $post_id, $source, $media_type );
		} else {
			$attachment_id = self::sideload_url( $post_id, $source, $media_type );
		}

		if ( is_wp_error( $attachment_id ) ) {
			return $attachment_id;
		}

		// Write the attachment ID to the correct record.
		self::write_attachment_id( $post_id, $attachment_id, $media_type, $format_row_id );

		return $attachment_id;
	}

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

	/**
	 * Determine whether $source is a local filesystem path.
	 *
	 * Returns true when $source does not begin with http:// or https://.
	 *
	 * @param string $source
	 * @return bool
	 */
	private static function is_local_path( string $source ): bool {
		$scheme = wp_parse_url( $source, PHP_URL_SCHEME );
		return ! in_array( $scheme, array( 'http', 'https' ), true );
	}

	/**
	 * Sideload from a local filesystem path.
	 *
	 * Copies the file to a temp location before handing to
	 * media_handle_sideload() so the source ZIP extraction file is not
	 * deleted (Q4 copy-then-sideload bypass).
	 *
	 * @param int    $post_id
	 * @param string $file_path
	 * @param string $media_type
	 * @return int|WP_Error Attachment ID or error.
	 */
	private static function sideload_local( int $post_id, string $file_path, string $media_type ): int|\WP_Error {
		if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) {
			return new \WP_Error(
				'mbs_media_not_found',
				sprintf( 'Media file not found or not readable: %s', esc_html( $file_path ) )
			);
		}

		// Copy to a temp file so media_handle_sideload() does not unlink the source.
		$tmp = wp_tempnam( basename( $file_path ) );
		if ( ! $tmp || ! copy( $file_path, $tmp ) ) {
			return new \WP_Error( 'mbs_media_copy_failed', 'Failed to copy media file to temp location.' );
		}

		$filename   = sanitize_file_name( basename( $file_path ) );
		$file_array = array(
			'name'     => $filename,
			'tmp_name' => $tmp,
		);

		$attachment_id = media_handle_sideload( $file_array, $post_id );

		if ( is_wp_error( $attachment_id ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors -- tmp cleanup; error not actionable
			wp_delete_file( $tmp );
			return $attachment_id;
		}

		// Validate MIME type for the given media_type.
		$mime_error = self::validate_mime( $attachment_id, $media_type );
		if ( $mime_error ) {
			wp_delete_attachment( $attachment_id, true );
			return $mime_error;
		}

		return $attachment_id;
	}

	/**
	 * Sideload from a remote http/https URL.
	 *
	 * @param int    $post_id
	 * @param string $url
	 * @param string $media_type
	 * @return int|WP_Error Attachment ID or error.
	 */
	private static function sideload_url( int $post_id, string $url, string $media_type ): int|\WP_Error {
		// Validate scheme — http/https only.
		$scheme = wp_parse_url( $url, PHP_URL_SCHEME );
		if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
			return new \WP_Error(
				'mbs_media_bad_scheme',
				'Invalid URL scheme — only http/https are accepted.'
			);
		}

		// Explicit SSRF guard — reject URLs that fail WP HTTP validation.
		if ( ! wp_http_validate_url( $url ) ) {
			return new \WP_Error( 'mbs_media_bad_url', 'URL failed HTTP validation.' );
		}

		// Download to a local temp file.
		$tmp = download_url( $url, 30 );
		if ( is_wp_error( $tmp ) ) {
			return new \WP_Error(
				'mbs_media_download_failed',
				'Download failed: ' . $tmp->get_error_message()
			);
		}

		// Derive a safe filename from the URL path.
		$url_path  = wp_parse_url( $url, PHP_URL_PATH ) ?: '';
		$filename  = sanitize_file_name( basename( $url_path ) ?: 'media-file' );

		// Ensure a recognized extension.
		$ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
		if ( ! in_array( $ext, array( 'jpg', 'jpeg', 'png', 'webp', 'mp4', 'webm', 'ogg', 'mp3', 'wav', 'aac', 'm4a' ), true ) ) {
			$filename .= '.jpg';
		}

		$file_array = array(
			'name'     => $filename,
			'tmp_name' => $tmp,
		);

		$attachment_id = media_handle_sideload( $file_array, $post_id );

		if ( is_wp_error( $attachment_id ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors -- tmp cleanup; error not actionable
			wp_delete_file( $tmp );
			return $attachment_id;
		}

		// Validate MIME type for the given media_type.
		$mime_error = self::validate_mime( $attachment_id, $media_type );
		if ( $mime_error ) {
			wp_delete_attachment( $attachment_id, true );
			return $mime_error;
		}

		return $attachment_id;
	}

	/**
	 * Validate an attachment's MIME type against the allowed list for $media_type.
	 *
	 * @param int    $attachment_id
	 * @param string $media_type
	 * @return WP_Error|null WP_Error on validation failure, null if valid.
	 */
	private static function validate_mime( int $attachment_id, string $media_type ): ?\WP_Error {
		$mime    = get_post_mime_type( $attachment_id );
		$allowed = self::allowed_mimes_for( $media_type );

		if ( ! empty( $allowed ) && ! in_array( $mime, $allowed, true ) ) {
			return new \WP_Error(
				'mbs_media_bad_mime',
				sprintf(
					'Rejected MIME type "%s" for media_type "%s".',
					esc_html( $mime ),
					esc_html( $media_type )
				)
			);
		}

		return null;
	}

	/**
	 * Return the allowed MIME list for a given media_type.
	 *
	 * Returns an empty array for unknown types (no MIME validation applied).
	 *
	 * @param string $media_type
	 * @return string[]
	 */
	private static function allowed_mimes_for( string $media_type ): array {
		switch ( $media_type ) {
			case 'cover':
			case 'banner':
			case 'headshot':
			case 'term_image':  // Term banner or author headshot; caller writes term_meta.
				return self::ALLOWED_IMAGE_MIMES;
			case 'spinning_video':
			case 'trailer_video':
				return self::ALLOWED_VIDEO_MIMES;
			case 'audio_sample':
				return self::ALLOWED_AUDIO_MIMES;
			case 'downloadable_file':
				// Book club downloadable files: broad document/file MIME list.
				// No write_attachment_id() case needed -- caller (sideload_downloadable_files)
				// writes attachment IDs directly to MBS_Book_Club_DAO::replace_files().
				return array(
					'application/pdf',
					'application/msword',
					'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
					'application/vnd.ms-excel',
					'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
					'application/vnd.ms-powerpoint',
					'application/vnd.openxmlformats-officedocument.presentationml.presentation',
					'text/plain',
					'application/rtf',
					'application/epub+zip',
				);
			default:
				return array();
		}
	}

	/**
	 * Write the attachment ID to the correct WordPress record.
	 *
	 * Routing:
	 *   cover, spinning_video, audio_sample + format_row_id non-null
	 *     → MBS_Format_Row_DAO::update( $format_row_id, array( $column => $id ) )
	 *   cover + format_row_id null
	 *     → set_post_thumbnail( $post_id, $attachment_id )
	 *   banner
	 *     → update_post_meta( $post_id, 'mbs_banner_image_id', $attachment_id )
	 *   trailer_video
	 *     → update_post_meta( $post_id, 'mbs_trailer_video_id', $attachment_id )
	 *   headshot
	 *     → attached to post only; term association is handled by the caller.
	 *
	 * @param int      $post_id
	 * @param int      $attachment_id
	 * @param string   $media_type
	 * @param int|null $format_row_id
	 */
	private static function write_attachment_id(
		int $post_id,
		int $attachment_id,
		string $media_type,
		?int $format_row_id
	): void {
		// Per-format types with a known format_row column.
		if ( isset( self::FORMAT_ROW_COLUMNS[ $media_type ] ) && $format_row_id > 0 ) {
			$column = self::FORMAT_ROW_COLUMNS[ $media_type ];
			MBS_Format_Row_DAO::update( $format_row_id, array( $column => $attachment_id ) );
			return;
		}

		// Cover with no format_row_id — set as featured image.
		if ( 'cover' === $media_type ) {
			set_post_thumbnail( $post_id, $attachment_id );
			return;
		}

		// Book-level media types stored as postmeta.
		switch ( $media_type ) {
			case 'banner':
				update_post_meta( $post_id, 'mbs_banner_image_id', $attachment_id );
				break;
			case 'trailer_video':
				update_post_meta( $post_id, 'mbs_trailer_video_id', $attachment_id );
				break;
			case 'spinning_video':
				// Book-level spinning video fallback (null format_row_id path).
				// Writes to mbs_spinning_video_id postmeta so the cover renderer's
				// fallback path (class-mbs-cover-strategy-video.php) resolves it.
				update_post_meta( $post_id, 'mbs_spinning_video_id', $attachment_id );
				break;
			case 'headshot':
				// Headshot is attached to the post; term association is the caller's
				// responsibility (the caller knows which mbs_author term_id to update).
				break;
		}
	}
}
