<?php

namespace WPForms\Pro\Forms\Fields\FileUpload;

use InvalidArgumentException;

/**
 * Chunk class.
 *
 * This class handles all the chunk file uploading logic.
 *
 * @since 1.6.2
 */
class Chunk {

	/**
	 * Path where the upload chunks and metadata are stored.
	 *
	 * @since 1.6.2
	 *
	 * @var string
	 */
	protected $path;

	/**
	 * Metadata about the current upload.
	 *
	 * @since 1.6.2
	 *
	 * @var array
	 */
	protected $metadata;

	/**
	 * Chunk offset.
	 *
	 * @since 1.6.2
	 *
	 * @var int|null
	 */
	protected $offset;

	/**
	 * Information about each chunk.
	 *
	 * @since 1.6.2
	 *
	 * @var array
	 */
	protected $chunks = [];

	/**
	 * The Field object.
	 *
	 * @since 1.6.2
	 *
	 * @var Field
	 */
	protected $field;

	/**
	 * Chunk constructor.
	 *
	 * @since 1.6.2
	 *
	 * @param array $metadata Metadata about the chunk.
	 * @param Field $field    Field.
	 *
	 * @throws InvalidArgumentException Invalid UUID.
	 */
	public function __construct( array $metadata, Field $field ) {

		$metadata = array_merge(
			[
				'name'        => '',
				'uuid'        => '',
				'index'       => '',
				'file_size'   => 0,
				'chunk_total' => 0,
				'chunk_size'  => 0,
			],
			$metadata
		);

		if ( ! preg_match( '/^[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12}$/i', $metadata['uuid'] ) ) {
			throw new InvalidArgumentException( 'Invalid UUID' );
		}

		if ( isset( $metadata['offset'] ) ) {
			$this->set_offset( $metadata['offset'] );
			unset( $metadata['offset'] );
		}

		$this->path     = $field->get_tmp_dir() . '/' . sha1( $metadata['uuid'] ) . '-';
		$this->field    = $field;
		$this->metadata = $metadata;
	}

	/**
	 * Set the offset of the current block.
	 *
	 * @since 1.6.2
	 *
	 * @param int $offset Offset of the current chunk.
	 *
	 * @return Chunk
	 *
	 * @throws InvalidArgumentException Invalid offset.
	 */
	protected function set_offset( $offset ) {

		if ( ! is_numeric( $offset ) || ! is_int( $offset + 0 ) || $offset < 0 ) {
			throw new InvalidArgumentException( 'Invalid offset' );
		}

		$this->offset = (int) $offset;

		return $this;
	}

	/**
	 * Return the sanitized file name.
	 *
	 * @since 1.6.2
	 *
	 * @return string
	 */
	public function get_file_name() {

		return $this->metadata['name'] ?? '';
	}

	/**
	 * Return the original file name.
	 *
	 * @since 1.6.2
	 *
	 * @return string
	 */
	public function get_file_user_name() {

		return $this->metadata['file_user_name'] ?? '';
	}

	/**
	 * Return file_size.
	 *
	 * @since 1.6.2
	 *
	 * @return int
	 */
	public function get_file_size() {

		return isset( $this->metadata['file_size'] ) ? (int) $this->metadata['file_size'] : 0;
	}

	/**
	 * Create a Chunk object from the current request.
	 *
	 * If validation failed, FALSE is returned instead.
	 *
	 * @since 1.6.2
	 *
	 * @param Field $field File field instance.
	 *
	 * @return bool|Chunk False or the instance of this class.
	 */
	public static function from_current_request( Field $field ) {

		$field_name = $field->get_input_name();

		// phpcs:disable WordPress.Security.NonceVerification.Missing
		if ( isset( $_FILES[ $field_name ]['name'] ) ) {
			// The current upload has a file attached to it. We should check that DropZone
			// included the following required information about this current upload.
			$required = [
				// This is a UUID generated by the client to identify the current upload.
				'dzuuid'            => 'uuid',
				// The number of the current chunk.
				'dzchunkindex'      => 'index',
				// The size of the current chunk.
				'dzchunksize'       => 'chunk_size',
				// The total number of chunks for this current upload.
				'dztotalchunkcount' => 'chunk_total',
				// The offset in bytes of this current chunk.
				'dzchunkbyteoffset' => 'offset',
			];
			$settings = [
				'name'           => sanitize_file_name( wp_unslash( $_FILES[ $field_name ]['name'] ) ),
				'file_user_name' => sanitize_text_field( wp_unslash( $_FILES[ $field_name ]['name'] ) ),
			];
		} else {
			// No file attached, most likely this is an initialization Ajax call.
			// In that scenario, we require fewer fields.
			$required = [
				'dzuuid'          => 'uuid',
				'dztotalfilesize' => 'file_size',
				'name'            => 'file_user_name',
			];

			if ( isset( $_POST['name'] ) ) {
				$settings = [
					'name'           => sanitize_file_name( wp_unslash( $_POST['name'] ) ),
					'file_user_name' => sanitize_text_field( wp_unslash( $_POST['name'] ) ),
				];
			}

			if ( ! empty( $_POST['dztotalchunkcount'] ) ) {
				$settings['chunk_total'] = absint( $_POST['dztotalchunkcount'] );
			}
		}
		// phpcs:disable WordPress.Security.NonceVerification.Missing

		foreach ( $required as $field_name => $alias ) {
			if ( ! array_key_exists( $field_name, $_POST ) ) { // phpcs:ignore WordPress.Security.NonceVerification
				return false;
			}

			$settings[ $alias ] = sanitize_text_field( wp_unslash( $_POST[ $field_name ] ) ); // phpcs:ignore WordPress.Security.NonceVerification
		}

		return new self( $settings, $field );
	}

	/**
	 * Return the path of the metadata of the current upload.
	 *
	 * @since 1.6.2
	 *
	 * @return string The path of the metadata file.
	 */
	protected function get_metadata_file_path() {

		return $this->path . 'metadata.json';
	}

	/**
	 * Load the metadata which contains the upload details.
	 *
	 * @since 1.6.2
	 *
	 * @return bool Whether the metadata was loaded successfully or not.
	 */
	public function load_metadata() {

		if ( ! is_file( $this->get_metadata_file_path() ) ) {
			return false;
		}

		$chunk_total = ! empty( $this->metadata['chunk_total'] ) ? $this->metadata['chunk_total'] : 0;

		$this->metadata = array_merge(
			$this->metadata,
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
			json_decode( file_get_contents( $this->get_metadata_file_path() ), true )
		);

		// When the upload is initialized, the total chunks count is unknown yet and the default value is 0.
		// We need to make sure we update the count when it's available.
		if ( $chunk_total ) {
			$this->metadata['chunk_total'] = $chunk_total;
		}

		return true;
	}

	/**
	 * Create the metadata file that will be used in the chunk uploads.
	 *
	 * @since 1.6.2
	 *
	 * @return bool
	 * @noinspection NonSecureUniqidUsageInspection
	 */
	public function create_metadata() {

		if ( file_exists( $this->get_metadata_file_path() ) ) {
			return false;
		}
		$tmp                          = $this->path . '-' . uniqid();
		$this->metadata['chunk_size'] = $this->field->get_chunk_size();

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		file_put_contents( $tmp, wp_json_encode( $this->metadata ) );

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename
		return @rename( $tmp, $this->get_metadata_file_path() );
	}

	/**
	 * Verify the $_FILE entry is valid before returning it.
	 *
	 * @since 1.6.2
	 *
	 * @return bool|array The $_FILE array entry or false otherwise.
	 */
	protected function get_file_upload_array() {

		$field_name = $this->field->get_input_name();

		// phpcs:disable WordPress.Security.NonceVerification.Missing
		return isset( $_FILES[ $field_name ]['tmp_name'] ) && is_readable( $_FILES[ $field_name ]['tmp_name'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
			? $_FILES[ $field_name ] // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
			: false;
		// phpcs:enable WordPress.Security.NonceVerification.Missing
	}

	/**
	 * Verify the chunk size and offset.
	 *
	 * This function is very strict for security
	 * The exact number of bytes is expected, anything above or bellow that will be rejected.
	 * Only the latest chunk is allowed to maybe be smaller.
	 *
	 * @since 1.6.2
	 *
	 * @return bool Whether all chunks have the correct offset and size.
	 */
	protected function verify_chunk_size_and_offset() {

		$file = $this->get_file_upload_array();

		if ( ! $file ) {
			return false;
		}

		$size     = filesize( $file['tmp_name'] );
		$expected = $this->get_chunk_size();

		// The chunk size must be exactly as expected.
		// The last chunk is the only one allowed to maybe be smaller.
		return $size === $expected || ( $this->is_last_chunk() && $size < $expected );
	}

	/**
	 * Whether the current chunk is the last chunk of the file or not.
	 *
	 * The last chunk is determined by their offset position.
	 *
	 * @since 1.6.2
	 *
	 * @return bool
	 */
	protected function is_last_chunk() {

		$chunk_size = $this->get_chunk_size();
		$offset     = $this->offset + 1;
		$file_size  = $this->metadata['file_size'];

		return ceil( $file_size / $chunk_size ) === ceil( $offset / $chunk_size );
	}

	/**
	 * Return the maximum size for a chunk in file uploads.
	 *
	 * @since 1.6.2
	 *
	 * @return int The size of the current chunk.
	 */
	public function get_chunk_size() {

		return $this->metadata['chunk_size'];
	}

	/**
	 * Move the uploaded file to the temporary storage.
	 *
	 * No further check is performed, all the validations are performed once al the chunks have been uploaded.
	 *
	 * @since 1.6.2
	 *
	 * @return bool The status of the write operation.
	 */
	public function write() {

		$file = $this->get_file_upload_array();

		if ( ! $file || ! $this->verify_chunk_size_and_offset() ) {
			return false;
		}

		$path_to   = $this->path . $this->offset . '.chunk';
		$path_from = $file['tmp_name'];

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Generic.PHP.ForbiddenFunctions.Found
		return @move_uploaded_file( $path_from, $path_to );
	}

	/**
	 * Return the chunk offset from a chunk filename.
	 *
	 * @since 1.6.2
	 *
	 * @param string $chunk_path Chunk path.
	 *
	 * @return int
	 */
	protected function get_chunk_position_from_file( $chunk_path ) {

		if ( preg_match( '/(\d+).chunk$/', $chunk_path, $match ) ) {
			return (int) $match[1];
		}

		return - 1;
	}

	/**
	 * Check if all the chunks have been uploaded.
	 * This must be TRUE to finalize the upload.
	 *
	 * @since 1.6.2
	 *
	 * @return bool
	 */
	protected function validate_chunks() {

		$chunks = $this->get_chunks();

		if ( empty( $chunks ) || empty( $this->metadata['chunk_total'] ) || $this->metadata['chunk_total'] !== count( $chunks ) ) {
			return false;
		}

		foreach ( $chunks as $id => $chunk ) {
			if ( ! is_file( $chunk['file'] ) ) {
				return false;
			}

			$next = $chunks[ $id + 1 ] ?? null;

			if ( $next && $chunk['end'] !== $next['start'] ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Return all the chunks with some useful data: file (path), start, size, end.
	 *
	 * @since 1.6.2
	 *
	 * @return array
	 */
	protected function get_chunks() {

		if ( ! $this->chunks ) {
			$chunks = [];

			foreach ( glob( $this->path . '*.chunk' ) as $file ) {
				$start = $this->get_chunk_position_from_file( $file );
				$size  = filesize( $file );

				$chunks[] = [
					'file'  => $file,
					'start' => $start,
					'size'  => $size,
					'end'   => $start + $size,
				];
			}

			usort(
				$chunks,
				static function ( $chunk1, $chunk2 ) {

					return $chunk1['start'] - $chunk2['start'];
				}
			);

			$this->chunks = $chunks;
		}

		return $this->chunks;
	}

	/**
	 * Delete all chunks and metadata files.
	 * Should be called once the upload has been finalized.
	 *
	 * @since 1.6.2
	 */
	protected function delete_temporary_files(): void {

		foreach ( $this->get_chunks() as $chunk ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink
			@unlink( $chunk['file'] );
		}

		$this->chunks = [];
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink
		@unlink( $this->get_metadata_file_path() );
	}

	/**
	 * Attempt to finalize the uploading.
	 *
	 * This function should be called at most once.
	 * This will verify that all the chunks have been uploaded successfully
	 * and will attempt to merge all those chunks in a single file.
	 *
	 * @since 1.6.2
	 * @since 1.9.2 $file_name parameter added.
	 *
	 * @param string $path      Path where the file will be assembled.
	 * @param string $file_name File name.
	 *
	 * @return bool
	 */
	public function finalize( string $path, string $file_name = '' ): bool {

		if ( ! $this->validate_chunks() ) {
			$this->delete_temporary_files();

			return false;
		}

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$dest = @fopen( $path, 'w+b' );

		foreach ( $this->get_chunks() as $chunk ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen
			$source = @fopen( $chunk['file'], 'rb' );

			$bytes = stream_copy_to_stream( $source, $dest );

			if ( $bytes !== $chunk['size'] ) {
				$this->delete_temporary_files();

				return false;
			}

			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			@fclose( $source );
		}

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		@fclose( $dest );

		/**
		 * Hook triggered when a Modern uploader finishes storing temporary files.
		 *
		 * @since 1.9.2
		 *
		 * @param string $path      Path where the file will be assembled.
		 * @param string $file_name File name.
		 */
		do_action( 'wpforms_pro_forms_fields_file_upload_chunk_finalize_saved', $path, $file_name );

		$this->delete_temporary_files();

		return true;
	}
}
