<?php

namespace App\Form\UploadedFiles;

class File
{

	/**
	 * The uploaded (original) file name.
	 * @var string
	 */
	private $name = null;

	/**
	 * The uploaded file type.
	 * @var string
	 */
	private $type = null;

	/**
	 * The uploaded temporary file name.
	 * @var string
	 */
	private $tempName = null;

	/**
	 * The error received during upload.
	 * @var int
	 */
	private $error = null;

	/**
	 * The file size.
	 * @var int
	 */
	private $size = null;

	/**
	 * Indicates if the uploaded file is valid or not.
	 * @var boolean
	 */
	private $valid = null;

	/**
	 * Indicates if there is a file actually uploaded.
	 * @var boolean
	 */
	private $hasFile = null;

	/**
	 * A list of file extensions that are never allowed to be uploaded.
	 * @var array
	 */
	private $deniedFileExtensions = ['php', 'php3', 'php4', 'php5', 'phtml', 'js', 'pl', 'py', 'jsp', 'asp', 'exe', 'com', 'bat', 'msi', 'sh', 'cgi', 'htaccess'];


	/**
	 * The
	 * @param mixed $keys - supports an array of elements, or a single string (similar to an array with one element).
	 * @param int $index - used then the file input field is set as "multiple"
	 */
	public function __construct($keys, ?int $index=null)
	{

		// loads the succession of keys from $_FILES
		$this->loadUploadedFileAttributes($keys, $index);

	}

	/**
	 * Loads one file by looping through the complex $_FILES structure (it has a very weird structure
	 * especially when the field is defined as having more than 1 key and it is also multiple.
	 * @param mixed $keys - it
	 * @param int $index
	 * @throws \Exception
	 */
	private function loadUploadedFileAttributes($keys, ?int $index) :void
	{

		// no uploaded files at all
		if (!$_FILES) {
			$this->valid = false;
			$this->hasFile = false;
			return ;
		}

		// collect a list of keys to be used to process the $_FILES.
		if (is_string($keys)) {
			if (!strlen($keys)) {
				throw new \Exception('If the `keys` is sent as string, it must be set. Please rename your input fields names or check the sent params.');
			}
			$extraKeys = [$keys];
		} elseif (is_array($keys)) {
			if (!$keys) {
				throw new \Exception('If the `keys` is sent as array, it must have at least one key (usually it is just one).');
			}
			$extraKeys = $keys;
		} else {
			throw new \Exception('The `keys` parameter must be set as string (one key) or array of keys.');
		}
		// add the index as well (for multiple files per field)
		if ($index!==null) {
			if (!is_numeric($index) or $index<0) {
				throw new \Exception('Inavalid index sent, or incorrect naming for multiple files input field.');
			}
			$extraKeys[] = $index;
		}

		// there should always be a first key
		$firstKey = array_shift($extraKeys);

		// select the first key in $_FILES
		$filesArr = $_FILES[$firstKey];

		// if the first key is not valid - stop the process
		if (!$filesArr or !is_array($filesArr)) {
			$this->valid = false;
			$this->hasFile = false;
			return ;
		}

		// if no other keys are available, stop here and create with these values
		if (!$extraKeys) {
			$this->createFromFileParams($filesArr);
			return ;
		}

		// we should always have the "name" key here
		if (!$filesArr['name']) {
			$this->valid = false;
			$this->hasFile = false;
			return ;
		}

		// if there are extra keys - process them in a complicated way (the structure of $_FILES)
		$filesStartArr = $filesArr;
		$fileParams = []; // collects paarms to be sent for validation
		foreach (['name', 'tmp_name', 'type', 'error', 'size'] as $fileField) {

			$thisFilesArr = $filesStartArr[$fileField];

			foreach ($extraKeys as $key) {
				if (!array_key_exists($key, $thisFilesArr)) {
					$this->valid = false;
					$this->hasFile = false;
					return ;
				}
				$thisFilesArr = $thisFilesArr[$key];
			}

			// collects this param
			$fileParams[$fileField] = $thisFilesArr;

		}

		// if no array at this point - we set this as valid but empty
		if (!$fileParams) {
			$this->valid = true;
			$this->hasFile = false;
			return ;
		}

		// populate internal attributes based on the keys having the standard $_FILES keys
		$this->createFromFileParams($fileParams);

	}

	/**
	 * Receives an array with standard keys of $_FILES (per one file).
	 * @param array $fileParams
	 */
	private function createFromFileParams($fileParams) :void
	{

		// CHECK THAT THE INPUT HAS A VALID STRUCTURE ---------------------------------------------
		if (!is_array($fileParams)) {
			$this->valid = false;
			$this->hasFile = false;
			return ;
		}

		// all these keys must be present here
		if (
			!array_key_exists('name', $fileParams) or !array_key_exists('tmp_name', $fileParams) or !array_key_exists('type', $fileParams)
			or !array_key_exists('error', $fileParams) or !array_key_exists('size', $fileParams)
		) {
			$this->valid = false;
			$this->hasFile = false;
			return ;
		}
		// the values of the array must be scalar
		if (
			!is_scalar($fileParams['name']) or !is_scalar($fileParams['tmp_name']) or !is_scalar($fileParams['type'])
			or !is_scalar($fileParams['error']) or !is_scalar($fileParams['size'])
		) {
			$this->valid = false;
			$this->hasFile = false;
			return ;
		}


		// ASSOCIATE THE ELEMENTS FROM THE ARRAY --------------------------------------------------
		$this->name		= (string)$fileParams['name'];
		$this->tempName	= (string)$fileParams['tmp_name'];
		$this->type		= (string)$fileParams['type'];
		$this->error	= (int)$fileParams['error'];
		$this->size		= (int)$fileParams['size'];

		// CHECK THE VALUES OF ALL COMPONENTS OF THE UPLOADED FILE --------------------------------

		// any error occured during upload?
		if ($this->error and $this->error!=UPLOAD_ERR_NO_FILE) {
			$this->valid = false;
			$this->hasFile = true;
			// logs upload error
			\Log::getInstance()->error( sprintf("A file could not be correctly uploaded due to error code %d. File '%s'.", $this->error, $this->name), [
				'uploadFileAttributes' => new \Engine\Logger\Context\ArrayAssoc($fileParams)
			]);
			return ;
		}

		// check if this is an actually uploaded file
		if ($this->tempName and !is_uploaded_file($this->tempName)) {
			$this->valid = false;
			$this->hasFile = true;
			// logs potential hack
			\Log::getInstance()->error( sprintf("A file could not be accessed as an uploaded file - this is a potential attack. File: %s.", $this->name), [
				'uploadFileAttributes' => new \Engine\Logger\Context\ArrayAssoc($fileParams)
			]);
			return ;
		}

		// checks denied extensions
		if ($this->deniedFileExtensions and $this->name) {
			if ( in_array( strtolower(\Engine\File::getExtension($this->name)), $this->deniedFileExtensions) ) {
				$this->valid = false;
				$this->hasFile = true;
				// logs potential attack
				\Log::getInstance()->error( sprintf("A file with an invalid extension was uploaded - this is a potential attack. File: %s.", $this->name), [
					'uploadFileAttributes' => new \Engine\Logger\Context\ArrayAssoc($fileParams)
				]);
				return ;
			}
		}

		// none of the name and tempName were set and also error code is 4 (UPLOAD_ERR_NO_FILE) - no file uploaded
		if (!$this->name and !$this->tempName and $this->error==UPLOAD_ERR_NO_FILE) {
			$this->valid = true;
			$this->hasFile = false;
			return ;
		}

		// file was successfully uploaded, everything is fine
		if ($this->name and $this->tempName and $this->error==UPLOAD_ERR_OK) {
			$this->valid = true;
			$this->hasFile = true;
			return ;
		}

		// this is a fallback case - it should never happen
		$this->valid = false;
		$this->hasFile = true;
		// logs potential hack
		\Log::getInstance()->error( sprintf("A weird error occured during file upload. File: %s.", $this->name), [
			'uploadFileAttributes' => new \Engine\Logger\Context\ArrayAssoc($fileParams)
		]);
		// throws exception to stop
		throw new \RuntimeException('Weird (or untreated) combination of files array params. Check server logs also.');

	}


	/**
	 * Returns the raw error of the current uploaded file.
	 */
	public function getError() :?int
	{
		return $this->error;
	}

	/**
	 * Returns the temporary file name of the current uploaded file.
	 */
	public function getTempName() :?string
	{
		return $this->tempName;
	}

	/**
	 * Returns the name of the file being uploaded (as it was chosen by the user).
	 */
	public function getName() :?string
	{
		return $this->name;
	}

	/**
	 * Returns the file size.
	 */
	public function getSize() :?int
	{
		return $this->size;
	}

	public function getType() :?string
	{
		return $this->type;
	}

	/**
	 * Returns the temporary uploaded file contents.
	 * Files are automatically deleted at the end of the script.
	 * @param boolean $unlinkFile - after we get the contents, the file is deleted from temp dir
	 * @return string
	 * @throws \RuntimeException - if the file is not valid.
	 */
	public function getContents($unlinkFile = true) :string
	{
		// if the file is not valid - do not
		if (!$this->valid) {
			throw new \RuntimeException('Cannot get contents of an uploaded file as it is not valid.');
		}
		if (!$this->hasFile) {
			throw new \RuntimeException('Cannot get contents of an uploaded file as there is no file uploaded.');
		}

		// check if the file can be accessed and load its content
		$fileContents = file_get_contents( $this->tempName );
		if ($fileContents===false) {
			throw new \RuntimeException('Cannot load the contents of an uploaded file - this should be checked.');
		}

		// if th unkliFile is set - remove it from uploaded temp dir
		if ($unlinkFile) {
			if (!unlink($this->tempName)) {
				\Log::getInstance()->error( sprintf("Could not unlink the temporary uploaded file named `%s` while executing `%s`.", $this->name, __METHOD__), [
					'uploadFileAttributes' => new \Engine\Logger\Context\ArrayAssoc(get_object_vars($this))
				]);
			}
		}

		// return the file contents
		return $fileContents;
	}

	/**
	 * Returns if the uploaded file is valid.
	 * @return boolean
	 */
	public function isValid() :bool
	{
		return $this->valid;
	}

	/**
	 * Returns if there is an actual file uploaded.
	 * @return boolean
	 */
	public function hasFile() :bool
	{
		return $this->hasFile;
	}

	/**
	 * Quick method to be called when you want to process the uploaded file (like moving it or
	 * accessing its content).
	 * @return bool
	 */
	public function hasValidFile() :bool
	{
		return ($this->valid and $this->hasFile);
	}

	/**
	 * Moves the current uploaded file to a new location.
	 * @param string $destination
	 * @return boolean
	 * @throws \RuntimeException - if the file is not valid.
	 */
	public function moveUploadedFile(string $destination) :void
	{

		// check if the file is valid or has an actual file
		if (!$this->valid) {
			throw new \RuntimeException('Cannot move an uploaded file as it is not valid.');
		}
		if (!$this->hasFile) {
			throw new \RuntimeException('Cannot move an uploaded file as there is no actual file on disk.');
		}

		if (!move_uploaded_file($this->tempName, $destination)) {
			\Log::getInstance()->error( sprintf("An error occured and move_uploaded_file failed although the uploaded file was considered valid. File: %s.", $this->name), [
				'uploadFileAttributes' => new \Engine\Logger\Context\ArrayAssoc(get_object_vars($this))
			]);
			throw new \RuntimeException(sprintf('Error while moving the uploaded file %s', $this->name));
		}
		// also change user access
		\Engine\File::setDefaultPermissions($destination);
	}

	/**
	 * Deletes file from temporary
	 */
	public function deleteFile() : void
	{
		// check if the file is valid or has an actual file
		if (!$this->valid) {
			throw new \RuntimeException('Cannot delete an uploaded file as it is not valid.');
		}

		if (!$this->hasFile) {
			throw new \RuntimeException('Cannot delete an uploaded file as there is no actual file on disk.');
		}

		if (!unlink($this->tempName)) {
			\Log::getInstance()->error( sprintf("Could not unlink the temporary uploaded file named `%s` while executing `%s`.", $this->name, __METHOD__), [
					'uploadFileAttributes' => new \Engine\Logger\Context\ArrayAssoc(get_object_vars($this))
			]);
		}
	}

}
