<?php
/**
 * File Path: includes/import/class-mbs-zip-extractor.php
 *
 * ZIP Extractor for Import
 *
 * Safely unpacks a user-uploaded ZIP file to find a single CSV or XLSX data
 * file and optional cover image files. Designed to feed the existing CSV/XLSX
 * import pipeline without any changes to the upstream column-mapping UI.
 *
 * Security measures:
 *   - Path traversal: entries whose resolved path escapes $extract_dir are skipped.
 *   - MIME gate: only .csv, .xlsx, and media file extensions
 *     (.jpg/.jpeg/.png/.webp -- covers; .mp4/.mov/.m4v -- trailers;
 *     .mp3/.m4a/.aac -- audio samples) are extracted. All other files
 *     are silently ignored.
 *   - Total extracted size cap (MAX_EXTRACTED_BYTES) guards against ZIP bombs.
 *   - Uses ZipArchive::extractTo() per-entry (not a bulk extract) so each
 *     entry's destination path is validated before writing.
 *
 * Rules (from Task #22 spec):
 *   - Exactly one data file (CSV or XLSX) must be present in the ZIP.
 *   - If zero data files → WP_Error 'no_data_file'
 *   - If more than one data file → WP_Error 'multiple_data_files' with the
 *     exact message from the spec.
 *   - Image files are copied to an "images" subdirectory inside $extract_dir
 *     so insert_book() can resolve cover_image_file references.
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */

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

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

	/** Allowed data file extensions (the import pipeline accepts these). */
	const DATA_EXTENSIONS = array( 'csv', 'xlsx' );

	/** Allowed media file extensions for bundled covers, banners, trailers, and audio. */
	const IMAGE_EXTENSIONS = array( 'jpg', 'jpeg', 'png', 'webp', 'mp4', 'mov', 'm4v', 'mp3', 'm4a', 'aac' );

	/** Allowed file extensions for downloadable files bundled in the files/ subfolder. */
	const FILE_EXTENSIONS = array( 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf', 'epub' );

	/** Maximum total uncompressed size we will extract (200 MB). */
	const MAX_EXTRACTED_BYTES = 209715200;

	// ── Known MBS catalog ZIP subfolders ────────────────────────────────────

	/**
	 * Subfolders allowed in an MBS catalog ZIP (created by MBS_Exporter_Zip).
	 * Files outside these folders (except catalog.xlsx at root) are ignored.
	 *
	 * @var string[]
	 */
	const CATALOG_MEDIA_DIRS = array(
		'covers',
		'banners',
		'trailers',
		'spinning-videos',
		'audio',
		'author-headshots',
		'files',
	);

	// ── Public API ────────────────────────────────────────────────────────────

	/**
	 * Extract an MBS catalog ZIP (produced by MBS_Exporter_Zip::download()).
	 *
	 * Looks for catalog.xlsx at the ZIP root and extracts it plus all media
	 * files from the seven known subfolders (covers/, banners/, trailers/,
	 * spinning-videos/, audio/, author-headshots/, files/) to mirrored subdirectories
	 * inside $extract_dir. Files outside those locations are silently skipped.
	 *
	 * @param string $zip_path    Absolute path to the uploaded ZIP file.
	 * @param string $extract_dir Absolute path to the destination directory
	 *                            (must already exist).
	 * @return array|WP_Error On success:
	 *   - data_file   (string) Absolute path to extracted catalog.xlsx.
	 *   - media_root  (string) Absolute path to $extract_dir (subfolders inside).
	 *   - image_count (int)    Number of media files extracted.
	 *   On failure: WP_Error with code 'no_catalog_file', 'zip_open',
	 *                'zip_too_large', or 'path_traversal'.
	 */
	public static function extract_catalog( string $zip_path, string $extract_dir ): array|WP_Error {
		if ( ! class_exists( 'ZipArchive' ) ) {
			return new WP_Error( 'no_zip', 'PHP ZipArchive extension is required.' );
		}

		// Fail fast if the destination directory does not exist — path_is_safe() would
		// return false for every entry (realpath() on a non-existent dir returns false).
		if ( ! is_dir( $extract_dir ) ) {
			return new WP_Error( 'dir_missing', 'Extraction directory does not exist: ' . $extract_dir );
		}

		$zip = new ZipArchive();
		if ( true !== $zip->open( $zip_path ) ) {
			return new WP_Error( 'zip_open', 'Could not open ZIP file (invalid or corrupted).' );
		}

		$extract_dir  = rtrim( $extract_dir, '/\\' ) . DIRECTORY_SEPARATOR;
		$catalog_entry = false; // ZIP entry name for catalog.xlsx.
		$media_entries = array();
		$total_bytes   = 0;

		// ── Pass 1: catalog entries ─────────────────────────────────────────
		for ( $i = 0; $i < $zip->numFiles; $i++ ) {
			$stat = $zip->statIndex( $i );
			if ( false === $stat ) { continue; }

			$name = $stat['name'];

			// Skip directories and hidden/system files.
			if ( str_ends_with( $name, '/' ) || str_starts_with( basename( $name ), '.' ) ) {
				continue;
			}

			// ZIP bomb protection.
			$total_bytes += (int) $stat['size'];
			if ( $total_bytes > self::MAX_EXTRACTED_BYTES ) {
				$zip->close();
				return new WP_Error( 'zip_too_large', 'ZIP contents exceed the 200 MB extraction limit.' );
			}

			// catalog.xlsx must be at root (no directory component).
			if ( 'catalog.xlsx' === $name ) {
				$catalog_entry = $name;
				continue;
			}

			// Media files: must be inside one of the six known subfolders.
			$parts  = explode( '/', $name, 2 );
			$folder = $parts[0];
			$file   = $parts[1] ?? '';
			if ( '' === $file || ! in_array( $folder, self::CATALOG_MEDIA_DIRS, true ) ) {
				continue; // Outside known subfolders -- silently skip.
			}

			$ext     = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) );
			$allowed = ( 'files' === $folder ) ? self::FILE_EXTENSIONS : self::IMAGE_EXTENSIONS;
			if ( in_array( $ext, $allowed, true ) ) {
				$media_entries[] = array( 'entry' => $name, 'folder' => $folder, 'file' => $file );
			}
		}

		// ── Validate: catalog.xlsx must be present ────────────────────────────
		if ( false === $catalog_entry ) {
			$zip->close();
			return new WP_Error( 'no_catalog_file', 'ZIP does not contain catalog.xlsx at root. Upload a ZIP exported by My Book Showroom.' );
		}

		// ── Extract catalog.xlsx ─────────────────────────────────────────────
		$catalog_dest = $extract_dir . 'catalog.xlsx';
		if ( ! self::path_is_safe( $catalog_dest, $extract_dir ) ) {
			$zip->close();
			return new WP_Error( 'path_traversal', 'Unsafe path detected for catalog.xlsx.' );
		}
		$content = $zip->getFromName( $catalog_entry );
		if ( false === $content ) {
			$zip->close();
			return new WP_Error( 'zip_read', 'Could not read catalog.xlsx from ZIP.' );
		}
		if ( ! self::fs_put_contents( $catalog_dest, $content ) ) {
			$zip->close();
			return new WP_Error( 'file_write', 'Could not write catalog.xlsx to disk.' );
		}

		// ── Extract media files preserving one-level subfolder structure ─────
		$image_count = 0;
		foreach ( $media_entries as $m ) {
			$sub_dir  = $extract_dir . $m['folder'] . DIRECTORY_SEPARATOR;
			$img_dest = $sub_dir . self::safe_basename( $m['file'] );

			if ( ! is_dir( $sub_dir ) ) {
				wp_mkdir_p( $sub_dir );
			}
			if ( ! self::path_is_safe( $img_dest, $sub_dir ) ) {
				continue;
			}

			$img_content = $zip->getFromName( $m['entry'] );
			if ( false === $img_content ) {
				continue;
			}
			if ( self::fs_put_contents( $img_dest, $img_content ) ) {
				$image_count++;
			}
		}

		$zip->close();

		return array(
			'data_file'   => $catalog_dest,
			'media_root'  => $extract_dir,
			'image_count' => $image_count,
		);
	}


	/**
	 * Extract a ZIP file, locate the single data file, and copy image files.
	 *
	 * @param string $zip_path    Absolute path to the uploaded ZIP file.
	 * @param string $extract_dir Absolute path to the destination directory
	 *                            (must already exist).
	 * @return array|WP_Error On success, an array with keys:
	 *   - data_file  (string)  Absolute path to the extracted CSV or XLSX file.
	 *   - images_dir (string)  Absolute path to the images subdirectory
	 *                          (empty string if no images found).
	 *   - image_count (int)    Number of image files extracted.
	 */
	public static function extract( string $zip_path, string $extract_dir ): array|WP_Error {
		if ( ! class_exists( 'ZipArchive' ) ) {
			return new WP_Error( 'no_zip', 'PHP ZipArchive extension is required to extract ZIP files.' );
		}

		$zip = new ZipArchive();
		if ( true !== $zip->open( $zip_path ) ) {
			return new WP_Error( 'zip_open', 'Could not open ZIP file (invalid or corrupted).' );
		}

		$extract_dir = rtrim( $extract_dir, '/\\' ) . DIRECTORY_SEPARATOR;
		$images_dir  = $extract_dir . 'images' . DIRECTORY_SEPARATOR;

		$data_entries  = array(); // ZIP entry names that are data files.
		$image_entries = array(); // ZIP entry names that are image files.
		$total_bytes   = 0;

		// ── Pass 1: catalog entries ─────────────────────────────────────────
		for ( $i = 0; $i < $zip->numFiles; $i++ ) {
			$stat = $zip->statIndex( $i );
			if ( false === $stat ) {
				continue;
			}

			$name = $stat['name'];

			// Skip directories and hidden/system files.
			if ( str_ends_with( $name, '/' ) || str_starts_with( basename( $name ), '.' ) ) {
				continue;
			}

			// Guard against ZIP bombs.
			$total_bytes += (int) $stat['size'];
			if ( $total_bytes > self::MAX_EXTRACTED_BYTES ) {
				$zip->close();
				return new WP_Error( 'zip_too_large', 'ZIP contents exceed the 200 MB extraction limit.' );
			}

			$ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );

			if ( in_array( $ext, self::DATA_EXTENSIONS, true ) ) {
				$data_entries[] = $name;
			} elseif ( in_array( $ext, self::IMAGE_EXTENSIONS, true ) ) {
				$image_entries[] = $name;
			}
			// All other files are silently ignored.
		}

		// ── Validate data file count ──────────────────────────────────────────
		if ( empty( $data_entries ) ) {
			$zip->close();
			return new WP_Error( 'no_data_file', 'ZIP contains no CSV or XLSX file.' );
		}

		if ( count( $data_entries ) > 1 ) {
			$zip->close();
			return new WP_Error(
				'multiple_data_files',
				'ZIP contains more than one data file — please include exactly one CSV or XLSX.'
			);
		}

		// ── Pass 2: extract data file ─────────────────────────────────────────
		$data_entry    = $data_entries[0];
		$safe_filename = self::safe_basename( $data_entry );
		$dest_path     = $extract_dir . $safe_filename;

		if ( ! self::path_is_safe( $dest_path, $extract_dir ) ) {
			$zip->close();
			return new WP_Error( 'path_traversal', 'Unsafe file path detected in ZIP entry.' );
		}

		$content = $zip->getFromName( $data_entry );
		if ( false === $content ) {
			$zip->close();
			return new WP_Error( 'zip_read', 'Could not read data file from ZIP.' );
		}

		if ( ! self::fs_put_contents( $dest_path, $content ) ) {
			$zip->close();
			return new WP_Error( 'file_write', 'Could not write extracted data file to disk.' );
		}

		// ── Pass 3: extract image files ───────────────────────────────────────
		$image_count = 0;

		if ( ! empty( $image_entries ) ) {
			if ( ! is_dir( $images_dir ) ) {
				wp_mkdir_p( $images_dir );
			}

			foreach ( $image_entries as $entry ) {
				$img_filename = self::safe_basename( $entry );
				$img_dest     = $images_dir . $img_filename;

				if ( ! self::path_is_safe( $img_dest, $images_dir ) ) {
					continue; // Skip unsafe paths silently.
				}

				$img_content = $zip->getFromName( $entry );
				if ( false === $img_content ) {
					continue;
				}

				if ( self::fs_put_contents( $img_dest, $img_content ) ) {
					$image_count++;
				}
			}
		}

		$zip->close();

		return array(
			'data_file'   => $dest_path,
			'images_dir'  => $image_count > 0 ? $images_dir : '',
			'image_count' => $image_count,
		);
	}

	// ── Private helpers ───────────────────────────────────────────────────────

	/**
	 * Sanitize a ZIP entry name down to a safe, flat filename.
	 *
	 * Strips directory components and sanitizes the bare filename.
	 *
	 * @param string $entry ZIP entry name.
	 * @return string       Safe filename (basename only, sanitized).
	 */
	private static function safe_basename( string $entry ): string {
		return sanitize_file_name( basename( $entry ) );
	}

	/**
	 * Verify that a resolved destination path stays within the allowed directory.
	 *
	 * Guards against path traversal: if an entry name contains "../" sequences
	 * its resolved path would escape $allowed_dir.
	 *
	 * @param string $path        Resolved absolute path.
	 * @param string $allowed_dir Directory the path must be within (with trailing slash).
	 * @return bool               True if safe, false otherwise.
	 */
	private static function path_is_safe( string $path, string $allowed_dir ): bool {
		$real_dir = realpath( rtrim( $allowed_dir, '/\\' ) );
		if ( false === $real_dir ) {
			return false;
		}
		// realpath() requires the file to exist; we check the parent directory.
		$parent = realpath( dirname( $path ) );
		if ( false === $parent ) {
			return false;
		}
		return str_starts_with( $parent . DIRECTORY_SEPARATOR, $real_dir . DIRECTORY_SEPARATOR );
	}
	/**
	 * Write binary content to a file, using WP_Filesystem when available.
	 *
	 * Returns true on success, false on failure -- matches WP_Filesystem semantics.
	 * Falls back to file_put_contents() when WP_Filesystem is not initialized
	 * (e.g. during FTP/SSH credential flows or CLI context).
	 *
	 * @param string $path    Absolute destination path.
	 * @param string $content Binary content to write.
	 * @return bool
	 */
	private static function fs_put_contents( string $path, string $content ): bool {
		global $wp_filesystem;
		if ( ! empty( $wp_filesystem ) && is_object( $wp_filesystem ) ) {
			return (bool) $wp_filesystem->put_contents( $path, $content, FS_CHMOD_FILE );
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- fallback when WP_Filesystem unavailable
		return false !== file_put_contents( $path, $content );
	}

}
