<?php
/**
 * File body handler.
 *
 * @package Style Manager
 * @license GPL-2.0-or-later
 * @since 2.0.0
 */

declare ( strict_types = 1 );

namespace Pixelgrade\StyleManager\HTTP\ResponseBody;

use Pixelgrade\StyleManager\Exception\InvalidFileName;

/**
 * File body handler class.
 *
 * @since 2.0.0
 */
class FileBody implements ResponseBody {
	/**
	 * Absolute path to the file to stream.
	 *
	 * @var string
	 */
	protected $filename;

	/**
	 * Create a file response body.
	 *
	 * @since 2.0.0
	 *
	 * @param string $filename Absolute path to the file to stream.
	 * @throws InvalidFileName If the file name fails validation.
	 */
	public function __construct( string $filename ) {
		$result = validate_file( $filename );
		if ( 0 !== $result ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- internal exception for a developer-supplied path; the message is not rendered to browsers.
			throw InvalidFileName::withValidationCode( $filename, $result );
		}

		$this->filename = $filename;
	}

	/**
	 * Stream the file.
	 *
	 * @since 2.0.0
	 */
	public function emit() {
		$this->configure_environment();
		$this->clean_buffers();
		$this->readfile_chunked( $this->filename );
	}

	/**
	 * Configure the environment before sending a file.
	 *
	 * @since 2.0.0
	 */
	protected function configure_environment() {
		session_write_close();

		if ( $this->function_exists( 'apache_setenv' ) ) {
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_apache_setenv
			apache_setenv( 'no-gzip', '1' );
		}

		if ( $this->function_exists( 'ini_set' ) ) {
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_ini_set, Squiz.PHP.DiscouragedFunctions.Discouraged -- streaming download: turn off output compression so chunks flush.
			ini_set( 'zlib.output_compression', 'Off' );
		}

		if ( $this->function_exists( 'set_time_limit' ) ) {
			// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- streaming download: prevent a PHP timeout while sending a large file.
			set_time_limit( 0 );
		}
	}

	/**
	 * Clean output buffers.
	 *
	 * @since 2.0.0
	 */
	protected function clean_buffers() {
		$levels = ob_get_level();
		for ( $i = 0; $i < $levels; $i++ ) {
			ob_end_clean();
		}
	}

	/**
	 * Output a file.
	 *
	 * Reads file in chunks so big downloads are possible without changing `php.ini`.
	 *
	 * @link https://github.com/bcit-ci/CodeIgniter/wiki/Download-helper-for-large-files
	 *
	 * @since 2.0.0
	 *
	 * @param string $filename Absolute path to a file.
	 */
	protected function readfile_chunked( string $filename ) {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- chunked streaming of a validated local file; WP_Filesystem would buffer the whole file into memory.
		$handle = fopen( $filename, 'rb' );
		if ( false === $handle ) {
			return;
		}

		while ( ! feof( $handle ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fread -- chunked streaming of a validated local file; WP_Filesystem would buffer the whole file into memory.
			$buffer = fread( $handle, MB_IN_BYTES );
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			echo $buffer;
			flush();
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose, WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- chunked streaming of a validated local file; WP_Filesystem would buffer the whole file into memory.
		fclose( $handle );
	}

	/**
	 * Whether a function exists and is enabled.
	 *
	 * @since 2.0.0
	 *
	 * @param string $name Function name.
	 * @return bool
	 */
	protected function function_exists( string $name ): bool {
		return \function_exists( $name ) && false === strpos( ini_get( 'disable_functions' ), $name );
	}
}
