<?php

namespace WPForms\Pro\Forms\Fields\FileUpload;

use InvalidArgumentException;
use WPForms_Field_File_Upload;

/**
 * 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 WPForms_Field_File_Upload
	 */
	protected $field;

	/**
	 * Chunk constructor.
	 *
	 * @since 1.6.2
	 *
	 * @param array                     $metadata Metadata about the chunk.
	 * @param WPForms_Field_File_Upload $field    Field.
	 *
	 * @throws InvalidArgumentException Invalid UUID.
	 */
	public function __construct( array $metadata, WPForms_Field_File_Upload $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 isset( $this->metadata['name'] ) ? $this->metadata['name'] : '';
	}

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

		return isset( $this->metadata['file_user_name'] ) ? $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 WPForms_Field_File_Upload $field File field instance.
	 *
	 * @return bool|Chunk False or the instance of this class.
	 */
	public static function from_current_request( WPForms_Field_File_Upload $field ) {

		$field_name = $field->get_input_name();

		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 a initialization Ajax call, in that scenario
			// we require fewer fields.
			$required = [
				'dzuuid'          => 'uuid',
				'dztotalfilesize' => 'file_size',
				'name'            => 'file_user_name',
			];

			// phpcs:disable WordPress.Security.NonceVerification.Missing

			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:enable 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,
			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
	 */
	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();

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

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

	/**
	 * 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();

		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;
	}

	/**
	 * Verify the chunk size and offset.
	 *
	 * This function is very strict for security. The exact amount of bytes are 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 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 are performed, all the validations are performed
	 * once al the chunks has 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'];

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

	/**
	 * 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 in order 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 = isset( $chunks[ $id + 1 ] ) ? $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() {

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

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

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

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

			return false;
		}

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

		foreach ( $this->get_chunks() as $chunk ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_read_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_read_fclose
			@fclose( $source );
		}

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

		$this->delete_temporary_files();

		return true;
	}
}
