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

/********************************************************************
 * Copyright (C) 2025 Upvimeo.com (https://upvimeo.com)
 *
 * This file is part of Upvimeo
 *
 * Upvimeo is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 2 of the License, or
 * (at your option) any later version.
 *
 * Upvimeo is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Upvimeo. If not, see <https://www.gnu.org/licenses/>.
 **********************************************************************/

/**
 * Class UpVimeo_File_Chunk
 *
 * This class handles all the chunk file uploading logic.
 *
 * Heavily inspired by WPForms Chunk class.
 *
 * @license GPLv2
 * @copyright Upvimeo.com (https://upvimeo.com)
 * @since 1.6.0
 */
class UpVimeo_File_Chunk
{

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

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

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

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

	/**
	 * The input name
	 * @var string
	 */
	protected $input_name;

	/**
	 * Chunk size by default
	 * @var float|int
	 */
	protected $chunk_size = 2 * 1024 * 1024;

	/**
	 * The tmp file dir
	 * @var string|null
	 */
	protected $tmp_dir = null;

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

		$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']);
		}

		if (isset($params['tmp_dir'])) {
			$this->tmp_dir = $params['tmp_dir'];
		}

		if (isset($params['chunk_size'])) {
			$this->chunk_size = $params['chunk_size'];
		}

		if (isset($params['input_name'])) {
			$this->input_name = $params['input_name'];
		}

		$this->path     = trailingslashit($this->tmp_dir) . sha1($metadata['uuid']) . '-';
		$this->metadata = $metadata;
	}

	/**
	 * Set the offset of the current block.
	 *
	 * @param int $offset Offset of the current chunk.
	 *
	 * @return UpVimeo_File_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.
	 *
	 * @return string
	 */
	public function get_file_name()
	{

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

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

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

	/**
	 * Return file_size.
	 *
	 * @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.
	 *
	 * @param array $params
	 *
	 * @return bool|UpVimeo_File_Chunk False or the instance of this class.
	 */
	public static function from_current_request($params)
	{

		// Security nonce check
		// phpcs:ignore WordPress.Security.NonceVerification.Missing
		if (!isset($_POST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'upvimeo_ajax_nonce')) {
			return false;
		}

		$field_name = isset($params['input_name']) ? $params['input_name'] : '';

		if (empty($field_name)) {
			return false;
		}

		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',
			];
			if (isset($_POST['name'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
				$settings = [
					'name' => sanitize_file_name(wp_unslash($_POST['name'])),
					// phpcs:ignore 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, $params);
	}

	/**
	 * Return the path of the metadata of the current upload.
	 *
	 * @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.
	 *
	 * @return bool Whether the metadata was loaded successfully or not.
	 */
	public function load_metadata()
	{

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

		$this->metadata = array_merge(
			$this->metadata,
			json_decode(file_get_contents($this->get_metadata_file_path()), true)
		);

		return true;
	}

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

		if (file_exists($this->get_metadata_file_path())) {
			return false;
		}
		global $wp_filesystem;
		if (empty($wp_filesystem)) {
			require_once ABSPATH . '/wp-admin/includes/file.php';
			WP_Filesystem();
		}

		$tmp                          = $this->path . '-' . uniqid();
		$this->metadata['chunk_size'] = $this->chunk_size;

		$wp_filesystem->put_contents($tmp, wp_json_encode($this->metadata), FS_CHMOD_FILE);

		return $wp_filesystem->move($tmp, $this->get_metadata_file_path(), true);
	}

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

		$field_name = $this->input_name;

		// phpcs:disable WordPress.Security.NonceVerification.Missing
        if ( isset( $_FILES[ $field_name ]['tmp_name'] ) && is_readable( $_FILES[ $field_name ]['tmp_name'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            
            // 1. Unslash the array to satisfy WPCS requirement
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            $file_array = wp_unslash( $_FILES[ $field_name ] ); 
            
            // 2. Sanitize the ONLY part that is safe to sanitize: the name
            $file_array['name'] = sanitize_file_name( $file_array['name'] );
            
            return $file_array;
        }
        // phpcs:enable

		return 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.
	 *
	 * @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.
	 *
	 * @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.
	 *
	 * @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.
	 *
	 * @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';
		$target_dir = dirname($path_to);
		$file_name  = $this->get_file_name();

		// Ensure file has a valid extension
		if (! function_exists('upvimeo_get_allowed_extensions')) {
			return false;
		}

		$ext = pathinfo($file_name, PATHINFO_EXTENSION);
		if (! in_array(strtolower($ext), upvimeo_get_allowed_extensions(), true)) {
			return false;
		}

		// Prepare file array for wp_handle_upload
		// We use a temporary name with the correct extension to pass test_type checks
		$file_copy = $file;
		$file_copy['name'] = 'chunk_' . $this->offset . '.' . $ext;

		require_once ABSPATH . 'wp-admin/includes/file.php';

		// Temporarily filter the upload directory so wp_handle_upload places the file securely in our tmp environment
		$upload_dir_filter = function ($dir) use ($target_dir) {
			$dir['path']    = $target_dir;
			$dir['url']     = '';
			$dir['subdir']  = '';
			$dir['basedir'] = $target_dir;
			$dir['baseurl'] = '';
			$dir['error']   = false;
			return $dir;
		};

		add_filter('upload_dir', $upload_dir_filter);

		$overrides = [
			'test_form' => false,
		];

		$upload = wp_handle_upload($file_copy, $overrides);

		remove_filter('upload_dir', $upload_dir_filter);

		if (isset($upload['error']) || ! isset($upload['file'])) {
			return false;
		}

		// wp_handle_upload might have modified the file name. Move to exact chunk name
		global $wp_filesystem;
		if (empty($wp_filesystem)) {
			WP_Filesystem();
		}

		if ($upload['file'] !== $path_to) {
			return $wp_filesystem->move($upload['file'], $path_to, true);
		}

		return true;
	}

	/**
	 * Return the chunk offset from a chunk filename.
	 *
	 * @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.
	 *
	 * @return bool
	 *
	 */
	protected function validate_chunks()
	{

		$chunks = $this->get_chunks();

		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.
	 *
	 * @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.
	 *
	 */
	protected function delete_temporary_files()
	{
		global $wp_filesystem;
		if (empty($wp_filesystem)) {
			require_once ABSPATH . '/wp-admin/includes/file.php';
			WP_Filesystem();
		}

		foreach ($this->get_chunks() as $chunk) {
			$wp_filesystem->delete($chunk['file']);
		}

		$this->chunks = [];
		$wp_filesystem->delete($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 has been uploaded
	 * successfully and will attempt to merge all those chunks in a single file.
	 *
	 * @param string $path Path where the file will be assembled.
	 *
	 * @return bool
	 *
	 */
	public function finalize($path)
	{

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

		global $wp_filesystem;
		if (empty($wp_filesystem)) {
			require_once ABSPATH . '/wp-admin/includes/file.php';
			WP_Filesystem();
		}

		// Ensure the target file exists and is empty
		$wp_filesystem->put_contents($path, '', FS_CHMOD_FILE);

		foreach ($this->get_chunks() as $chunk) {
			$chunk_data = $wp_filesystem->get_contents($chunk['file']);
			if ( $chunk_data === false ) {
				return false;
			}

			$upload_dir = wp_upload_dir();
			$plugin_upload_dir = wp_normalize_path( $upload_dir['basedir'] . '/upvimeo-direct-uploads' );

			if ( ! file_exists( $plugin_upload_dir ) ) {
				wp_mkdir_p( $plugin_upload_dir );
				file_put_contents( trailingslashit( $plugin_upload_dir ) . 'index.php', '<?php // Silence is golden.' );
				file_put_contents( trailingslashit( $plugin_upload_dir ) . '.htaccess', 'Deny from all' );
			}

			$safe_path = wp_normalize_path( $path );

			// Prevent directory traversal and enforce the uploads directory boundary
			if ( strpos( $safe_path, $plugin_upload_dir ) === 0 && false === strpos( $safe_path, '../' ) && false === strpos($safe_path, '..\\' ) ) {
			    // Note: Native file_put_contents is required here for performance as WP_Filesystem lacks a native, memory-efficient APPEND stream for massive video chunks.
			    $bytes = file_put_contents( $safe_path, $chunk_data, FILE_APPEND );
			} else {
			    wp_send_json_error( array( 'message' => 'Security Error: Invalid or restricted file path.' ), 403 );
			    exit;
			}

			if ($bytes !== $chunk['size']) {
				return false;
			}
		}

		$this->delete_temporary_files();

		return true;
	}
}
