<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Minimal streaming ZIP writer. Stores entries uncompressed (method 0).
 * Works without ZipArchive and without holding full files in memory.
 * Uses data descriptors (bit 3 of general purpose flags) so file sizes / CRC
 * can be determined as data streams in. ZIP64 is supported for archives that
 * exceed 4GB in total size or contain large files.
 */
class CCGAL_Zip_Stream {

	private $offset = 0;
	private $central = array();
	private $finished = false;
	private $used_zip64 = false;

	public function add_file_from_callable( $archive_name, $producer ) {
		$archive_name = $this->sanitize_name( $archive_name );
		$name_bytes   = strlen( $archive_name );

		$flags        = 1 << 3;
		$method       = 0;
		$mod_time     = $this->dos_time();
		$mod_date     = $this->dos_date();
		$crc          = 0;
		$size         = 0;
		$use_zip64    = false;

		$local_offset = $this->offset;

		$header  = "PK\x03\x04";
		$header .= pack( 'v', 20 );
		$header .= pack( 'v', $flags );
		$header .= pack( 'v', $method );
		$header .= pack( 'v', $mod_time );
		$header .= pack( 'v', $mod_date );
		$header .= pack( 'V', 0 ); // CRC placeholder
		$header .= pack( 'V', 0 ); // Compressed size placeholder
		$header .= pack( 'V', 0 ); // Uncompressed size placeholder
		$header .= pack( 'v', $name_bytes );
		$header .= pack( 'v', 0 );
		$header .= $archive_name;

		$this->emit( $header );

		$crc_ctx = hash_init( 'crc32b' );

		$writer = function( $chunk ) use ( &$crc_ctx, &$size ) {
			$len = strlen( $chunk );
			if ( 0 === $len ) {
				return;
			}
			hash_update( $crc_ctx, $chunk );
			$size += $len;
			$this->emit( $chunk );
		};

		$ok = call_user_func( $producer, $writer );

		$crc_hex = hash_final( $crc_ctx );
		$crc     = hexdec( $crc_hex );

		if ( $size >= 0xFFFFFFFF ) {
			$use_zip64         = true;
			$this->used_zip64  = true;
		}

		if ( $use_zip64 ) {
			$descriptor  = "PK\x07\x08";
			$descriptor .= pack( 'V', $crc );
			$descriptor .= pack( 'P', $size );
			$descriptor .= pack( 'P', $size );
		} else {
			$descriptor  = "PK\x07\x08";
			$descriptor .= pack( 'V', $crc );
			$descriptor .= pack( 'V', $size );
			$descriptor .= pack( 'V', $size );
		}
		$this->emit( $descriptor );

		if ( false === $ok ) {
			return false;
		}

		$this->central[] = array(
			'name'         => $archive_name,
			'crc'          => $crc,
			'size'         => $size,
			'local_offset' => $local_offset,
			'mod_time'     => $mod_time,
			'mod_date'     => $mod_date,
			'flags'        => $flags,
			'method'       => $method,
			'use_zip64'    => $use_zip64,
		);

		return true;
	}

	public function finish() {
		if ( $this->finished ) {
			return;
		}
		$this->finished = true;

		$central_start = $this->offset;
		$central_size  = 0;

		foreach ( $this->central as $entry ) {
			$name_bytes = strlen( $entry['name'] );
			$extra      = '';

			$needs_zip64_extra = $entry['use_zip64'] || $entry['local_offset'] >= 0xFFFFFFFF;

			if ( $needs_zip64_extra ) {
				$this->used_zip64 = true;
				$zip64_body       = '';
				$zip64_body      .= pack( 'P', $entry['size'] );
				$zip64_body      .= pack( 'P', $entry['size'] );
				$zip64_body      .= pack( 'P', $entry['local_offset'] );
				$extra            = pack( 'v', 0x0001 ) . pack( 'v', strlen( $zip64_body ) ) . $zip64_body;
				$size_field       = 0xFFFFFFFF;
				$offset_field     = 0xFFFFFFFF;
			} else {
				$size_field   = $entry['size'];
				$offset_field = $entry['local_offset'];
			}

			$extra_bytes = strlen( $extra );

			$record  = "PK\x01\x02";
			$record .= pack( 'v', 0x031E );
			$record .= pack( 'v', 45 );
			$record .= pack( 'v', $entry['flags'] );
			$record .= pack( 'v', $entry['method'] );
			$record .= pack( 'v', $entry['mod_time'] );
			$record .= pack( 'v', $entry['mod_date'] );
			$record .= pack( 'V', $entry['crc'] );
			$record .= pack( 'V', $size_field );
			$record .= pack( 'V', $size_field );
			$record .= pack( 'v', $name_bytes );
			$record .= pack( 'v', $extra_bytes );
			$record .= pack( 'v', 0 );
			$record .= pack( 'v', 0 );
			$record .= pack( 'v', 0 );
			$record .= pack( 'V', 0 );
			$record .= pack( 'V', $offset_field );
			$record .= $entry['name'];
			$record .= $extra;

			$this->emit( $record );
			$central_size += strlen( $record );
		}

		$count = count( $this->central );

		if ( $this->used_zip64 || $central_start >= 0xFFFFFFFF || $central_size >= 0xFFFFFFFF || $count >= 0xFFFF ) {
			$zip64_eocd_offset = $this->offset;

			$zip64_eocd  = "PK\x06\x06";
			$zip64_eocd .= pack( 'P', 44 );
			$zip64_eocd .= pack( 'v', 45 );
			$zip64_eocd .= pack( 'v', 45 );
			$zip64_eocd .= pack( 'V', 0 );
			$zip64_eocd .= pack( 'V', 0 );
			$zip64_eocd .= pack( 'P', $count );
			$zip64_eocd .= pack( 'P', $count );
			$zip64_eocd .= pack( 'P', $central_size );
			$zip64_eocd .= pack( 'P', $central_start );
			$this->emit( $zip64_eocd );

			$zip64_locator  = "PK\x06\x07";
			$zip64_locator .= pack( 'V', 0 );
			$zip64_locator .= pack( 'P', $zip64_eocd_offset );
			$zip64_locator .= pack( 'V', 1 );
			$this->emit( $zip64_locator );

			$eocd_count  = 0xFFFF;
			$eocd_size   = 0xFFFFFFFF;
			$eocd_offset = 0xFFFFFFFF;
		} else {
			$eocd_count  = $count;
			$eocd_size   = $central_size;
			$eocd_offset = $central_start;
		}

		$eocd  = "PK\x05\x06";
		$eocd .= pack( 'v', 0 );
		$eocd .= pack( 'v', 0 );
		$eocd .= pack( 'v', $eocd_count );
		$eocd .= pack( 'v', $eocd_count );
		$eocd .= pack( 'V', $eocd_size );
		$eocd .= pack( 'V', $eocd_offset );
		$eocd .= pack( 'v', 0 );

		$this->emit( $eocd );
	}

	private function emit( $data ) {
		echo $data; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		$this->offset += strlen( $data );
		if ( function_exists( 'ob_get_level' ) && ob_get_level() > 0 ) {
			@ob_flush();
		}
		@flush();
	}

	private function dos_time() {
		$h = (int) gmdate( 'H' );
		$m = (int) gmdate( 'i' );
		$s = (int) gmdate( 's' );
		return ( ( $h & 0x1F ) << 11 ) | ( ( $m & 0x3F ) << 5 ) | ( ( $s >> 1 ) & 0x1F );
	}

	private function dos_date() {
		$y = (int) gmdate( 'Y' );
		$m = (int) gmdate( 'n' );
		$d = (int) gmdate( 'j' );
		return ( ( ( $y - 1980 ) & 0x7F ) << 9 ) | ( ( $m & 0x0F ) << 5 ) | ( $d & 0x1F );
	}

	private function sanitize_name( $name ) {
		$name = str_replace( array( "\0", "\r", "\n" ), '', (string) $name );
		$name = preg_replace( '#[\\\\]+#', '/', $name );
		$name = ltrim( $name, '/' );
		if ( '' === $name ) {
			$name = 'file';
		}
		return $name;
	}
}
