<?php
/**
 * Archive creation, extraction, shell helper, and archive queue helpers for BackupService.
 *
 * @package BackupRestore
 */

namespace BackupRestore\Includes;

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

trait BackupArchiveTrait {
	private function find_system_zip_binary(): ?string {
		$paths = [ '/usr/bin/zip', '/bin/zip', '/usr/local/bin/zip' ];
		if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
			if ( ! self::exec_function_available() ) {
				return null;
			}
			$paths = [ 'zip' ];
			foreach ( $paths as $p ) {
				$out = [];
				$code = 0;
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_exec
				@exec( 'where ' . escapeshellarg( $p ) . ' 2>NUL', $out, $code );
				if ( $code === 0 && ! empty( $out[0] ) && is_string( $out[0] ) && file_exists( trim( $out[0] ) ) ) {
					return trim( $out[0] );
				}
			}
			return null;
		}
		foreach ( $paths as $p ) {
			if ( $p && @is_executable( $p ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				return $p;
			}
		}
		return null;
	}

	private function find_system_unzip_binary(): ?string {
		$paths = [ '/usr/bin/unzip', '/bin/unzip', '/usr/local/bin/unzip' ];
		if ( strtoupper( substr( PHP_OS, 0, 3 ) ) === 'WIN' ) {
			if ( ! self::exec_function_available() ) {
				return null;
			}
			$paths = [ 'unzip' ];
			foreach ( $paths as $p ) {
				$out  = [];
				$code = 0;
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_exec
				@exec( 'where ' . escapeshellarg( $p ) . ' 2>NUL', $out, $code );
				if ( $code === 0 && ! empty( $out[0] ) && is_string( $out[0] ) && file_exists( trim( $out[0] ) ) ) {
					return trim( $out[0] );
				}
			}
			return null;
		}
		foreach ( $paths as $p ) {
			if ( $p && @is_executable( $p ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				return $p;
			}
		}
		return null;
	}

	/**
	 * Restore extract/publish tuning (filterable; clamped for safety on shared hosts).
	 *
	 * @return array{unzip_max_files: int, unzip_max_seconds: int, unzip_inner_flushes: int, unzip_max_index_step: int, copy_batch: int, copy_max_seconds: int, copy_inner_flushes: int}
	 */
	private function restore_chunk_limits(): array {
		$unzip_files = (int) apply_filters( 'wpps_br_restore_unzip_batch', self::RESTORE_UNZIP_BATCH );
		$unzip_files = max( 200, min( 3000, $unzip_files ) );

		$unzip_seconds = (int) apply_filters( 'wpps_br_restore_unzip_max_seconds', self::RESTORE_UNZIP_MAX_SECONDS );
		$unzip_seconds = max( 20, min( 120, $unzip_seconds ) );

		$unzip_inner = (int) apply_filters( 'wpps_br_restore_unzip_inner_flushes', self::RESTORE_UNZIP_INNER_FLUSHES );
		$unzip_inner = max( 1, min( 12, $unzip_inner ) );

		$copy_batch = (int) apply_filters( 'wpps_br_restore_copy_batch', self::RESTORE_COPY_BATCH );
		$copy_batch = max( 100, min( 2000, $copy_batch ) );

		$copy_seconds = (int) apply_filters( 'wpps_br_restore_copy_max_seconds', self::RESTORE_COPY_MAX_SECONDS );
		$copy_seconds = max( 20, min( 120, $copy_seconds ) );

		$copy_inner = (int) apply_filters( 'wpps_br_restore_copy_inner_flushes', self::RESTORE_COPY_INNER_FLUSHES );
		$copy_inner = max( 1, min( 12, $copy_inner ) );

		return [
			'unzip_max_files'       => $unzip_files,
			'unzip_max_seconds'     => $unzip_seconds,
			'unzip_inner_flushes'   => $unzip_inner,
			'unzip_max_index_step'  => self::RESTORE_UNZIP_MAX_INDEX_STEP,
			'copy_batch'            => $copy_batch,
			'copy_max_seconds'      => $copy_seconds,
			'copy_inner_flushes'    => $copy_inner,
		];
	}

	/**
	 * Extract a component zip with system unzip into the restore staging tree (plugins/… under workdir).
	 */
	private function try_system_unzip_component( string $zip_path, string $zip_prefix, string $dest_root ): bool {
		// Default off: system unzip does not apply the Zip Slip checks used by ZipArchive.
		if ( ! apply_filters( 'wpps_br_use_system_unzip', false, $zip_prefix, $dest_root ) ) {
			return false;
		}
		if ( ! $this->archive_shell_available() ) {
			return false;
		}
		$bin = $this->find_system_unzip_binary();
		if ( ! $bin || ! is_readable( $zip_path ) ) {
			return false;
		}

		$dest_root  = rtrim( wp_normalize_path( $dest_root ), '/' );
		$extract_to = dirname( $dest_root );
		$prefix     = rtrim( str_replace( '\\', '/', $zip_prefix ), '/' );
		if ( $prefix === '' || ! wp_mkdir_p( $extract_to ) ) {
			return false;
		}

		// Validate every matching entry BEFORE shell unzip (post-extract scans miss escapes).
		if ( ! $this->zip_entries_safe_for_extract( $zip_path, $prefix, $dest_root, $extract_to ) ) {
			$this->log_line(
				sprintf(
					/* translators: %s: zip internal prefix */
					__( 'System unzip skipped for %s: archive contains unsafe paths.', 'wppowersuite-lite' ),
					$prefix
				)
			);

			return false;
		}

		$cmd_parts = [
			escapeshellarg( $bin ),
			'-q',
			'-o',
			escapeshellarg( wp_normalize_path( $zip_path ) ),
			escapeshellarg( $prefix . '/*' ),
			'-d',
			escapeshellarg( $extract_to ),
		];
		$cmd = implode( ' ', $cmd_parts );

		$stderr = '';
		$code   = $this->run_shell_command_in_directory( $cmd, $extract_to, 900, $stderr );
		if ( 0 !== $code || ! is_dir( $dest_root ) ) {
			$detail = $stderr !== '' ? ' ' . trim( $stderr ) : '';
			$this->log_line(
				sprintf(
					/* translators: 1: zip internal prefix, 2: optional error detail */
					__( 'System unzip failed for %1$s; falling back to ZipArchive.%2$s', 'wppowersuite-lite' ),
					$prefix,
					$detail
				)
			);

			return false;
		}

		if ( ! $this->assert_extracted_tree_inside_root( $dest_root ) ) {
			$this->log_line(
				sprintf(
					/* translators: %s: zip internal prefix */
					__( 'System unzip for %s wrote paths outside the staging root; falling back to ZipArchive.', 'wppowersuite-lite' ),
					$prefix
				)
			);
			$this->recursive_delete( $dest_root );

			return false;
		}

		$this->log_line(
			sprintf(
				/* translators: 1: zip internal prefix, 2: unzip binary path */
				__( 'Extracted %1$s with system unzip (%2$s).', 'wppowersuite-lite' ),
				$prefix,
				$bin
			)
		);

		return true;
	}

	/**
	 * Archive resource limits (filterable).
	 *
	 * @return array{
	 *   max_archive_bytes:int,
	 *   max_extracted_bytes:int,
	 *   max_files:int,
	 *   max_file_bytes:int,
	 *   max_ratio:float,
	 *   max_path_length:int,
	 *   max_depth:int
	 * }
	 */
	private function archive_extract_limits(): array {
		/*
		 * Defaults sized for real sites (large media libraries routinely exceed 2 GiB /
		 * 100k files — comparable plugins impose no absolute size caps at all). Zip-bomb
		 * protection comes from the compression-ratio guard, per-entry path validation
		 * and the free-space preflight, not from these backstop caps. 32-bit PHP cannot
		 * represent (or filesize()) archives beyond PHP_INT_MAX anyway.
		 */
		$gib         = 1024 * 1024 * 1024;
		$max_archive = (int) apply_filters( 'wpps_br_archive_max_bytes', PHP_INT_SIZE >= 8 ? 64 * $gib : PHP_INT_MAX );
		$max_extract = (int) apply_filters( 'wpps_br_archive_max_extracted_bytes', PHP_INT_SIZE >= 8 ? 128 * $gib : PHP_INT_MAX );
		$max_files   = (int) apply_filters( 'wpps_br_archive_max_files', 400000 );
		$max_file    = (int) apply_filters( 'wpps_br_archive_max_file_bytes', PHP_INT_SIZE >= 8 ? 8 * $gib : PHP_INT_MAX );
		$max_ratio   = (float) apply_filters( 'wpps_br_archive_max_compression_ratio', 100.0 );
		$max_path    = (int) apply_filters( 'wpps_br_archive_max_path_length', 512 );
		$max_depth   = (int) apply_filters( 'wpps_br_archive_max_depth', 40 );

		return [
			'max_archive_bytes'   => max( 1024 * 1024, $max_archive ),
			'max_extracted_bytes' => max( 1024 * 1024, $max_extract ),
			'max_files'           => max( 1, min( 2000000, $max_files ) ),
			'max_file_bytes'      => max( 1024, $max_file ),
			'max_ratio'           => max( 1.0, $max_ratio ),
			'max_path_length'     => max( 64, min( 4096, $max_path ) ),
			'max_depth'           => max( 1, min( 128, $max_depth ) ),
		];
	}

	/**
	 * Record WHY archive validation refused to extract (admins need the reason and
	 * the filter name; a bare "failed safety validation" is undebuggable).
	 */
	private function log_extract_rejection( string $reason ): void {
		$this->log_line( __( 'Archive validation failed: ', 'wppowersuite-lite' ) . $reason );
	}

	/**
	 * Whether a zip entry is a forbidden special type (symlink/device/etc.).
	 */
	private function zip_entry_is_special_file( \ZipArchive $z, int $index ): bool {
		$stat = $z->statIndex( $index );
		if ( ! is_array( $stat ) ) {
			return true;
		}
		if ( ! method_exists( $z, 'getExternalAttributesIndex' ) ) {
			return false;
		}
		$opsys = 0;
		$attr  = 0;
		if ( ! $z->getExternalAttributesIndex( $index, $opsys, $attr ) ) {
			return false;
		}
		$opsys = (int) $opsys;
		$attr  = (int) $attr;
		if ( ( defined( 'ZipArchive::OPSYS_UNIX' ) && $opsys === \ZipArchive::OPSYS_UNIX ) || $opsys === 3 ) {
			$mode = ( $attr >> 16 ) & 0xFFFF;
			$type = $mode & 0xF000;
			// 0xA000 symlink, 0x6000 block, 0x2000 char, 0x1000 fifo, 0xC000 socket.
			if ( in_array( $type, [ 0xA000, 0x6000, 0x2000, 0x1000, 0xC000 ], true ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Public wrapper for regression tests / shared callers.
	 */
	public static function is_zip_entry_name_unsafe( string $name ): bool {
		$svc = new self();

		return $svc->zip_entry_path_is_unsafe( $name );
	}

	/**
	 * Reject unsafe zip entry path names before any extraction.
	 */
	private function zip_entry_path_is_unsafe( string $name ): bool {
		if ( $name === '' || str_contains( $name, "\0" ) ) {
			return true;
		}
		$norm = str_replace( '\\', '/', $name );
		if ( $norm === '' || $norm === '.' || $norm === '..' ) {
			return true;
		}
		if ( str_starts_with( $norm, '/' ) || str_starts_with( $norm, '//' ) ) {
			return true;
		}
		if ( preg_match( '#^[A-Za-z]:/#', $norm ) || preg_match( '#^[A-Za-z]:$#', $norm ) ) {
			return true;
		}
		if ( str_starts_with( $norm, '\\\\' ) || str_contains( $name, '\\\\' ) ) {
			return true;
		}
		if ( preg_match( '#(^|/)\.\.(/|$)#', $norm ) === 1 ) {
			return true;
		}
		if ( str_contains( $name, '..\\' ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Pre-flight Zip Slip + resource check: every entry under $zip_prefix must
	 * resolve inside $dest_root when extracted to $extract_to. No extraction begins
	 * until this returns true.
	 */
	private function zip_entries_safe_for_extract( string $zip_path, string $zip_prefix, string $dest_root, string $extract_to ): bool {
		if ( ! class_exists( '\ZipArchive' ) ) {
			$this->log_extract_rejection( 'ZipArchive PHP extension is not available.' );
			return false;
		}
		$limits = $this->archive_extract_limits();
		$archive_size = is_readable( $zip_path ) ? (int) filesize( $zip_path ) : 0;
		if ( $archive_size < 1 ) {
			$this->log_extract_rejection( sprintf( 'archive is unreadable or empty (%s).', basename( $zip_path ) ) );
			return false;
		}
		if ( $archive_size > $limits['max_archive_bytes'] ) {
			$this->log_extract_rejection(
				sprintf(
					'archive size %1$s exceeds the limit %2$s (raise via wpps_br_archive_max_bytes filter).',
					size_format( $archive_size, 1 ),
					size_format( $limits['max_archive_bytes'], 1 )
				)
			);
			return false;
		}

		$z = new \ZipArchive();
		if ( true !== $z->open( $zip_path, \ZipArchive::RDONLY ) ) {
			$this->log_extract_rejection( sprintf( 'could not open %s as a zip archive (corrupt or truncated file).', basename( $zip_path ) ) );
			return false;
		}

		$prefix      = rtrim( str_replace( '\\', '/', $zip_prefix ), '/' );
		$prefix_slash = $prefix === '' ? '' : $prefix . '/';
		$dest_root   = rtrim( wp_normalize_path( $dest_root ), '/' );
		$extract_to  = rtrim( wp_normalize_path( $extract_to ), '/' );
		$prefix_len  = strlen( $prefix_slash );
		$entry_count = 0;
		$uncompressed_total = 0;

		for ( $i = 0; $i < $z->numFiles; $i++ ) {
			$name = $z->getNameIndex( $i );
			if ( ! is_string( $name ) || $name === '' ) {
				$this->log_extract_rejection( sprintf( 'entry #%d has an empty or unreadable name.', $i ) );
				$z->close();
				return false;
			}
			$norm = str_replace( '\\', '/', $name );
			if ( $prefix_slash !== '' && ! str_starts_with( $norm, $prefix_slash ) && $norm !== $prefix ) {
				continue;
			}
			if ( $this->zip_entry_path_is_unsafe( $name ) || $this->zip_entry_path_is_unsafe( $norm ) ) {
				$this->log_extract_rejection( sprintf( 'unsafe entry path "%s".', $this->truncate_for_log( $norm ) ) );
				$z->close();
				return false;
			}
			if ( $this->zip_entry_is_special_file( $z, $i ) ) {
				$this->log_extract_rejection( sprintf( 'entry "%s" is a symlink or special filesystem object.', $this->truncate_for_log( $norm ) ) );
				$z->close();
				return false;
			}
			++$entry_count;
			if ( $entry_count > $limits['max_files'] ) {
				$this->log_extract_rejection(
					sprintf( 'more than %d entries (raise via wpps_br_archive_max_files filter).', $limits['max_files'] )
				);
				$z->close();
				return false;
			}
			if ( strlen( $norm ) > $limits['max_path_length'] ) {
				$this->log_extract_rejection(
					sprintf( 'entry path longer than %1$d characters: "%2$s".', $limits['max_path_length'], $this->truncate_for_log( $norm ) )
				);
				$z->close();
				return false;
			}
			$depth = substr_count( trim( $norm, '/' ), '/' );
			if ( $depth > $limits['max_depth'] ) {
				$this->log_extract_rejection(
					sprintf( 'entry "%1$s" is nested deeper than %2$d directories.', $this->truncate_for_log( $norm ), $limits['max_depth'] )
				);
				$z->close();
				return false;
			}

			$stat = $z->statIndex( $i );
			$size = is_array( $stat ) ? (int) ( $stat['size'] ?? 0 ) : 0;
			if ( $size < 0 || $size > $limits['max_file_bytes'] ) {
				$this->log_extract_rejection(
					sprintf(
						'file "%1$s" (%2$s) exceeds the single-file limit %3$s (raise via wpps_br_archive_max_file_bytes filter).',
						$this->truncate_for_log( $norm ),
						size_format( max( 0, $size ), 1 ),
						size_format( $limits['max_file_bytes'], 1 )
					)
				);
				$z->close();
				return false;
			}
			$uncompressed_total += $size;
			if ( $uncompressed_total > $limits['max_extracted_bytes'] ) {
				$this->log_extract_rejection(
					sprintf(
						'total uncompressed size exceeds %s (raise via wpps_br_archive_max_extracted_bytes filter).',
						size_format( $limits['max_extracted_bytes'], 1 )
					)
				);
				$z->close();
				return false;
			}

			$rel = $prefix_slash !== '' && str_starts_with( $norm, $prefix_slash )
				? substr( $norm, $prefix_len )
				: ltrim( $norm, '/' );
			$rel = ltrim( str_replace( '\\', '/', (string) $rel ), '/' );
			$target = $rel === '' ? $dest_root : wp_normalize_path( $dest_root . '/' . $rel );
			if ( ! str_starts_with( $target, $dest_root . '/' ) && $target !== $dest_root ) {
				$this->log_extract_rejection( sprintf( 'entry "%s" would escape the staging directory.', $this->truncate_for_log( $norm ) ) );
				$z->close();
				return false;
			}
			$abs = wp_normalize_path( $extract_to . '/' . $norm );
			if ( ! str_starts_with( $abs, $extract_to . '/' ) && $abs !== $extract_to ) {
				$this->log_extract_rejection( sprintf( 'entry "%s" would escape the extraction directory.', $this->truncate_for_log( $norm ) ) );
				$z->close();
				return false;
			}
		}

		if ( $archive_size > 0 && $uncompressed_total > 0 ) {
			$ratio = $uncompressed_total / max( 1, $archive_size );
			if ( $ratio > $limits['max_ratio'] ) {
				$this->log_extract_rejection(
					sprintf(
						'compression ratio %1$s:1 exceeds %2$s:1 — possible zip bomb (raise via wpps_br_archive_max_compression_ratio filter if legitimate).',
						number_format_i18n( $ratio, 1 ),
						number_format_i18n( $limits['max_ratio'], 1 )
					)
				);
				$z->close();
				return false;
			}
		}

		$z->close();

		// Free-space preflight: refuse to start an extraction that cannot fit.
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		$free = function_exists( 'disk_free_space' ) ? @disk_free_space( $extract_to ) : false;
		if ( false !== $free && $free > 0 && $uncompressed_total + 268435456 > (float) $free ) {
			$this->log_extract_rejection(
				sprintf(
					'not enough disk space: extraction needs %1$s plus working margin, only %2$s free.',
					size_format( $uncompressed_total, 1 ),
					size_format( (int) $free, 1 )
				)
			);
			return false;
		}

		if ( $entry_count < 1 ) {
			$this->log_extract_rejection( sprintf( 'no entries found under archive prefix "%s".', $zip_prefix ) );
		}

		return $entry_count > 0;
	}

	/**
	 * Shorten a path for a log line.
	 */
	private function truncate_for_log( string $text ): string {
		return strlen( $text ) > 160 ? substr( $text, 0, 157 ) . '…' : $text;
	}

	/**
	 * Ensure every realpath under $root stays inside $root (Zip Slip guard after system unzip).
	 */
	private function assert_extracted_tree_inside_root( string $root ): bool {
		$root_real = realpath( $root );
		if ( ! is_string( $root_real ) || $root_real === '' || ! is_dir( $root_real ) ) {
			return false;
		}
		$root_norm = wp_normalize_path( $root_real );

		try {
			$iterator = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $root_real, \FilesystemIterator::SKIP_DOTS ),
				\RecursiveIteratorIterator::SELF_FIRST
			);
		} catch ( \Throwable $e ) {
			return false;
		}

		foreach ( $iterator as $item ) {
			$path = $item->getPathname();
			$real = realpath( $path );
			if ( ! is_string( $real ) || $real === '' ) {
				return false;
			}
			$norm = wp_normalize_path( $real );
			if ( $norm !== $root_norm && ! str_starts_with( $norm, $root_norm . '/' ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Chunk/archive tuning (filterable; clamped for safety on shared hosts).
	 *
	 * @return array{max_files: int, max_seconds: int, max_bytes: int, inner_flushes: int, large_file_bytes: int}
	 */
	private function archive_chunk_limits(): array {
		$max_files = (int) apply_filters( 'wpps_br_archive_chunk_max_files', self::ARCHIVE_CHUNK_MAX_FILES );
		$max_files = max( 100, min( 2000, $max_files ) );

		$max_seconds = (int) apply_filters( 'wpps_br_archive_chunk_max_seconds', self::ARCHIVE_CHUNK_MAX_SECONDS );
		$max_seconds = max( 20, min( 120, $max_seconds ) );

		$max_bytes = (int) apply_filters( 'wpps_br_archive_chunk_max_bytes', self::ARCHIVE_CHUNK_MAX_BYTES );
		$max_bytes = max( 5242880, min( 104857600, $max_bytes ) );

		$inner = (int) apply_filters( 'wpps_br_archive_chunk_inner_flushes', self::ARCHIVE_CHUNK_INNER_FLUSHES );
		$inner = max( 1, min( 20, $inner ) );

		return [
			'max_files'        => $max_files,
			'max_seconds'      => $max_seconds,
			'max_bytes'        => $max_bytes,
			'inner_flushes'    => $inner,
			'large_file_bytes' => self::ARCHIVE_LARGE_FILE_BYTES,
		];
	}

	/**
	 * Whether shell helpers (proc_open / shell_exec) are usable for system zip.
	 */
	private function archive_shell_available(): bool {
		$dis = ini_get( 'disable_functions' );
		if ( ! is_string( $dis ) ) {
			$dis = '';
		}
		$dis_lower = strtolower( $dis );
		$proc_ok   = function_exists( 'proc_open' ) && ! str_contains( $dis_lower, 'proc_open' );
		$shell_ok  = function_exists( 'shell_exec' ) && ! str_contains( $dis_lower, 'shell_exec' );

		/**
		 * @param bool $allow Default true when proc_open or shell_exec is allowed.
		 */
		return (bool) apply_filters( 'wpps_br_allow_system_zip', $proc_ok || $shell_ok );
	}

	/**
	 * Skip dev/build trees that are not needed to run WordPress (vendor/ is kept for Composer plugins).
	 */
	private function relative_path_should_skip_archive_bloat( string $rel ): bool {
		if ( $this->relative_path_contains_vcs_metadata( $rel ) ) {
			return true;
		}
		$rel = str_replace( '\\', '/', $rel );
		$skip_segments = apply_filters(
			'wpps_br_archive_skip_path_segments',
			[
				'node_modules',
				'bower_components',
				'__macosx',
				'.npm',
				'.yarn',
				'.pnpm-store',
				'.cache',
				'.sass-cache',
				'.parcel-cache',
				'.nyc_output',
				'coverage',
			],
			$rel
		);
		foreach ( explode( '/', $rel ) as $seg ) {
			if ( $seg === '' ) {
				continue;
			}
			foreach ( $skip_segments as $skip ) {
				if ( is_string( $skip ) && strcasecmp( $seg, $skip ) === 0 ) {
					return true;
				}
			}
		}

		/**
		 * @param bool   $skip Default false.
		 * @param string $rel  Path relative to the archive root.
		 */
		return (bool) apply_filters( 'wpps_br_skip_archive_path', false, $rel );
	}

	/**
	 * System zip can only preserve plugins/ or themes/ prefixes when the source lives under wp-content/{folder}.
	 */
	private function can_system_zip_wp_content_tree( string $source_dir, string $wp_content_folder ): bool {
		$source   = rtrim( wp_normalize_path( $source_dir ), '/' );
		$content  = rtrim( wp_normalize_path( WP_CONTENT_DIR ), '/' );
		$expected = $content . '/' . trim( $wp_content_folder, '/' );

		return $source === $expected;
	}

	/**
	 * @return list<string>
	 */
	private function system_zip_exclude_globs(): array {
		$globs = [
			'*/node_modules/*',
			'*/bower_components/*',
			'*/.git/*',
			'*/.svn/*',
			'*/.hg/*',
			'*/__MACOSX/*',
			'*/.cache/*',
			'*/.sass-cache/*',
			'*/.parcel-cache/*',
			'*/.nyc_output/*',
			'*/coverage/*',
		];

		/** @var list<string> $globs */
		$globs = apply_filters( 'wpps_br_system_zip_exclude_globs', $globs );

		return array_values( array_filter( $globs, 'is_string' ) );
	}

	private function validate_zip_archive_file( string $zip_path ): bool {
		if ( ! is_file( $zip_path ) ) {
			return false;
		}
		$size = (int) filesize( $zip_path );
		if ( $size < 22 ) {
			return false;
		}
		if ( ! class_exists( 'ZipArchive' ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
			$head = file_get_contents( $zip_path, false, null, 0, 4 );

			return is_string( $head ) && str_starts_with( $head, "PK\x03\x04" );
		}
		$z = new \ZipArchive();
		$ok = $z->open( $zip_path, \ZipArchive::RDONLY );
		if ( true !== $ok ) {
			return false;
		}
		$z->close();

		return true;
	}

	/**
	 * Run Info-ZIP compatible binary from wp-content to produce plugins.zip or themes.zip.
	 */
	private function try_system_zip_wp_content_tree( string $zip_path, string $source_dir, string $wp_content_folder, string $log_label ): bool {
		if ( ! apply_filters( 'wpps_br_use_system_zip', true, $wp_content_folder, $source_dir ) ) {
			return false;
		}
		if ( ! $this->can_system_zip_wp_content_tree( $source_dir, $wp_content_folder ) ) {
			return false;
		}
		if ( ! $this->archive_shell_available() ) {
			return false;
		}
		$bin = $this->find_system_zip_binary();
		if ( ! $bin ) {
			return false;
		}

		$content = rtrim( wp_normalize_path( WP_CONTENT_DIR ), '/' );
		$folder  = trim( $wp_content_folder, '/' ) . '/';
		$zip_abs = wp_normalize_path( $zip_path );

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

		$cmd_parts = [
			escapeshellarg( $bin ),
			'-r',
			'-q',
			'-X',
			escapeshellarg( $zip_abs ),
			escapeshellarg( $folder ),
		];
		foreach ( $this->system_zip_exclude_globs() as $glob ) {
			$cmd_parts[] = '-x';
			$cmd_parts[] = escapeshellarg( $glob );
		}
		$cmd = implode( ' ', $cmd_parts );

		$this->log_line(
			sprintf(
				/* translators: 1: archive label (Plugins/Themes), 2: zip binary path */
				__( '%1$s archive: creating with system zip (%2$s).', 'wppowersuite-lite' ),
				$log_label,
				$bin
			)
		);

		$stderr = '';
		$code   = $this->run_shell_command_in_directory( $cmd, $content, 600, $stderr );
		if ( 0 !== $code || ! $this->validate_zip_archive_file( $zip_abs ) ) {
			if ( is_file( $zip_abs ) ) {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				@unlink( $zip_abs );
			}
			$detail = $stderr !== '' ? ' ' . trim( $stderr ) : '';
			$this->log_line(
				sprintf(
					/* translators: 1: archive label, 2: optional error detail */
					__( '%1$s archive: system zip failed; falling back to ZipArchive.%2$s', 'wppowersuite-lite' ),
					$log_label,
					$detail
				)
			);

			return false;
		}

		$this->log_line(
			sprintf(
				/* translators: 1: archive label, 2: archive size */
				__( '%1$s archive: system zip finished (%2$s).', 'wppowersuite-lite' ),
				$log_label,
				size_format( (int) filesize( $zip_abs ), 1 )
			)
		);

		return true;
	}

	/**
	 * @param-out string $stderr Combined stderr output.
	 */
	private function run_shell_command_in_directory( string $command, string $cwd, int $timeout_seconds, string &$stderr ): int {
		$stderr         = '';
		$timeout_seconds = max( 30, min( 900, $timeout_seconds ) );
		$cwd            = rtrim( wp_normalize_path( $cwd ), '/' );
		if ( ! is_dir( $cwd ) ) {
			return 127;
		}

		$dis = ini_get( 'disable_functions' );
		if ( ! is_string( $dis ) ) {
			$dis = '';
		}
		$dis_lower = strtolower( $dis );
		if ( function_exists( 'proc_open' ) && ! str_contains( $dis_lower, 'proc_open' ) ) {
			$descriptors = [
				0 => [ 'pipe', 'r' ],
				1 => [ 'pipe', 'w' ],
				2 => [ 'pipe', 'w' ],
			];
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_proc_open
			$proc = @proc_open( $command, $descriptors, $pipes, $cwd );
			if ( is_resource( $proc ) ) {
				fclose( $pipes[0] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				stream_set_blocking( $pipes[1], false );
				stream_set_blocking( $pipes[2], false );
				$stdout     = '';
				$deadline   = microtime( true ) + $timeout_seconds;
				$terminated = false;
				$exit_code  = 1;
				while ( true ) {
					$read   = [ $pipes[1], $pipes[2] ];
					$write  = null;
					$except = null;
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_stream_select
					$n = @stream_select( $read, $write, $except, 1 );
					if ( $n === false ) {
						break;
					}
					if ( $n > 0 ) {
						if ( in_array( $pipes[1], $read, true ) ) {
							$chunk = fread( $pipes[1], 8192 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
							if ( is_string( $chunk ) && $chunk !== '' ) {
								$stdout .= $chunk;
							}
						}
						if ( in_array( $pipes[2], $read, true ) ) {
							$chunk = fread( $pipes[2], 8192 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
							if ( is_string( $chunk ) && $chunk !== '' ) {
								$stderr .= $chunk;
							}
						}
					}
					$status = proc_get_status( $proc );
					if ( is_array( $status ) && empty( $status['running'] ) ) {
						$exit_code = isset( $status['exitcode'] ) ? (int) $status['exitcode'] : 0;
						break;
					}
					if ( microtime( true ) >= $deadline ) {
						// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_proc_terminate
						@proc_terminate( $proc );
						$terminated = true;
						break;
					}
				}
				fclose( $pipes[1] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				fclose( $pipes[2] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				proc_close( $proc );
				if ( $terminated ) {
					return 124;
				}

				return $exit_code ?? 0;
			}
		}

		if ( function_exists( 'shell_exec' ) && ! str_contains( $dis_lower, 'shell_exec' ) ) {
			$wrapped = 'cd ' . escapeshellarg( $cwd ) . ' && ' . $command;
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_call_shell_exec
			$out = @shell_exec( $wrapped . ' 2>&1' );
			if ( is_string( $out ) && $out !== '' ) {
				$stderr = $out;
			}

			return 0;
		}

		return 127;
	}

	/**
	 * One ZipArchive open/add/close cycle for a line-delimited file queue.
	 *
	 * @param array<string, mixed> $state
	 * @return array{added: int, done: bool}
	 */
	private function archive_zip_add_queue_batch(
		string $zip_path,
		string $queue_path,
		string $source_dir,
		string $zip_prefix,
		array &$state,
		string $offset_key,
		int $total_lines,
		string $log_component,
		array $limits
	): array {
		$offset      = isset( $state[ $offset_key ] ) ? (int) $state[ $offset_key ] : 0;
		$source_dir  = trailingslashit( wp_normalize_path( $source_dir ) );
		$source_root = rtrim( $source_dir, '/' );
		$zip_prefix  = trailingslashit( str_replace( '\\', '/', $zip_prefix ) );

		$z = new \ZipArchive();
		$flags = 0 === $offset
			? ( \ZipArchive::CREATE | \ZipArchive::OVERWRITE )
			: \ZipArchive::CREATE;
		if ( 0 !== $offset && ! is_file( $zip_path ) ) {
			throw new \Exception(
				sprintf(
					/* translators: %s: archive component name */
					__( '%s archive was missing mid-job; please start the backup again.', 'wppowersuite-lite' ),
					$log_component
				)
			);
		}
		if ( true !== $z->open( $zip_path, $flags ) ) {
			throw new \Exception(
				sprintf(
					/* translators: %s: archive component name */
					__( 'Could not open %s zip for writing.', 'wppowersuite-lite' ),
					$log_component
				)
			);
		}

		$batch_start  = microtime( true );
		$added        = 0;
		$batch_bytes  = 0;
		$line_index   = 0;
		$large_bytes  = (int) $limits['large_file_bytes'];
		$max_files    = (int) $limits['max_files'];
		$max_bytes    = (int) $limits['max_bytes'];
		$max_seconds  = (int) $limits['max_seconds'];

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$qh = fopen( $queue_path, 'rb' );
		if ( false === $qh ) {
			$z->close();
			throw new \Exception(
				sprintf(
					/* translators: %s: archive component name */
					__( 'Could not read %s file queue.', 'wppowersuite-lite' ),
					$log_component
				)
			);
		}

		try {
			while ( ( $line = fgets( $qh ) ) !== false ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fgets
				if ( $line_index < $offset ) {
					$line_index++;
					continue;
				}
				$rel = trim( (string) $line );
				if ( $rel === '' ) {
					$line_index++;
					$state[ $offset_key ] = $line_index;
					continue;
				}
				$full = wp_normalize_path( $source_dir . str_replace( '\\', '/', $rel ) );
				if ( ! str_starts_with( $full, $source_root ) ) {
					$line_index++;
					$state[ $offset_key ] = $line_index;
					continue;
				}
				$fsize = ( is_readable( $full ) && is_file( $full ) ) ? (int) filesize( $full ) : 0;
				if ( $added > 0 && $fsize > $large_bytes ) {
					break;
				}
				$zip_rel = $zip_prefix . ltrim( str_replace( '\\', '/', $rel ), '/' );
				if ( is_readable( $full ) && is_file( $full ) ) {
					if ( ! $z->addFile( $full, $zip_rel ) ) {
						$this->log_line( sprintf( 'Skipped file (not readable): %s', $full ) );
					} else {
						$this->zip_set_compression_for_file( $z, $zip_rel, $full );
						$batch_bytes += $fsize;
					}
				}
				$added++;
				$line_index++;
				$state[ $offset_key ] = $line_index;
				if ( $fsize > $large_bytes ) {
					break;
				}
				if ( $added >= $max_files ) {
					break;
				}
				if ( $batch_bytes >= $max_bytes ) {
					break;
				}
				if ( ( microtime( true ) - $batch_start ) >= min( 12, $max_seconds ) ) {
					break;
				}
			}
		} finally {
			fclose( $qh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			$z->close();
		}

		$next_off = (int) $state[ $offset_key ];
		$done     = $next_off >= $total_lines;
		if ( $added > 0 ) {
			$this->log_line(
				sprintf(
					/* translators: 1: component, 2: batch size, 3: progress, 4: total */
					__( '%1$s archive: batch added %2$d file(s); progress %3$d / %4$d.', 'wppowersuite-lite' ),
					$log_component,
					$added,
					min( $next_off, $total_lines ),
					$total_lines
				)
			);
		}

		return [
			'added' => $added,
			'done'  => $done,
		];
	}

	/**
	 * Process multiple ZipArchive flushes in one HTTP tick when chunked fallback is used.
	 *
	 * @param array<string, mixed> $state
	 */
	private function archive_zip_queue_chunk_tick(
		string $zip_path,
		string $queue_path,
		string $source_dir,
		string $zip_prefix,
		array &$state,
		string $offset_key,
		string $total_key,
		string $progress_key,
		string $log_component,
		string $progress_template
	): bool {
		$limits        = $this->archive_chunk_limits();
		$total_lines   = (int) ( $state[ $total_key ] ?? 0 );
		$tick_deadline = microtime( true ) + $limits['max_seconds'];
		$flushes       = 0;
		$done          = false;

		while ( $flushes < $limits['inner_flushes'] && microtime( true ) < $tick_deadline ) {
			$batch = $this->archive_zip_add_queue_batch(
				$zip_path,
				$queue_path,
				$source_dir,
				$zip_prefix,
				$state,
				$offset_key,
				$total_lines,
				$log_component,
				$limits
			);
			$flushes++;
			$done = $batch['done'];
			if ( $done || $batch['added'] === 0 ) {
				break;
			}
		}

		$next_off = (int) ( $state[ $offset_key ] ?? 0 );
		$state[ $progress_key ] = sprintf(
			$progress_template,
			min( $next_off, max( 1, $total_lines ) ),
			max( 1, $total_lines )
		);

		return $done;
	}

	private function relative_path_contains_vcs_metadata( string $rel ): bool {
		$rel = str_replace( '\\', '/', $rel );
		foreach ( explode( '/', $rel ) as $seg ) {
			$lower = strtolower( $seg );
			if ( in_array( $lower, [ '.git', '.svn', '.hg', '.bzr' ], true ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Directory prefixes to skip when archiving “other” wp-content (plugins/themes/uploads/backup dir + common junk).
	 *
	 * @return list<string> Normalized paths without trailing slash.
	 */
	private function others_wp_content_exclude_directories( string $content ): array {
		$content_root = rtrim( wp_normalize_path( trailingslashit( $content ) ), '/' );
		$paths        = [
			wp_normalize_path( trailingslashit( WP_PLUGIN_DIR ) ),
			wp_normalize_path( trailingslashit( get_theme_root() ) ),
			wp_normalize_path( trailingslashit( wp_upload_dir()['basedir'] ) ),
			wp_normalize_path( trailingslashit( $this->get_backup_root() ) ),
			wp_normalize_path( trailingslashit( $content ) . PrivateStorageResolver::BACKUP_LEAF ),
			wp_normalize_path( trailingslashit( $content ) . PrivateStorageResolver::PRIVATE_LEAF ),
			wp_normalize_path( trailingslashit( $content ) . PrivateStorageResolver::STAGING_LEAF ),
		];
		foreach ( $this->get_legacy_backup_roots() as $legacy ) {
			$paths[] = wp_normalize_path( trailingslashit( $legacy ) );
		}
		$private = $this->get_private_storage_root();
		if ( $private !== '' ) {
			$paths[] = wp_normalize_path( trailingslashit( $private ) );
		}
		$exclude = [];
		foreach ( $paths as $p ) {
			$p = rtrim( wp_normalize_path( $p ), '/' );
			if ( $p && str_starts_with( $p, $content_root ) ) {
				$exclude[] = $p;
			}
		}
		return array_values( array_unique( $exclude ) );
	}

	/**
	 * Write a line-delimited list of paths relative to wp-content.
	 *
	 * @return int Number of files queued.
	 */
	/**
	 * Resumable others queue build (BFS directory walk with time budget).
	 *
	 * @param array<string, mixed> $state
	 * @return bool True when the queue file is complete.
	 */
	private function build_others_file_queue_tick( string $job_dir, string $content, string $exclude_others_csv, array &$state ): bool {
		$content      = wp_normalize_path( trailingslashit( $content ) );
		$content_root = rtrim( $content, '/' );
		$queue_path   = trailingslashit( $job_dir ) . self::OTHERS_QUEUE_BASENAME;

		if ( empty( $state['others_queue_building'] ) ) {
			$this->log_line( __( 'Others: building file queue using your Settings exclusion list.', 'wppowersuite-lite' ) );
			if ( $exclude_others_csv !== '' ) {
				$this->log_line( sprintf( 'Others exclusion tokens: %s', $exclude_others_csv ) );
			}
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
			$fh = fopen( $queue_path, 'wb' );
			if ( false === $fh ) {
				throw new \Exception( __( 'Could not write others file queue.', 'wppowersuite-lite' ) );
			}
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			$state['others_queue_building']   = true;
			$state['others_queue_build_stack'] = [ $content_root ];
			$state['others_queue_build_count'] = 0;
			$state['others_queue_build_bytes'] = 0;
		}

		$stack   = isset( $state['others_queue_build_stack'] ) && is_array( $state['others_queue_build_stack'] ) ? $state['others_queue_build_stack'] : [];
		$count   = isset( $state['others_queue_build_count'] ) ? (int) $state['others_queue_build_count'] : 0;
		$qbytes  = isset( $state['others_queue_build_bytes'] ) ? (int) $state['others_queue_build_bytes'] : 0;
		$exclude = $this->others_wp_content_exclude_directories( $content );
		$deadline = microtime( true ) + self::ARCHIVE_CHUNK_MAX_SECONDS;

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$fh = fopen( $queue_path, 'ab' );
		if ( false === $fh ) {
			throw new \Exception( __( 'Could not write others file queue.', 'wppowersuite-lite' ) );
		}

		try {
			while ( $stack !== [] && microtime( true ) < $deadline ) {
				$dir = array_pop( $stack );
				if ( ! is_string( $dir ) || $dir === '' || ! is_dir( $dir ) ) {
					continue;
				}
				$dir_n = rtrim( wp_normalize_path( $dir ), '/' );
				foreach ( $exclude as $ex_dir ) {
					if ( $this->path_is_excluded( $dir_n, wp_normalize_path( $ex_dir ) ) ) {
						continue 2;
					}
				}
				$rel_dir = ltrim( substr( $dir_n, strlen( $content_root ) ), '/' );
				if ( $rel_dir !== '' && $this->relative_path_contains_vcs_metadata( $rel_dir ) ) {
					continue;
				}
				if ( $rel_dir !== '' && $exclude_others_csv !== '' && $this->relative_path_matches_exclude_csv( $rel_dir, 'others', $exclude_others_csv ) ) {
					continue;
				}

				$entries = @scandir( $dir_n ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( ! is_array( $entries ) ) {
					continue;
				}
				foreach ( $entries as $entry ) {
					if ( '.' === $entry || '..' === $entry ) {
						continue;
					}
					$full = $dir_n . '/' . $entry;
					$full = wp_normalize_path( $full );
					if ( is_dir( $full ) && ! is_link( $full ) ) {
						$stack[] = $full;
						continue;
					}
					if ( ! is_file( $full ) || is_link( $full ) ) {
						continue;
					}
					if ( ! $this->backup_path_is_allowed_file( $full, $content_root ) ) {
						continue;
					}
					$rel = ltrim( substr( $full, strlen( $content_root ) ), '/' );
					if ( $rel === '' || $this->relative_path_contains_vcs_metadata( $rel ) ) {
						continue;
					}
					if ( $exclude_others_csv !== '' && $this->relative_path_matches_exclude_csv( $rel, 'others', $exclude_others_csv ) ) {
						continue;
					}
					$qbytes += (int) @filesize( $full ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					fwrite( $fh, $rel . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
					++$count;
				}
			}
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

		$state['others_queue_build_stack'] = $stack;
		$state['others_queue_build_count'] = $count;
		$state['others_queue_build_bytes'] = $qbytes;

		if ( $stack !== [] ) {
			$state['others_progress_label'] = sprintf(
				/* translators: %d: files queued so far */
				__( 'Scanning other wp-content files… %d queued', 'wppowersuite-lite' ),
				$count
			);
			return false;
		}

		$state['others_queue_total']  = $count;
		$state['others_queue_offset'] = 0;
		unset( $state['others_queue_building'], $state['others_queue_build_stack'], $state['others_queue_build_count'], $state['others_queue_build_bytes'] );
		$this->log_line(
			sprintf(
				'Others: queued %d files (%s).',
				$count,
				size_format( $qbytes, 1 )
			)
		);

		return true;
	}

	/**
	 * Add the next slice of “others” files so each AJAX tick stays within proxy / PHP-friendly bounds.
	 *
	 * @param array<string, mixed> $state
	 */
	private function zip_others_wp_content_chunked( string $zip_path, string $job_dir, array &$state ): bool {
		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new \Exception( __( 'PHP ZipArchive is required to archive “other” wp-content items. Please enable the zip extension.', 'wppowersuite-lite' ) );
		}

		$content    = wp_normalize_path( trailingslashit( WP_CONTENT_DIR ) );
		$queue_path = trailingslashit( $job_dir ) . self::OTHERS_QUEUE_BASENAME;

		$total_lines = isset( $state['others_queue_total'] ) ? (int) $state['others_queue_total'] : 0;

		$needs_queue = ! empty( $state['others_queue_building'] )
			|| ! is_file( $queue_path )
			|| ( ! isset( $state['others_queue_total'] ) && ! isset( $state['others_queue_offset'] ) );

		if ( $needs_queue ) {
			if ( empty( $state['others_queue_building'] ) ) {
				if ( is_file( $queue_path ) ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					@unlink( $queue_path );
				}
				if ( is_file( $zip_path ) ) {
					// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
					@unlink( $zip_path );
				}
			}
			$ex_oth = isset( $state['exclude_others_csv'] ) && is_string( $state['exclude_others_csv'] )
				? $state['exclude_others_csv']
				: self::default_exclude_others();
			if ( ! $this->build_others_file_queue_tick( $job_dir, $content, $ex_oth, $state ) ) {
				return false;
			}
			$total_lines = isset( $state['others_queue_total'] ) ? (int) $state['others_queue_total'] : 0;

			if ( 0 === $total_lines ) {
				$this->log_line( __( 'Others archive: nothing to include (all paths excluded).', 'wppowersuite-lite' ) );
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not open others.zip for writing.', 'wppowersuite-lite' ) );
				}
				$z->addFromString(
					'others/readme.txt',
					"No additional files under wp-content were archived (exclusions only).\n"
				);
				$z->close();
				return true;
			}

			$this->log_line(
				sprintf(
					/* translators: %d: file count */
					__( 'Others archive: queued %d file(s) under wp-content.', 'wppowersuite-lite' ),
					$total_lines
				)
			);
		}

		return $this->archive_zip_queue_chunk_tick(
			$zip_path,
			$queue_path,
			$content,
			'others/',
			$state,
			'others_queue_offset',
			'others_queue_total',
			'others_progress_label',
			'Others',
			/* translators: 1: processed lines, 2: total queued files */
			__( 'Creating archive (Other wp-content)… %1$d / %2$d files', 'wppowersuite-lite' )
		);
	}

	/**
	 * Chunked uploads ZIP — mirrors the others chunking to survive PHP-FPM timeouts.
	 */
	private function zip_uploads_chunked( string $zip_path, string $job_dir, array &$state ): bool {
		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new \Exception( __( 'PHP ZipArchive is required.', 'wppowersuite-lite' ) );
		}

		$uploads_dir = trailingslashit( wp_normalize_path( wp_upload_dir()['basedir'] ) );
		$queue_path  = trailingslashit( $job_dir ) . self::UPLOADS_QUEUE_BASENAME;

		$total_lines = isset( $state['uploads_queue_total'] ) ? (int) $state['uploads_queue_total'] : 0;

		$needs_queue = ! empty( $state['uploads_queue_building'] )
			|| ! is_file( $queue_path )
			|| ( ! isset( $state['uploads_queue_total'] ) && ! isset( $state['uploads_queue_offset'] ) );

		if ( $needs_queue ) {
			if ( empty( $state['uploads_queue_building'] ) ) {
				if ( is_file( $queue_path ) ) {
					@unlink( $queue_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				}
				if ( is_file( $zip_path ) ) {
					@unlink( $zip_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				}
			}
			$ex_up = isset( $state['exclude_uploads_csv'] ) && is_string( $state['exclude_uploads_csv'] )
				? $state['exclude_uploads_csv']
				: self::default_exclude_uploads();
			if ( ! $this->build_uploads_file_queue_tick( $queue_path, $uploads_dir, $ex_up, $state ) ) {
				return false;
			}
			$total_lines = isset( $state['uploads_queue_total'] ) ? (int) $state['uploads_queue_total'] : 0;
			$state['uploads_queue_total']  = $total_lines;
			$state['uploads_queue_offset'] = 0;

			if ( 0 === $total_lines ) {
				$this->log_line( __( 'Uploads archive: nothing to include.', 'wppowersuite-lite' ) );
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not open uploads.zip for writing.', 'wppowersuite-lite' ) );
				}
				$z->addFromString( 'uploads/readme.txt', "No uploads files were archived.\n" );
				$z->close();
				return true;
			}

			$this->log_line(
				sprintf(
					__( 'Uploads archive: queued %d file(s).', 'wppowersuite-lite' ),
					$total_lines
				)
			);
		}

		return $this->archive_zip_queue_chunk_tick(
			$zip_path,
			$queue_path,
			$uploads_dir,
			'uploads/',
			$state,
			'uploads_queue_offset',
			'uploads_queue_total',
			'uploads_progress_label',
			'Uploads',
			__( 'Creating archive (Uploads)… %1$d / %2$d files', 'wppowersuite-lite' )
		);
	}

	/**
	 * Line-delimited paths relative to $root_dir (VCS + dev bloat skipped). Shared by chunked plugins/themes archives.
	 */
	private function build_vcs_filtered_file_queue( string $queue_path, string $root_dir ): int {
		$root_dir    = trailingslashit( wp_normalize_path( $root_dir ) );
		$source_root = rtrim( $root_dir, '/' );
		$count       = 0;
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$fh = fopen( $queue_path, 'wb' );
		if ( false === $fh ) {
			throw new \Exception( __( 'Could not create file queue for archive.', 'wppowersuite-lite' ) );
		}
		$rit_flags = \FilesystemIterator::SKIP_DOTS;
		$inner     = new \RecursiveDirectoryIterator( $root_dir, $rit_flags );
		$filtered  = new \RecursiveCallbackFilterIterator(
			$inner,
			function ( $current ) use ( $source_root ) {
				$full = wp_normalize_path( $current->getPathname() );
				if ( ! str_starts_with( $full, $source_root ) ) {
					return false;
				}
				$rel = ltrim( substr( $full, strlen( $source_root ) ), '/' );
				return ! $this->relative_path_should_skip_archive_bloat( $rel );
			}
		);
		$it = new \RecursiveIteratorIterator( $filtered, \RecursiveIteratorIterator::SELF_FIRST );
		try {
			foreach ( $it as $file ) {
				if ( ! $file->isFile() ) {
					continue;
				}
				$full = wp_normalize_path( $file->getPathname() );
				if ( ! $this->backup_path_is_allowed_file( $full, $source_root ) ) {
					continue;
				}
				if ( ! str_starts_with( $full, $source_root ) ) {
					continue;
				}
				$rel = ltrim( substr( $full, strlen( $source_root ) ), '/' );
				if ( $this->relative_path_should_skip_archive_bloat( $rel ) ) {
					continue;
				}
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
				fwrite( $fh, $rel . "\n" );
				$count++;
			}
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}
		return $count;
	}

	/**
	 * Chunked plugins.zip — avoids one long Zip pass hitting max_execution_time on huge wp-content/plugins trees.
	 *
	 * @param array<string, mixed> $state
	 */
	private function zip_plugins_chunked( string $zip_path, string $job_dir, array &$state ): bool {
		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new \Exception( __( 'PHP ZipArchive is required.', 'wppowersuite-lite' ) );
		}
		$plugins_dir = trailingslashit( wp_normalize_path( WP_PLUGIN_DIR ) );
		$queue_path  = trailingslashit( $job_dir ) . self::PLUGINS_QUEUE_BASENAME;
		$needs_queue = ! is_file( $queue_path )
			|| ( ! isset( $state['plugins_queue_total'] ) && ! isset( $state['plugins_queue_offset'] ) );

		if ( $needs_queue ) {
			if ( is_file( $queue_path ) ) {
				@unlink( $queue_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			}
			if ( is_file( $zip_path ) ) {
				@unlink( $zip_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			}
			if ( ! is_dir( $plugins_dir ) ) {
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not create plugins.zip.', 'wppowersuite-lite' ) );
				}
				$z->addFromString( 'plugins/readme.txt', "Plugin directory was not found during backup.\n" );
				$z->close();
				return true;
			}
			if ( $this->try_system_zip_wp_content_tree( $zip_path, $plugins_dir, 'plugins', 'Plugins' ) ) {
				return true;
			}
			$total_lines = $this->build_vcs_filtered_file_queue( $queue_path, $plugins_dir );
			$state['plugins_queue_total']  = $total_lines;
			$state['plugins_queue_offset'] = 0;
			if ( 0 === $total_lines ) {
				$this->log_line( __( 'Plugins archive: nothing to include.', 'wppowersuite-lite' ) );
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not open plugins.zip for writing.', 'wppowersuite-lite' ) );
				}
				$z->addFromString( 'plugins/readme.txt', "No plugin files were archived.\n" );
				$z->close();
				return true;
			}
			$this->log_line(
				sprintf(
					/* translators: %d: file count */
					__( 'Plugins archive: queued %d file(s).', 'wppowersuite-lite' ),
					$total_lines
				)
			);
		}

		return $this->archive_zip_queue_chunk_tick(
			$zip_path,
			$queue_path,
			$plugins_dir,
			'plugins/',
			$state,
			'plugins_queue_offset',
			'plugins_queue_total',
			'plugins_progress_label',
			'Plugins',
			/* translators: 1: current file index, 2: total files */
			__( 'Creating archive (Plugins)… %1$d / %2$d files', 'wppowersuite-lite' )
		);
	}

	/**
	 * Chunked themes.zip — same pattern as zip_plugins_chunked.
	 *
	 * @param array<string, mixed> $state
	 */
	private function zip_themes_chunked( string $zip_path, string $job_dir, array &$state ): bool {
		if ( ! class_exists( 'ZipArchive' ) ) {
			throw new \Exception( __( 'PHP ZipArchive is required.', 'wppowersuite-lite' ) );
		}
		$themes_root = trailingslashit( wp_normalize_path( get_theme_root() ) );
		$queue_path  = trailingslashit( $job_dir ) . self::THEMES_QUEUE_BASENAME;
		$needs_queue = ! is_file( $queue_path )
			|| ( ! isset( $state['themes_queue_total'] ) && ! isset( $state['themes_queue_offset'] ) );

		if ( $needs_queue ) {
			if ( is_file( $queue_path ) ) {
				@unlink( $queue_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			}
			if ( is_file( $zip_path ) ) {
				@unlink( $zip_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			}
			if ( ! is_dir( $themes_root ) ) {
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not create themes.zip.', 'wppowersuite-lite' ) );
				}
				$z->addFromString( 'themes/readme.txt', "Theme directory was not found during backup.\n" );
				$z->close();
				return true;
			}
			if ( $this->try_system_zip_wp_content_tree( $zip_path, $themes_root, 'themes', 'Themes' ) ) {
				return true;
			}
			$total_lines = $this->build_vcs_filtered_file_queue( $queue_path, $themes_root );
			$state['themes_queue_total']  = $total_lines;
			$state['themes_queue_offset'] = 0;
			if ( 0 === $total_lines ) {
				$this->log_line( __( 'Themes archive: nothing to include.', 'wppowersuite-lite' ) );
				$z = new \ZipArchive();
				if ( true !== $z->open( $zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
					throw new \Exception( __( 'Could not open themes.zip for writing.', 'wppowersuite-lite' ) );
				}
				$z->addFromString( 'themes/readme.txt', "No theme files were archived.\n" );
				$z->close();
				return true;
			}
			$this->log_line(
				sprintf(
					/* translators: %d: file count */
					__( 'Themes archive: queued %d file(s).', 'wppowersuite-lite' ),
					$total_lines
				)
			);
		}

		return $this->archive_zip_queue_chunk_tick(
			$zip_path,
			$queue_path,
			$themes_root,
			'themes/',
			$state,
			'themes_queue_offset',
			'themes_queue_total',
			'themes_progress_label',
			'Themes',
			/* translators: 1: current file index, 2: total files */
			__( 'Creating archive (Themes)… %1$d / %2$d files', 'wppowersuite-lite' )
		);
	}

	/**
	 * Build the file queue for chunked uploads archiving.
	 */
	/**
	 * Resumable uploads queue build.
	 *
	 * @param array<string, mixed> $state
	 * @return bool True when complete.
	 */
	private function build_uploads_file_queue_tick( string $queue_path, string $uploads_dir, string $exclude_csv, array &$state ): bool {
		$source_root = rtrim( wp_normalize_path( $uploads_dir ), '/' );

		if ( empty( $state['uploads_queue_building'] ) ) {
			$fh = fopen( $queue_path, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
			if ( false === $fh ) {
				throw new \Exception( __( 'Could not create uploads file queue.', 'wppowersuite-lite' ) );
			}
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			$state['uploads_queue_building']    = true;
			$state['uploads_queue_build_stack'] = [ $source_root ];
			$state['uploads_queue_build_count'] = 0;
		}

		$stack    = isset( $state['uploads_queue_build_stack'] ) && is_array( $state['uploads_queue_build_stack'] ) ? $state['uploads_queue_build_stack'] : [];
		$count    = isset( $state['uploads_queue_build_count'] ) ? (int) $state['uploads_queue_build_count'] : 0;
		$deadline = microtime( true ) + self::ARCHIVE_CHUNK_MAX_SECONDS;

		$fh = fopen( $queue_path, 'ab' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		if ( false === $fh ) {
			throw new \Exception( __( 'Could not create uploads file queue.', 'wppowersuite-lite' ) );
		}

		try {
			while ( $stack !== [] && microtime( true ) < $deadline ) {
				$dir = array_pop( $stack );
				if ( ! is_string( $dir ) || ! is_dir( $dir ) ) {
					continue;
				}
				$dir_n   = rtrim( wp_normalize_path( $dir ), '/' );
				$rel_dir = ltrim( substr( $dir_n, strlen( $source_root ) ), '/' );
				if ( $rel_dir !== '' && $this->relative_path_contains_vcs_metadata( $rel_dir ) ) {
					continue;
				}
				if ( $rel_dir !== '' && $exclude_csv !== '' && $this->relative_path_matches_exclude_csv( $rel_dir, 'uploads', $exclude_csv ) ) {
					continue;
				}
				$entries = @scandir( $dir_n ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( ! is_array( $entries ) ) {
					continue;
				}
				foreach ( $entries as $entry ) {
					if ( '.' === $entry || '..' === $entry ) {
						continue;
					}
					$full = wp_normalize_path( $dir_n . '/' . $entry );
					if ( is_dir( $full ) && ! is_link( $full ) ) {
						$stack[] = $full;
						continue;
					}
					if ( ! is_file( $full ) || is_link( $full ) ) {
						continue;
					}
					$rel = ltrim( substr( $full, strlen( $source_root ) ), '/' );
					if ( $rel === '' || $this->relative_path_contains_vcs_metadata( $rel ) ) {
						continue;
					}
					if ( $exclude_csv !== '' && $this->relative_path_matches_exclude_csv( $rel, 'uploads', $exclude_csv ) ) {
						continue;
					}
					fwrite( $fh, $rel . "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
					++$count;
				}
			}
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		}

		$state['uploads_queue_build_stack'] = $stack;
		$state['uploads_queue_build_count'] = $count;
		if ( $stack !== [] ) {
			$state['uploads_progress_label'] = sprintf(
				/* translators: %d: files queued */
				__( 'Scanning uploads… %d queued', 'wppowersuite-lite' ),
				$count
			);
			return false;
		}

		$state['uploads_queue_total']  = $count;
		$state['uploads_queue_offset'] = 0;
		unset( $state['uploads_queue_building'], $state['uploads_queue_build_stack'], $state['uploads_queue_build_count'] );

		return true;
	}

}
