<?php
/**
 * Bundle preparation and uploaded backup ingestion helpers for BackupService.
 *
 * @package BackupRestore
 */

namespace BackupRestore\Includes;

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

trait BackupBundleImportTrait {
	private function bundle_cache_basename(): string {
		return '.wpps-br-bundle-ready.zip';
	}

	/**
	 * In-progress bundle zip while tick packing runs (promoted to the ready cache when complete).
	 */
	private function bundle_building_basename(): string {
		return '.wpps-br-bundle-building.zip';
	}

	/**
	 * Backup parts (+ manifest.json) that belong in the full-ZIP bundle.
	 *
	 * @return array{
	 *   queue: list<array{abs: string, entry: string}>,
	 *   parts: list<array{key: string, name: string, size: int}>,
	 *   total_size: int
	 * }
	 */
	private function scan_bundle_sources( string $dir ): array {
		$queue      = [];
		$parts      = [];
		$total_size = 0;
		foreach ( self::storage_basename_map() as $comp => $fn ) {
			$p = $dir . $fn;
			if ( is_readable( $p ) && is_file( $p ) ) {
				$sz          = (int) filesize( $p );
				$parts[]     = [ 'key' => $comp, 'name' => $fn, 'size' => $sz ];
				$total_size += $sz;
				$queue[]     = [ 'abs' => $p, 'entry' => $fn ];
			}
		}
		$man = $dir . 'manifest.json';
		if ( is_readable( $man ) && is_file( $man ) ) {
			$queue[] = [ 'abs' => $man, 'entry' => 'manifest.json' ];
		}

		return [ 'queue' => $queue, 'parts' => $parts, 'total_size' => $total_size ];
	}

	/**
	 * Timeline payload rendered by the bundle modal.
	 *
	 * @param list<array{key: string, name: string, size: int}> $parts Scanned parts.
	 * @param string $pack_status 'active' or 'done'.
	 * @param string $label_extra Optional suffix for the pack step (current file n/m).
	 * @return array<string, mixed>
	 */
	private function bundle_progress_payload( array $parts, int $total_size, string $pack_status, string $label_extra = '' ): array {
		$pack = [ 'key' => 'pack', 'status' => $pack_status, 'total_size' => $total_size ];
		if ( '' !== $label_extra ) {
			$pack['label_extra'] = $label_extra;
		}

		return [
			'steps' => [
				[ 'key' => 'scan', 'status' => 'done', 'parts' => $parts, 'total_size' => $total_size ],
				$pack,
				[ 'key' => 'stage', 'status' => 'done' === $pack_status ? 'done' : 'pending' ],
			],
		];
	}

	/**
	 * @return array{path: string, filename: string}|null
	 */
	private function find_cached_bundle_download_file( string $dir, string $backup_id ): ?array {
		$cached = $dir . $this->bundle_cache_basename();
		if ( ! is_readable( $cached ) || ! is_file( $cached ) ) {
			return null;
		}
		$bundle_mtime = (int) @filemtime( $cached ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( $bundle_mtime < 1 ) {
			return null;
		}
		foreach ( self::storage_basename_map() as $fn ) {
			$p = $dir . $fn;
			if ( is_readable( $p ) && is_file( $p ) ) {
				$part_mtime = (int) @filemtime( $p ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( $part_mtime > $bundle_mtime ) {
					return null;
				}
			}
		}
		$man = $dir . 'manifest.json';
		if ( is_readable( $man ) && is_file( $man ) ) {
			$man_mtime = (int) @filemtime( $man ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( $man_mtime > $bundle_mtime ) {
				return null;
			}
		}
		$sz = (int) filesize( $cached );
		if ( $sz < 64 ) {
			return null;
		}

		return [
			'path'     => $cached,
			'filename' => self::build_branded_bundle_download_basename( $backup_id ),
		];
	}

	/**
	 * One AJAX-sized step of full-ZIP preparation (avoids host 503 on large packs).
	 *
	 * Stateless resume: progress lives in the building zip itself (entries already added),
	 * so there is no job file that can go stale or desync. Each call opens the building
	 * zip with CREATE, appends the first missing part, and closes. When every part is in,
	 * the building zip is promoted to the reusable ready cache and its path is returned.
	 *
	 * Uses CM_STORE (no compression) because the parts are already compressed archives.
	 *
	 * @return array{
	 *   success: bool,
	 *   done?: bool,
	 *   message?: string,
	 *   path?: string,
	 *   filename?: string,
	 *   progress?: array<string, mixed>|null
	 * }
	 */
	public function tick_bundle_prepare( string $backup_id ): array {
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id || ! $this->is_allowed_backup_folder_id( $backup_id ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid backup id.', 'wppowersuite-lite' ) ];
		}
		$dir = $this->locate_backup_dir( $backup_id );
		if ( $dir === '' || ! is_dir( $dir ) || ! $this->is_path_under_managed_storage( $dir ) ) {
			return [ 'success' => false, 'message' => __( 'Backup folder not found.', 'wppowersuite-lite' ) ];
		}
		if ( ! class_exists( 'ZipArchive' ) ) {
			return [ 'success' => false, 'message' => __( 'ZipArchive is not available on this server.', 'wppowersuite-lite' ) ];
		}

		// Remove leftovers from the retired pack-job/progress-file engines (pre-1.11.6).
		foreach ( [ '.wpps-br-bundle-pack-job.json', '.wpps-bundle-progress.json' ] as $legacy ) {
			if ( is_file( $dir . $legacy ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $dir . $legacy );
			}
		}

		$scan       = $this->scan_bundle_sources( $dir );
		$queue      = $scan['queue'];
		$parts      = $scan['parts'];
		$total_size = $scan['total_size'];
		if ( empty( $queue ) ) {
			return [ 'success' => false, 'message' => __( 'No backup parts found to pack.', 'wppowersuite-lite' ) ];
		}

		$cached = $this->find_cached_bundle_download_file( $dir, $backup_id );
		if ( is_array( $cached ) ) {
			return [
				'success'  => true,
				'done'     => true,
				'path'     => $cached['path'],
				'filename' => $cached['filename'],
				'progress' => $this->bundle_progress_payload( $parts, $total_size, 'done' ),
			];
		}

		$building = $dir . $this->bundle_building_basename();

		// Restart when a part changed after packing began (otherwise the zip would mix generations).
		if ( is_file( $building ) ) {
			$building_mtime = (int) @filemtime( $building ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			foreach ( $queue as $item ) {
				$part_mtime = (int) @filemtime( $item['abs'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( $building_mtime > 0 && $part_mtime > $building_mtime ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					@unlink( $building );
					break;
				}
			}
		}

		$z = new \ZipArchive();
		if ( true !== $z->open( $building, \ZipArchive::CREATE ) ) {
			return [ 'success' => false, 'message' => __( 'Could not create the ZIP bundle.', 'wppowersuite-lite' ) ];
		}

		$have = [];
		for ( $i = 0; $i < $z->numFiles; $i++ ) {
			$n = $z->getNameIndex( $i );
			if ( is_string( $n ) ) {
				$have[ $n ] = true;
			}
		}

		$next = null;
		foreach ( $queue as $item ) {
			if ( ! isset( $have[ $item['entry'] ] ) ) {
				$next = $item;
				break;
			}
		}

		if ( null === $next ) {
			// All parts are in: promote the building zip to the reusable ready cache.
			if ( ! $z->close() ) {
				return [ 'success' => false, 'message' => __( 'Could not save the ZIP bundle while packing.', 'wppowersuite-lite' ) ];
			}
			$cached_path = $dir . $this->bundle_cache_basename();
			if ( is_file( $cached_path ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $cached_path );
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( ! @rename( $building, $cached_path ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( ! @copy( $building, $cached_path ) ) {
					return [ 'success' => false, 'message' => __( 'Could not finalize the ZIP bundle.', 'wppowersuite-lite' ) ];
				}
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $building );
			}

			return [
				'success'  => true,
				'done'     => true,
				'path'     => $cached_path,
				'filename' => self::build_branded_bundle_download_basename( $backup_id ),
				'progress' => $this->bundle_progress_payload( $parts, $total_size, 'done' ),
			];
		}

		$real = realpath( $next['abs'] );
		if ( false === $real || ! $z->addFile( $real, $next['entry'] ) ) {
			$z->close();
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $building );
			return [
				'success' => false,
				/* translators: %s: file name */
				'message' => sprintf( __( 'Could not add %s to the ZIP bundle.', 'wppowersuite-lite' ), $next['entry'] ),
			];
		}
		$z->setCompressionName( $next['entry'], \ZipArchive::CM_STORE );
		if ( ! $z->close() ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $building );
			return [ 'success' => false, 'message' => __( 'Could not save the ZIP bundle while packing.', 'wppowersuite-lite' ) ];
		}

		$done_count  = count( $have ) + 1;
		$label_extra = sprintf( '%1$s (%2$d/%3$d)', $next['entry'], $done_count, count( $queue ) );

		return [
			'success'  => true,
			'done'     => false,
			'progress' => $this->bundle_progress_payload( $parts, $total_size, 'active', $label_extra ),
		];
	}

	/**
	 * True if archive has at least two known backup basenames at zip root.
	 */
	public function zip_archive_root_has_multiple_backup_parts( string $zip_abs ): bool {
		return $this->count_backup_parts_in_zip( $zip_abs ) >= 2;
	}

	/**
	 * Count how many known backup basenames exist at zip root (also counts manifest.json).
	 */
	public function count_backup_parts_in_zip( string $zip_abs ): int {
		if ( ! is_readable( $zip_abs ) || ! class_exists( 'ZipArchive' ) ) {
			return 0;
		}
		$z = new \ZipArchive();
		if ( true !== $z->open( $zip_abs, \ZipArchive::RDONLY ) ) {
			return 0;
		}
		$want = array_flip( array_values( self::storage_basename_map() ) );
		$want['manifest.json'] = true;
		$hits = 0;
		for ( $i = 0; $i < $z->numFiles; $i++ ) {
			$name = $z->getNameIndex( $i );
			if ( ! is_string( $name ) || $name === '' ) {
				continue;
			}
			$norm = str_replace( '\\', '/', $name );
			if ( str_contains( $norm, '..' ) || str_ends_with( $norm, '/' ) ) {
				continue;
			}
			$leaf = basename( $norm );
			if ( isset( $want[ $leaf ] ) ) {
				++$hits;
			}
		}
		$z->close();

		return $hits;
	}

	/**
	 * Import a single .zip that contains database.sql / plugins.zip / … at archive root.
	 *
	 * @return array{success: bool, message: string, imported?: int}
	 */
	public function import_bundle_zip_from_path( string $backup_id, string $zip_abs, bool $unlink_after ): array {
		$zip_abs = wp_normalize_path( $zip_abs );
		if ( ! is_readable( $zip_abs ) || ! class_exists( 'ZipArchive' ) ) {
			return [ 'success' => false, 'message' => __( 'Could not read the bundle.', 'wppowersuite-lite' ) ];
		}
		$backup_id = sanitize_file_name( $backup_id );
		if ( '' === $backup_id || ! $this->is_allowed_backup_folder_id( $backup_id ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid backup folder name.', 'wppowersuite-lite' ) ];
		}
		if ( ! $this->ensure_backup_root() ) {
			return [ 'success' => false, 'message' => __( 'Backup directory is not writable.', 'wppowersuite-lite' ) ];
		}
		$dir = trailingslashit( $this->get_backup_root() ) . $backup_id;
		if ( ! wp_mkdir_p( $dir ) ) {
			return [ 'success' => false, 'message' => __( 'Could not create backup folder.', 'wppowersuite-lite' ) ];
		}
		$dir_real  = wp_normalize_path( $dir );
		$root_real = wp_normalize_path( $this->get_backup_root() );
		if ( ! str_starts_with( $dir_real, $root_real ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid path.', 'wppowersuite-lite' ) ];
		}

		$z = new \ZipArchive();
		if ( true !== $z->open( $zip_abs, \ZipArchive::RDONLY ) ) {
			return [ 'success' => false, 'message' => __( 'Could not open bundle archive.', 'wppowersuite-lite' ) ];
		}
		$allowed = array_flip( array_merge( array_values( self::storage_basename_map() ), [ 'manifest.json' ] ) );
		$imported = 0;

		for ( $i = 0; $i < $z->numFiles; $i++ ) {
			$name = $z->getNameIndex( $i );
			if ( ! is_string( $name ) || $name === '' ) {
				continue;
			}
			$norm = str_replace( '\\', '/', $name );
			if ( str_contains( $norm, '..' ) || str_ends_with( $norm, '/' ) ) {
				continue;
			}
			$base = basename( $norm );
			if ( ! isset( $allowed[ $base ] ) ) {
				continue;
			}
			$dest = $dir . '/' . $base;
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( is_file( $dest ) ) {
				@unlink( $dest );
			}
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
			$in = $z->getStream( $name );
			if ( false !== $in && is_resource( $in ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
				$out = fopen( $dest, 'wb' );
				if ( false !== $out ) {
					stream_copy_to_stream( $in, $out );
					fclose( $in );
					fclose( $out );
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
					@chmod( $dest, 0644 );
					++$imported;
				} else {
					fclose( $in );
				}
			} else {
				$data = $z->getFromIndex( $i );
				if ( false !== $data && '' !== $data ) {
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
					if ( false !== file_put_contents( $dest, $data ) ) {
						// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
						@chmod( $dest, 0644 );
						++$imported;
					}
				}
			}
		}
		$z->close();

		if ( $imported < 1 ) {
			if ( $unlink_after ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $zip_abs );
			}
			return [
				'success' => false,
				'message' => __( 'The bundle did not contain any recognized backup files (database.sql, plugins.zip, themes.zip, uploads.zip, others.zip, or manifest.json).', 'wppowersuite-lite' ),
			];
		}

		if ( $unlink_after ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $zip_abs );
		}

		return [
			'success'   => true,
			/* translators: %d: number of files written */
			'message'   => sprintf( __( 'Imported %d file(s) from the bundle.', 'wppowersuite-lite' ), $imported ),
			'imported'  => $imported,
		];
	}

	/**
	 * Whether $id is a safe folder name under the backup root.
	 */
	public function is_allowed_backup_folder_id( string $id ): bool {
		return self::is_valid_backup_folder_name( $id );
	}

	/**
	 * Existing backup-* directories (for upload target dropdown).
	 *
	 * @return list<array{id: string, has_manifest: bool}>
	 */
	public function list_backup_dirs_for_upload(): array {
		$out   = [];
		$seen  = [];
		$roots = [];
		$primary = $this->get_backup_root();
		if ( $primary !== '' ) {
			$roots[] = $primary;
		}
		foreach ( $this->get_legacy_backup_roots() as $legacy ) {
			$roots[] = $legacy;
		}
		foreach ( array_unique( $roots ) as $root ) {
			if ( ! is_dir( $root ) ) {
				continue;
			}
			$dirs = glob( trailingslashit( $root ) . 'backup-*', GLOB_ONLYDIR );
			if ( ! is_array( $dirs ) ) {
				continue;
			}
			foreach ( $dirs as $dir ) {
				$id = basename( $dir );
				if ( ! $this->is_allowed_backup_folder_id( $id ) || isset( $seen[ $id ] ) ) {
					continue;
				}
				$seen[ $id ] = true;
				$out[]       = [
					'id'           => $id,
					'has_manifest' => is_readable( trailingslashit( $dir ) . 'manifest.json' ),
					'legacy'       => $primary === '' || ! str_starts_with( wp_normalize_path( $dir ), wp_normalize_path( $primary ) ),
				];
			}
		}
		usort(
			$out,
			static function ( $a, $b ) {
				return strcmp( (string) ( $b['id'] ?? '' ), (string) ( $a['id'] ?? '' ) );
			}
		);
		return $out;
	}

	/**
	 * Map an uploaded filename to a component key (same layout as our backups).
	 */
	public static function detect_component_from_upload_filename( string $filename ): ?string {
		$branded = self::parse_branded_upload_filename( $filename );
		if ( is_array( $branded ) ) {
			return $branded['component'];
		}
		$base = strtolower( basename( $filename ) );
		if ( preg_match( '/\.sql$/', $base ) ) {
			return 'database';
		}
		if ( str_ends_with( $base, 'others.zip' ) ) {
			return 'others';
		}
		if ( str_ends_with( $base, 'uploads.zip' ) ) {
			return 'uploads';
		}
		if ( str_ends_with( $base, 'themes.zip' ) ) {
			return 'themes';
		}
		if ( str_ends_with( $base, 'plugins.zip' ) ) {
			return 'plugins';
		}
		if ( str_ends_with( $base, '.zip' ) && str_contains( $base, 'bundle' ) ) {
			return 'bundle';
		}
		return null;
	}

	/**
	 * First bytes look like a MySQL / WordPress SQL dump (handles chunked uploads named "blob" with no extension).
	 */
	private static function file_head_looks_like_sql_dump( string $abs_path ): bool {
		$sz = is_readable( $abs_path ) ? (int) filesize( $abs_path ) : 0;
		if ( $sz < 16 ) {
			return false;
		}
		$read = (int) min( 65536, $sz );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$head = (string) file_get_contents( $abs_path, false, null, 0, $read );
		if ( $head === '' ) {
			return false;
		}
		if ( str_starts_with( $head, "\xEF\xBB\xBF" ) ) {
			$head = substr( $head, 3 );
		}
		$trim = ltrim( $head );
		if ( str_starts_with( $trim, "PK\x03\x04" ) || str_starts_with( $trim, "PK\x05\x06" ) ) {
			return false;
		}
		if ( preg_match( '/^([\s\r\n]*)(--|#|\/\*)/', $trim ) ) {
			return true;
		}

		return (bool) preg_match(
			'/\b(CREATE TABLE|INSERT INTO|DROP TABLE|LOCK TABLES|UNLOCK TABLES|SET (?:SQL_MODE|NAMES|FOREIGN_KEY_CHECKS)|DUMPFILE:|MYSQL DUMP)\b/i',
			$trim
		);
	}

	/**
	 * Detect plugins.zip / themes.zip / uploads.zip layout (archive root is that folder, not database.sql at root).
	 *
	 * @return ?string One of plugins|themes|uploads or null.
	 */
	private function infer_component_from_component_zip_layout( string $zip_abs ): ?string {
		if ( ! is_readable( $zip_abs ) || ! class_exists( 'ZipArchive' ) ) {
			return null;
		}
		$z = new \ZipArchive();
		if ( true !== $z->open( $zip_abs, \ZipArchive::RDONLY ) ) {
			return null;
		}
		$plug = 0;
		$thm  = 0;
		$upl  = 0;
		$oth  = 0;
		$max  = min( 2000, $z->numFiles );
		for ( $i = 0; $i < $max; $i++ ) {
			$n = $z->getNameIndex( $i );
			if ( ! is_string( $n ) || $n === '' || str_ends_with( $n, '/' ) ) {
				continue;
			}
			$norm = str_replace( '\\', '/', $n );
			if ( str_contains( $norm, '..' ) ) {
				continue;
			}
			$ln = strtolower( $norm );
			if ( str_starts_with( $ln, 'plugins/' ) ) {
				++$plug;
			} elseif ( str_starts_with( $ln, 'themes/' ) ) {
				++$thm;
			} elseif ( str_starts_with( $ln, 'uploads/' ) ) {
				++$upl;
			} else {
				++$oth;
			}
		}
		$z->close();
		if ( $plug === 0 && $thm === 0 && $upl === 0 ) {
			return null;
		}
		if ( $plug > 0 && $thm === 0 && $upl === 0 && $oth === 0 ) {
			return 'plugins';
		}
		if ( $thm > 0 && $plug === 0 && $upl === 0 && $oth === 0 ) {
			return 'themes';
		}
		if ( $upl > 0 && $plug === 0 && $thm === 0 && $oth === 0 ) {
			return 'uploads';
		}

		return null;
	}

	/**
	 * When the filename is useless (e.g. "blob"), infer database / component ZIP from contents.
	 *
	 * @return ?string database|plugins|themes|uploads or null (not bundle).
	 */
	private function infer_upload_component_from_file_contents( string $abs_path ): ?string {
		if ( ! is_readable( $abs_path ) ) {
			return null;
		}
		if ( self::file_is_zip_by_magic( $abs_path ) ) {
			return $this->infer_component_from_component_zip_layout( $abs_path );
		}
		if ( self::file_head_looks_like_sql_dump( $abs_path ) ) {
			return 'database';
		}

		return null;
	}

	/**
	 * Filename + branded + forced + content sniff → component key.
	 *
	 * @param array{backup_id: string, component: string}|null $branded
	 * @return ?string database|plugins|themes|uploads|others|bundle|null
	 */
	private function resolve_upload_component_key( string $abs_path, string $display_name, ?array $branded, ?string $forced_component ): ?string {
		if ( is_string( $forced_component ) && $forced_component !== '' ) {
			$k = sanitize_key( $forced_component );
			if ( in_array( $k, [ 'database', 'plugins', 'themes', 'uploads', 'others', 'bundle' ], true ) ) {
				return $k;
			}
		}
		if ( is_array( $branded ) && ! empty( $branded['component'] ) ) {
			$k = sanitize_key( (string) $branded['component'] );
			if ( in_array( $k, [ 'database', 'plugins', 'themes', 'uploads', 'others', 'bundle' ], true ) ) {
				return $k;
			}
		}
		$from = self::detect_component_from_upload_filename( $display_name );
		if ( null !== $from ) {
			return $from;
		}

		return $this->infer_upload_component_from_file_contents( $abs_path );
	}

	/**
	 * Whether to run bundle extraction: never when we already resolved a single concrete part.
	 *
	 * @param string|null $resolved_key From resolve_upload_component_key().
	 */
	private function should_attempt_bundle_zip_import( string $abs_path, string $display_name, ?string $resolved_key ): bool {
		if ( 'bundle' === $resolved_key ) {
			return true;
		}
		if ( is_string( $resolved_key ) && in_array( $resolved_key, [ 'database', 'plugins', 'themes', 'uploads', 'others' ], true ) ) {
			return false;
		}
		if ( ! $this->is_zip_upload_candidate( $abs_path, $display_name ) ) {
			return false;
		}
		$low = strtolower( basename( str_replace( '\\', '/', $display_name ) ) );
		$n   = $this->count_backup_parts_in_zip( $abs_path );

		return str_contains( $low, 'bundle' ) || $n >= 1;
	}

	/**
	 * Move one uploaded file into a backup set folder using standard names (Updraft-style manual upload).
	 *
	 * @param array<string, mixed> $file `$_FILES['backup_file']` single file entry.
	 * @return array{success: bool, message: string, component?: string}
	 */
	public function ingest_uploaded_backup_part( string $backup_id, array $file, ?string $forced_component ): array {
		$err = isset( $file['error'] ) ? (int) $file['error'] : UPLOAD_ERR_NO_FILE;
		if ( UPLOAD_ERR_OK !== $err ) {
			$msg = __( 'No file was uploaded.', 'wppowersuite-lite' );
			if ( UPLOAD_ERR_INI_SIZE === $err || UPLOAD_ERR_FORM_SIZE === $err ) {
				$msg = __( 'The uploaded file exceeds the server upload limit.', 'wppowersuite-lite' );
			}
			if ( UPLOAD_ERR_PARTIAL === $err ) {
				$msg = __( 'The file was only partially uploaded.', 'wppowersuite-lite' );
			}
			return [ 'success' => false, 'message' => $msg ];
		}

		$tmp = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : '';
		if ( '' === $tmp || ! is_uploaded_file( $tmp ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid upload.', 'wppowersuite-lite' ) ];
		}

		$max  = (int) wp_max_upload_size();
		$size = isset( $file['size'] ) ? (int) $file['size'] : 0;
		if ( $max > 0 && $size > $max ) {
			return [ 'success' => false, 'message' => __( 'File is larger than the allowed upload size.', 'wppowersuite-lite' ) ];
		}

		$name    = isset( $file['name'] ) ? (string) $file['name'] : '';
		$branded = self::parse_branded_upload_filename( $name );
		if ( is_array( $branded ) && self::is_valid_backup_folder_name( $branded['backup_id'] ) ) {
			$backup_id = $branded['backup_id'];
		}

		if ( ! $this->is_allowed_backup_folder_id( $backup_id ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid backup folder name.', 'wppowersuite-lite' ) ];
		}

		$key = $this->resolve_upload_component_key( $tmp, $name, $branded, $forced_component );

		if ( $this->should_attempt_bundle_zip_import( $tmp, $name, $key ) ) {
			if ( ! $this->is_zip_upload_candidate( $tmp, $name ) ) {
				return [ 'success' => false, 'message' => __( 'A full backup bundle must be a .zip file.', 'wppowersuite-lite' ) ];
			}
			$r = $this->import_bundle_zip_from_path( $backup_id, $tmp, true );
			if ( empty( $r['success'] ) ) {
				return [ 'success' => false, 'message' => (string) ( $r['message'] ?? __( 'Bundle import failed.', 'wppowersuite-lite' ) ) ];
			}

			return [
				'success'   => true,
				'message'   => (string) ( $r['message'] ?? '' ),
				'component' => 'bundle',
				'backup_id' => $backup_id,
			];
		}

		$comp = $key;

		if ( 'bundle' === $comp && $this->is_zip_upload_candidate( $tmp, $name ) ) {
			$r = $this->import_bundle_zip_from_path( $backup_id, $tmp, true );
			if ( empty( $r['success'] ) ) {
				return [ 'success' => false, 'message' => (string) ( $r['message'] ?? __( 'Bundle import failed.', 'wppowersuite-lite' ) ) ];
			}
			return [
				'success'   => true,
				'message'   => (string) ( $r['message'] ?? '' ),
				'component' => 'bundle',
				'backup_id' => $backup_id,
			];
		}

		$allowed = [ 'database', 'plugins', 'themes', 'uploads', 'others' ];
		if ( ! is_string( $comp ) || ! in_array( $comp, $allowed, true ) ) {
			if ( $this->is_zip_upload_candidate( $tmp, $name ) ) {
				$r = $this->import_bundle_zip_from_path( $backup_id, $tmp, true );
				if ( ! empty( $r['success'] ) ) {
					return [
						'success'   => true,
						'message'   => (string) ( $r['message'] ?? '' ),
						'component' => 'bundle',
						'backup_id' => $backup_id,
					];
				}
				return [
					'success' => false,
					'message' => (string) (
						$r['message'] ?? __(
							'Could not determine the backup part. Use a WP PowerSuite download (wpps-backup__…), choose a part under Advanced, or rename the file to end with database.sql, plugins.zip, etc.',
							'wppowersuite-lite'
						)
					),
				];
			}
			return [
				'success' => false,
				'message' => __( 'Could not determine the backup part. Use a WP PowerSuite download (wpps-backup__…), choose a part under Advanced, or rename the file to end with database.sql, plugins.zip, etc.', 'wppowersuite-lite' ),
			];
		}

		$map  = self::storage_basename_map();
		$dest = trailingslashit( $this->get_backup_root() ) . $backup_id . '/' . $map[ $comp ];

		if ( ! $this->ensure_backup_root() ) {
			return [ 'success' => false, 'message' => __( 'Backup directory is not writable.', 'wppowersuite-lite' ) ];
		}

		$dir = trailingslashit( $this->get_backup_root() ) . $backup_id;
		if ( ! wp_mkdir_p( $dir ) ) {
			return [ 'success' => false, 'message' => __( 'Could not create backup folder.', 'wppowersuite-lite' ) ];
		}

		$dir_real  = wp_normalize_path( $dir );
		$root_real = wp_normalize_path( $this->get_backup_root() );
		if ( ! str_starts_with( $dir_real, $root_real ) ) {
			return [ 'success' => false, 'message' => __( 'Invalid path.', 'wppowersuite-lite' ) ];
		}

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( is_file( $dest ) ) {
			@unlink( $dest );
		}

		if ( ! move_uploaded_file( $tmp, $dest ) ) {
			return [ 'success' => false, 'message' => __( 'Could not save the uploaded file.', 'wppowersuite-lite' ) ];
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
		@chmod( $dest, 0644 );

		return [
			'success'    => true,
			'message'    => '',
			'component'  => $comp,
			'backup_id'  => $backup_id,
		];
	}

	/**
	 * Ingest a file already on disk (e.g. after wp_handle_upload to staging — Updraft-style plupload path).
	 *
	 * @param string $staged_abs_path Absolute path from wp_handle_upload()['file'].
	 * @return array{success: bool, message: string, component?: string, backup_id?: string}
	 */
	public function ingest_staged_backup_part( string $backup_id, string $staged_abs_path, string $original_filename, ?string $forced_component ): array {
		$staged_abs_path = wp_normalize_path( $staged_abs_path );
		if ( '' === $staged_abs_path || ! is_file( $staged_abs_path ) || ! is_readable( $staged_abs_path ) ) {
			return [ 'success' => false, 'message' => __( 'Could not read the uploaded file.', 'wppowersuite-lite' ) ];
		}

		$root_real = wp_normalize_path( $this->get_backup_root() );
		// Global namespace: wp_realpath() lives in WordPress global scope (not BackupRestore\Includes).
		if ( function_exists( 'wp_realpath' ) ) {
			$resolved = \wp_realpath( $staged_abs_path );
		} else {
			$tmp = \realpath( $staged_abs_path );
			$resolved = ( false !== $tmp ) ? $tmp : false;
		}
		if ( ! is_string( $resolved ) || '' === $resolved ) {
			$staged_real = $staged_abs_path;
		} else {
			$staged_real = wp_normalize_path( $resolved );
		}
		$root_slash = trailingslashit( $root_real );
		if ( ! str_starts_with( $staged_real, $root_slash ) && $staged_real !== $root_real ) {
			return [ 'success' => false, 'message' => __( 'Invalid staged file path.', 'wppowersuite-lite' ) ];
		}

		$name    = $original_filename !== '' ? $original_filename : basename( $staged_abs_path );
		$branded = self::parse_branded_upload_filename( $name );
		if ( is_array( $branded ) && self::is_valid_backup_folder_name( $branded['backup_id'] ) ) {
			$backup_id = $branded['backup_id'];
		}

		if ( ! $this->is_allowed_backup_folder_id( $backup_id ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
			return [ 'success' => false, 'message' => __( 'Invalid backup folder name.', 'wppowersuite-lite' ) ];
		}

		$key = $this->resolve_upload_component_key( $staged_abs_path, $name, $branded, $forced_component );

		if ( $this->should_attempt_bundle_zip_import( $staged_abs_path, $name, $key ) ) {
			if ( ! $this->is_zip_upload_candidate( $staged_abs_path, $name ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $staged_abs_path );
				return [ 'success' => false, 'message' => __( 'A full backup bundle must be a .zip file.', 'wppowersuite-lite' ) ];
			}
			$r = $this->import_bundle_zip_from_path( $backup_id, $staged_abs_path, true );
			if ( empty( $r['success'] ) ) {
				return [ 'success' => false, 'message' => (string) ( $r['message'] ?? __( 'Bundle import failed.', 'wppowersuite-lite' ) ) ];
			}

			return [
				'success'   => true,
				'message'   => (string) ( $r['message'] ?? '' ),
				'component' => 'bundle',
				'backup_id' => $backup_id,
			];
		}

		$comp = $key;

		if ( 'bundle' === $comp && $this->is_zip_upload_candidate( $staged_abs_path, $name ) ) {
			$r = $this->import_bundle_zip_from_path( $backup_id, $staged_abs_path, true );
			if ( empty( $r['success'] ) ) {
				return [ 'success' => false, 'message' => (string) ( $r['message'] ?? __( 'Bundle import failed.', 'wppowersuite-lite' ) ) ];
			}
			return [
				'success'   => true,
				'message'   => (string) ( $r['message'] ?? '' ),
				'component' => 'bundle',
				'backup_id' => $backup_id,
			];
		}

		$allowed = [ 'database', 'plugins', 'themes', 'uploads', 'others' ];
		if ( ! is_string( $comp ) || ! in_array( $comp, $allowed, true ) ) {
			if ( $this->is_zip_upload_candidate( $staged_abs_path, $name ) ) {
				$r = $this->import_bundle_zip_from_path( $backup_id, $staged_abs_path, true );
				if ( ! empty( $r['success'] ) ) {
					return [
						'success'   => true,
						'message'   => (string) ( $r['message'] ?? '' ),
						'component' => 'bundle',
						'backup_id' => $backup_id,
					];
				}
				if ( is_file( $staged_abs_path ) ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					@unlink( $staged_abs_path );
				}
				return [
					'success' => false,
					'message' => (string) (
						$r['message'] ?? __(
							'Could not determine the backup part. Use a WP PowerSuite download (wpps-backup__…), choose a part under Advanced, or rename the file to end with database.sql, plugins.zip, etc.',
							'wppowersuite-lite'
						)
					),
				];
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
			return [
				'success' => false,
				'message' => __( 'Could not determine the backup part. Use a WP PowerSuite download (wpps-backup__…), choose a part under Advanced, or rename the file to end with database.sql, plugins.zip, etc.', 'wppowersuite-lite' ),
			];
		}

		$map  = self::storage_basename_map();
		$dest = trailingslashit( $this->get_backup_root() ) . $backup_id . '/' . $map[ $comp ];

		if ( ! $this->ensure_backup_root() ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
			return [ 'success' => false, 'message' => __( 'Backup directory is not writable.', 'wppowersuite-lite' ) ];
		}

		$dir = trailingslashit( $this->get_backup_root() ) . $backup_id;
		if ( ! wp_mkdir_p( $dir ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
			return [ 'success' => false, 'message' => __( 'Could not create backup folder.', 'wppowersuite-lite' ) ];
		}

		$dir_real  = wp_normalize_path( $dir );
		$root_real = wp_normalize_path( $this->get_backup_root() );
		if ( ! str_starts_with( $dir_real, $root_real ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
			return [ 'success' => false, 'message' => __( 'Invalid path.', 'wppowersuite-lite' ) ];
		}

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( is_file( $dest ) ) {
			@unlink( $dest );
		}

		if ( ! @rename( $staged_abs_path, $dest ) ) {// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
			if ( ! @copy( $staged_abs_path, $dest ) ) {// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $staged_abs_path );
				return [ 'success' => false, 'message' => __( 'Could not save the uploaded file.', 'wppowersuite-lite' ) ];
			}
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			@unlink( $staged_abs_path );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
		@chmod( $dest, 0644 );

		return [
			'success'   => true,
			'message'   => '',
			'component' => $comp,
			'backup_id' => $backup_id,
		];
	}

}
