<?php
/**
 * Zip archive writer with exclusion + compression-level support.
 *
 * Compression level (0–9) maps to ZipArchive's deflate strength. Already-
 * compressed file extensions (jpg, mp4, …) are detected and stored without
 * recompression regardless of the chosen level — this saves significant CPU
 * with effectively zero size impact.
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

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

class ArchiveWriter {

	/** @var string[] */
	private array $exclusions;

	private ?Logger $logger;

	/** Deflate level 0–9 (0 = store / no compression). */
	private int $level;

	/**
	 * Extensions that don't compress further. Storing them speeds up the build
	 * with no meaningful size cost.
	 */
	private const NOCOMPRESS_EXT = [
		'jpg','jpeg','png','gif','webp','heic','heif','avif',
		'mp3','m4a','ogg','opus','flac','aac',
		'mp4','m4v','mov','avi','mkv','webm',
		'zip','gz','bz2','xz','7z','rar','tar','tgz',
		'pdf','docx','xlsx','pptx',
	];

	/** Max uncompressed bytes per zip part (0 = no split). */
	private int $max_chunk_bytes;

	/**
	 * Absolute directory prefix that must never be archived, regardless of
	 * source. Used to skip the plugin's own backup storage when it lives
	 * inside a backed-up location (e.g. uploads/backreload). '' = no skip.
	 */
	private string $skip_abs;

	public function __construct( array $exclusions = [], ?Logger $logger = null, int $level = 6, int $max_chunk_mb = 0, string $skip_abs = '' ) {
		$this->exclusions      = $exclusions;
		$this->logger          = $logger;
		$this->level           = max( 0, min( 9, $level ) );
		$this->max_chunk_bytes = $max_chunk_mb > 0 ? $max_chunk_mb * 1048576 : 0;
		$this->skip_abs        = '' !== $skip_abs ? rtrim( $skip_abs, '/\\' ) : '';
	}

	/**
	 * Auto-tune chunk threshold based on PHP memory_limit.
	 *
	 * Design goal: ONE archive per category for typical WordPress sites.
	 * Splitting is the safety net for genuinely big sites — not the default
	 * behaviour. UpdraftPlus, BackupBuddy, etc. all default to ~400 MB and
	 * the result is what users expect (one plugins.zip, one themes.zip).
	 *
	 * We measure *uncompressed* source bytes, so the threshold has to be
	 * generous — most WP source compresses 2–4×, so a 600 MB threshold here
	 * still produces a comfortable ~150–300 MB zip on disk.
	 *
	 * Memory footprint of the build is dominated by ZipArchive's file table
	 * + a 1 MB read buffer (we stream files in), not the archive contents
	 * themselves. So we can be much less conservative than the old 0.4×
	 * multiplier suggested.
	 *
	 *   128 MB host  → 200 MB threshold (floor)
	 *   256 MB       → 205 MB
	 *   512 MB       → 410 MB
	 *   1 GB         → 819 MB
	 *   2 GB+        → 1024 MB (cap)
	 */
	public static function recommended_chunk_mb(): int {
		$limit_str = (string) ini_get( 'memory_limit' );
		$bytes     = function_exists( 'wp_convert_hr_to_bytes' )
			? (int) wp_convert_hr_to_bytes( $limit_str )
			: 512 * 1048576;
		$mb = $bytes / 1048576;
		if ( $mb <= 0 || $mb > 99999 ) $mb = 512;          // -1 == unlimited
		return (int) max( 200, min( 1024, round( $mb * 0.8 ) ) );
	}

	/**
	 * Create one or more zip parts. Splits when the running uncompressed size
	 * would exceed the chunk threshold.
	 *
	 * @param string                $base_path  Without extension, e.g. "/tmp/bk_xxx-plugins"
	 *                                          → produces bk_xxx-plugins-1.zip, bk_xxx-plugins-2.zip, …
	 * @param array<int,string>     $sources    Absolute directory roots to include.
	 * @param array<string,string>  $extra      "abs_src" => "name_in_zip"
	 * @return array<int,array{name:string,size:int,checksum:string,files:int}>
	 *         Info for each part, in creation order.
	 */
	public function create_split( string $base_path, array $sources, array $extra = [] ): array {
		if ( ! class_exists( '\\ZipArchive' ) ) {
			throw new \RuntimeException( esc_html(  'PHP ZipArchive extension is required.'  )  );
		}

		$parts        = [];
		$part_index   = 1;
		$current_path = $this->part_path( $base_path, $part_index );
		$zip          = $this->open_part( $current_path );
		$running      = 0;
		$files_in_part = 0;

		$cut_part = function () use ( &$zip, &$current_path, &$part_index, &$parts, &$running, &$files_in_part, $base_path ) {
			if ( $files_in_part === 0 ) return; // empty — don't roll
			$zip->close();
			$parts[] = $this->summarize_part( $current_path, $files_in_part );
			$part_index++;
			$current_path = $this->part_path( $base_path, $part_index );
			$zip          = $this->open_part( $current_path );
			$running      = 0;
			$files_in_part = 0;
		};

		$add = function ( string $abs, string $rel ) use ( &$zip, &$running, &$files_in_part, $cut_part ) {
			$size = (int) @filesize( $abs );

			// Roll to a new part if this file would push us over the threshold.
			// (Always allow at least one file per part — never split a single file.)
			if ( $this->max_chunk_bytes > 0
				&& $files_in_part > 0
				&& ( $running + $size ) > $this->max_chunk_bytes ) {
				$cut_part();
			}

			$this->add_file( $zip, $abs, $rel );
			$running       += $size;
			$files_in_part += 1;
		};

		try {
			foreach ( $sources as $root ) {
				$this->walk_into( $root, $add );
			}
			foreach ( $extra as $src => $name ) {
				if ( is_file( $src ) ) {
					$add( $src, $name );
				}
			}
		} finally {
			if ( $files_in_part > 0 ) {
				$zip->close();
				$parts[] = $this->summarize_part( $current_path, $files_in_part );
			} else {
				// No files added at all — close and remove the empty zip.
				$zip->close();
				@unlink( $current_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
			}
		}

		$this->logger && $this->logger->info(
			'Archive set built',
			[ 'parts' => count( $parts ), 'total_bytes' => array_sum( array_column( $parts, 'size' ) ) ]
		);
		return $parts;
	}

	private function part_path( string $base, int $index ): string {
		return $base . '-' . $index . '.zip';
	}

	/**
	 * Open a zip part for writing at a `.tmp` path. We rename to the final name
	 * only after a successful close + verify, so a crashed PHP process can
	 * never leave a corrupt "complete-looking" file behind.
	 */
	private function open_part( string $final_path ): \ZipArchive {
		$tmp_path = $final_path . '.tmp';
		// Clean any leftover .tmp from a previous crashed run.
		if ( file_exists( $tmp_path ) ) {
			@unlink( $tmp_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
		}
		$zip = new \ZipArchive();
		if ( true !== $zip->open( $tmp_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
			throw new \RuntimeException( esc_html(  "Cannot open archive for writing: {$tmp_path}"  )  );
		}
		return $zip;
	}

	/**
	 * After close(), verify the zip is internally consistent and rename to the
	 * final path. Throws if the archive is corrupt — caller can retry.
	 */
	private function summarize_part( string $final_path, int $file_count ): array {
		$tmp_path = $final_path . '.tmp';
		if ( ! is_file( $tmp_path ) ) {
			throw new \RuntimeException( esc_html(  "Archive part missing after close: {$tmp_path}"  )  );
		}

		// Reopen with CHECKCONS — fails if the central directory or file headers are malformed.
		$verify = new \ZipArchive();
		$open_status = $verify->open( $tmp_path, \ZipArchive::CHECKCONS );
		if ( true !== $open_status ) {
			@unlink( $tmp_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
			throw new \RuntimeException(  esc_html( "Archive failed consistency check (code {$open_status}): " . basename( $final_path ) )  );
		}
		$actual_files = $verify->numFiles;
		$verify->close();
		if ( $actual_files < $file_count ) {
			@unlink( $tmp_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
			throw new \RuntimeException(
				 esc_html( "Archive entry count mismatch ({$actual_files} found, {$file_count} expected): " . basename( $final_path ) ) 
			);
		}

		// Atomic rename — only NOW does the final path exist.
		if ( ! @rename( $tmp_path, $final_path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic rename of the plugin's own temp file into place; WP_Filesystem::move is non-atomic.
			@unlink( $tmp_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
			throw new \RuntimeException(  esc_html( "Failed to finalize archive: " . basename( $final_path ) )  );
		}

		return [
			'name'     => basename( $final_path ),
			'size'     => (int) ( @filesize( $final_path ) ?: 0 ),
			'checksum' => hash_file( 'sha256', $final_path ),
			'files'    => $file_count,
		];
	}

	/** Recursive walker that calls $callback($abs, $rel) for every non-excluded file. */
	private function walk_into( string $root, callable $callback ): void {
		if ( ! is_dir( $root ) ) {
			return;
		}
		$root = rtrim( $root, DIRECTORY_SEPARATOR );
		$base = dirname( $root );
		$it = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ),
			\RecursiveIteratorIterator::SELF_FIRST
		);
		foreach ( $it as $file ) {
			$abs = (string) $file;
			// Never archive the plugin's own storage dir, even when it sits
			// inside a backed-up location (e.g. uploads/backreload) — otherwise
			// we'd back up our own backups.
			if ( '' !== $this->skip_abs ) {
				$n_abs = str_replace( '\\', '/', $abs );
				$n_skip = str_replace( '\\', '/', $this->skip_abs );
				if ( $n_abs === $n_skip || 0 === strpos( $n_abs, $n_skip . '/' ) ) {
					continue;
				}
			}
			$rel = ltrim( str_replace( $base, '', $abs ), DIRECTORY_SEPARATOR );
			if ( $this->is_excluded( $rel ) ) continue;
			if ( is_dir( $abs ) ) continue; // skip dir entries — files inside carry their path
			$callback( $abs, $rel );
		}
	}

	/**
	 * Create a zip from a list of source roots (absolute paths) plus extra files (absolute -> archive name).
	 *
	 * @param string                $output_zip Absolute path of zip to write.
	 * @param array<int,string>     $sources    Absolute directory paths to recursively include.
	 * @param array<string,string>  $extra      Map of "absolute_source_path" => "name_inside_zip".
	 */
	public function create( string $output_zip, array $sources, array $extra = [] ): int {
		if ( ! class_exists( '\\ZipArchive' ) ) {
			throw new \RuntimeException( esc_html(  'PHP ZipArchive extension is required.'  )  );
		}

		$zip = new \ZipArchive();
		if ( true !== $zip->open( $output_zip, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
			throw new \RuntimeException( esc_html(  "Cannot open archive for writing: {$output_zip}"  )  );
		}

		$files = 0;
		try {
			foreach ( $sources as $root ) {
				$files += $this->add_directory( $zip, $root );
			}

			foreach ( $extra as $src => $name ) {
				if ( is_file( $src ) ) {
					$this->add_file( $zip, $src, $name );
					$files++;
				}
			}
		} finally {
			// Always close — leaving a ZipArchive open leaks an fd and may
			// truncate the file on shutdown.
			$zip->close();
		}

		$this->logger && $this->logger->info(
			'Archive built',
			[ 'files' => $files, 'size' => @filesize( $output_zip ), 'compression_level' => $this->level ]
		);
		return $files;
	}

	private function add_directory( \ZipArchive $zip, string $root ): int {
		if ( ! is_dir( $root ) ) {
			return 0;
		}
		$root = rtrim( $root, DIRECTORY_SEPARATOR );
		$base = dirname( $root );

		$it = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ),
			\RecursiveIteratorIterator::SELF_FIRST
		);

		$count = 0;
		foreach ( $it as $file ) {
			$abs = (string) $file;
			// Never archive the plugin's own storage dir, even when it lives
			// inside a backed-up location (e.g. uploads/backreload) — otherwise
			// we'd back up our own backups.
			if ( '' !== $this->skip_abs ) {
				$n_abs = str_replace( '\\', '/', $abs );
				$n_skip = str_replace( '\\', '/', $this->skip_abs );
				if ( $n_abs === $n_skip || 0 === strpos( $n_abs, $n_skip . '/' ) ) {
					continue;
				}
			}
			$rel = ltrim( str_replace( $base, '', $abs ), DIRECTORY_SEPARATOR );

			if ( $this->is_excluded( $rel ) ) {
				continue;
			}

			if ( is_dir( $abs ) ) {
				$zip->addEmptyDir( $rel );
			} else {
				$this->add_file( $zip, $abs, $rel );
				$count++;
			}
		}
		return $count;
	}

	/** Add a file with the correct per-extension compression method/level. */
	private function add_file( \ZipArchive $zip, string $abs, string $rel ): void {
		$zip->addFile( $abs, $rel );

		// Skip-compression list: media + already-zipped formats.
		$ext = strtolower( pathinfo( $rel, PATHINFO_EXTENSION ) );
		if ( in_array( $ext, self::NOCOMPRESS_EXT, true ) ) {
			$zip->setCompressionName( $rel, \ZipArchive::CM_STORE );
			return;
		}

		// Otherwise apply the user-chosen deflate level.
		// On older PHP, the 3-arg setCompressionName isn't available; fall back gracefully.
		if ( 0 === $this->level ) {
			$zip->setCompressionName( $rel, \ZipArchive::CM_STORE );
		} else {
			// PHP 8.0+: setCompressionName($name, $method, $compflags)
			if ( PHP_VERSION_ID >= 80000 ) {
				@$zip->setCompressionName( $rel, \ZipArchive::CM_DEFLATE, $this->level );
			} else {
				$zip->setCompressionName( $rel, \ZipArchive::CM_DEFLATE );
			}
		}
	}

	private function is_excluded( string $relative_path ): bool {
		$normalized = str_replace( DIRECTORY_SEPARATOR, '/', $relative_path );
		foreach ( $this->exclusions as $pattern ) {
			if ( fnmatch( $pattern, $normalized ) || fnmatch( $pattern, basename( $normalized ) ) ) {
				return true;
			}
		}
		return false;
	}
}
