<?php

namespace XF\Http;

use XF\FileWrapper;

use function count, in_array, is_array;

class Upload
{
	protected $tempFile;
	protected $fileName;
	protected $extension;
	protected $fileSize;
	protected $uploadError;

	protected $extraErrors = [];

	protected $isImage = false;
	protected $optimize = false;
	protected $isOptimized = false;
	protected $imageWidth = 0;
	protected $imageHeight = 0;
	protected $imageType = null;
	protected $exif = null;

	protected $imageContentUnsafe = false;

	protected $isVideo = false;
	protected $videoType = null;

	protected $isAudio = false;
	protected $audioType = null;

	protected $allowedExtensions = null;
	protected $maxFileSize = null;
	protected $maxVideoSize = null;
	protected $maxWidth = null;
	protected $maxHeight = null;
	protected $imageRequired = false;
	protected $requireValidVideo = true;

	protected $transformed = false;

	public function __construct($tempFile, $fileName, $uploadError = 0)
	{
		if ($tempFile && (!file_exists($tempFile) || !is_readable($tempFile)))
		{
			throw new \InvalidArgumentException("Temporary file '$tempFile' can not be read or found");
		}

		$this->setFileName($fileName);

		$this->uploadError = $uploadError;

		$this->tempFile = $tempFile;
		$this->fileSize = $tempFile ? filesize($tempFile) : 0;
		$this->analyze();
	}

	public function setFileName($fileName)
	{
		$this->fileName = $fileName;
		$this->extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
	}

	public function getFileName()
	{
		return $this->fileName;
	}

	public function getExtension()
	{
		return $this->extension;
	}

	public function getFileSize()
	{
		return $this->fileSize;
	}

	public function getTempFile()
	{
		return $this->tempFile;
	}

	public function getUploadError()
	{
		return $this->uploadError;
	}

	public function getExif(): ?array
	{
		return $this->exif;
	}

	public function setExif(array $exif)
	{
		$this->exif = array_replace_recursive($this->exif ?? [], $exif);
	}

	public function requireImage()
	{
		$this->imageRequired = true;
		$this->allowedExtensions = array_keys($this->getImageExtensionMap());

		return $this;
	}

	public function allowInvalidVideo()
	{
		// this may be used when transcoding videos that we may not match
		$this->requireValidVideo = false;

		return $this;
	}

	public function setMaxImageDimensions($maxWidth, $maxHeight)
	{
		$this->maxWidth = $maxWidth;
		$this->maxHeight = $maxHeight;

		return $this;
	}

	public function setImageOptimize(bool $optimize): void
	{
		$this->optimize = $optimize;
	}

	public function setMaxFileSize($maxSize)
	{
		$this->maxFileSize = $maxSize;

		return $this;
	}

	public function setMaxVideoSize($maxVideoSize)
	{
		$this->maxVideoSize = $maxVideoSize;
	}

	public function setAllowedExtensions(?array $extensions = null)
	{
		if (is_array($extensions))
		{
			$extensions = array_map('strtolower', $extensions);
		}

		$this->allowedExtensions = $extensions;

		return $this;
	}

	public function applyConstraints(array $constraints)
	{
		if (isset($constraints['extensions']) && is_array($constraints['extensions']))
		{
			$this->setAllowedExtensions($constraints['extensions']);
		}

		if (isset($constraints['size']))
		{
			$this->setMaxFileSize($constraints['size']);
		}

		if (isset($constraints['video_size']))
		{
			$this->setMaxVideoSize($constraints['video_size']);
		}

		if (isset($constraints['width']) || isset($constraints['height']))
		{
			$this->setMaxImageDimensions(
				$constraints['width'] ?? null,
				$constraints['height'] ?? null
			);
		}

		return $this;
	}

	protected function analyze()
	{
		if (!$this->tempFile || !$this->fileSize)
		{
			return;
		}

		$this->analyzeVideo();
		$this->analyzeAudio();
		$this->analyzeImage();

		$fp = @fopen($this->tempFile, 'rb');
		if ($fp)
		{
			$previous = '';
			while (!@feof($fp))
			{
				$content = fread($fp, 256000);
				$test = $previous . $content;
				$exists = (
					strpos($test, '<?php') !== false
					|| preg_match('/<script\s+language\s*=\s*(php|"php"|\'php\')\s*>/i', $test)
				);
				if ($exists)
				{
					$this->imageContentUnsafe = true;
					break;
				}

				$previous = $content;
			}

			@fclose($fp);
		}
	}

	protected function analyzeImage()
	{
		$this->isImage = false;

		$map = $this->getImageExtensionMap();
		if (!isset($map[$this->extension]))
		{
			// require image extension to even try anything
			return;
		}

		$imageInfo = @getimagesize($this->tempFile);
		if (!$imageInfo)
		{
			return;
		}

		$imageType = $imageInfo[2];
		if (!in_array($imageType, $map))
		{
			return;
		}

		if ($imageType != $map[$this->extension])
		{
			foreach ($map AS $newExtension => $extensionType)
			{
				if ($imageType == $extensionType)
				{
					$this->setFileName(pathinfo($this->fileName, PATHINFO_FILENAME) . ".$newExtension");
					break;
				}
			}
		}

		$this->isImage = true;
		$this->imageType = $imageType;
		$this->imageWidth = $imageInfo[0];
		$this->imageHeight = $imageInfo[1];
		$this->isOptimized = $imageType === IMAGETYPE_WEBP;

		$this->setImageOptimize(\XF::app()->options()->imageOptimization === 'optimize');

		if ($imageType == IMAGETYPE_JPEG && function_exists('exif_read_data'))
		{
			@ini_set('exif.encode_unicode', 'UTF-8');
			$exif = @exif_read_data($this->tempFile, null, true) ?: [];
		}
		else
		{
			$exif = [];
		}

		$this->exif = array_replace_recursive($this->exif ?? [], $exif);
	}

	protected function analyzeVideo()
	{
		$this->isVideo = false;

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

		$fp = @fopen($this->tempFile, 'rb');
		if (!$fp)
		{
			return;
		}

		$preamble = fread($fp, 1024);
		fclose($fp);

		$first4 = substr($preamble, 0, 4);
		$first8 = substr($preamble, 0, 8);

		$videoType = null;

		$mp4Ftypes = $this->getSupportedMp4Ftypes();
		$mp4FtypesRegex = implode('|', $mp4Ftypes);

		// Signatures adapted from: https://www.garykessler.net/library/file_sigs.html
		if (preg_match('#^(....)ftyp(M4V |qt  |mp4[0-9]|' . $mp4FtypesRegex . ')#i', $preamble, $match))
		{
			switch ($match[2])
			{
				case 'M4V ':
					$videoType = 'm4v';
					break;

				case 'qt  ':
					$videoType = 'mov';
					break;

				default:
					$videoType = 'mp4';
			}
		}
		else if (preg_match('#^....(moov)#', $preamble))
		{
			$videoType = 'mov';
		}
		else if ($first8 === "OggS\x00\x02\x00\x00")
		{
			$videoType = 'ogv';
		}
		else if ($first4 === "\x1A\x45\xDF\xA3") // MKV, which WebM is a subset of
		{
			$videoType = 'webm';
		}
		else if (preg_match('#^RIFF....CDXA#', $preamble))
		{
			$videoType = 'mpg';
		}
		else if ($first4 === "\x00\x00\x01\xBA" || $first4 === "\x00\x00\x01\xB3")
		{
			$videoType = 'mpg';
		}

		if (!$videoType)
		{
			return;
		}

		if ($this->extension !== $videoType)
		{
			$this->setFileName(pathinfo($this->fileName, PATHINFO_FILENAME) . ".$videoType");
		}

		$this->isVideo = true;
		$this->videoType = $videoType;
	}

	protected function getSupportedMp4Ftypes(): array
	{
		return [
			'3gp', 'avc1', 'f4v', 'iso2', 'iso5', 'iso6', 'isom', 'mmp4', 'mp4v', 'msnv',
			'ndas', 'ndsc', 'ndsh', 'ndsm', 'ndsp', 'ndss', 'ndxc', 'ndxh',
			'ndxm', 'ndxp', 'ndxs', 'xavc',
			// mp41/2 handled via regex
		];
	}

	protected function analyzeAudio()
	{
		$this->isAudio = false;

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

		$fp = @fopen($this->tempFile, 'rb');
		if (!$fp)
		{
			return;
		}

		// Fetch the first bytes of the file, overfetching and trimming is necessary as
		// some files have padding. Supports MP3s with or without an ID3v2 container.
		$preamble = strtoupper(bin2hex(ltrim(fread($fp, 256000))));
		fclose($fp);

		$first24 = substr($preamble, 0, 24);

		$audioType = null;

		if (
			strpos($first24, '494433') === 0 || // indicates an ID3v2 container
			strpos($first24, 'FFFB') === 0 ||
			strpos($first24, 'FFF3') === 0 ||
			strpos($first24, 'FFF2') === 0
		)
		{
			$audioType = 'mp3';
		}
		else if (
			strpos($first24, '52494646') === 0 &&
			strpos($first24, '57415645') === 16
		)
		{
			$audioType = 'wav';
		}
		else if (strpos($first24, '4F676753') === 0)
		{
			$audioType = 'ogg';
		}

		if (!$audioType)
		{
			return;
		}

		if ($this->extension !== $audioType)
		{
			$this->setFileName(pathinfo($this->fileName, PATHINFO_FILENAME) . ".$audioType");
		}

		$this->isAudio = true;
		$this->audioType = $audioType;
	}

	public function transformImage()
	{
		if ($this->transformed)
		{
			return $this;
		}
		$this->transformed = true;

		if (!$this->isImage || $this->imageContentUnsafe)
		{
			// do nothing, just let it error
			return $this;
		}

		$orientation = 0;
		if ($this->exif && !empty($this->exif['IFD0']['Orientation']) && $this->exif['IFD0']['Orientation'] > 1)
		{
			$orientation = $this->exif['IFD0']['Orientation'];
		}
		$transformRequired = ($orientation > 1);

		$maxWidth = $this->maxWidth;
		$maxHeight = $this->maxHeight;

		if ($orientation >= 5 && $orientation <= 8)
		{
			// after rotation the X and Y coords will be reversed,
			// so flip the limits to reflect the "after" value
			$maxHeight = $this->maxWidth;
			$maxWidth = $this->maxHeight;
		}

		$resizeRequired = (
			($maxWidth && $this->imageWidth > $maxWidth)
			|| ($maxHeight && $this->imageHeight > $maxHeight)
		);

		$optimizeRequired = $this->optimize && !$this->isOptimized;

		if ($resizeRequired || $transformRequired || $optimizeRequired)
		{
			$imageManager = \XF::app()->imageManager();
			if ($imageManager->canResize($this->imageWidth, $this->imageHeight))
			{
				$image = $imageManager->imageFromFile($this->tempFile);
				if ($image)
				{
					$save = false;

					if ($resizeRequired)
					{
						$image->resize($maxWidth ?: $maxHeight, $maxHeight ?: null);
						$save = true;
					}
					if ($transformRequired)
					{
						$image->transformByExif($orientation);
						$save = true;
					}
					if ($optimizeRequired)
					{
						$success = $image->optimizeImage($this->tempFile);
						if ($success)
						{
							$save = true;
							$this->isOptimized = true;
							$this->setFileName(pathinfo($this->fileName, PATHINFO_FILENAME) . '.webp');
						}
					}

					if ($save && $image->save($this->tempFile))
					{
						$this->imageWidth = $image->getWidth();
						$this->imageHeight = $image->getHeight();
						clearstatcache();
						$this->fileSize = filesize($this->tempFile);
					}
				}
				else
				{
					// treat as non-image
					$this->isImage = false;
				}
			}
		}

		return $this;
	}

	public function isImage()
	{
		return $this->isImage && $this->hasImageExtension();
	}

	public function isVideo()
	{
		return $this->isVideo && $this->hasVideoExtension();
	}

	public function getVideoType()
	{
		return $this->videoType;
	}

	public function getImageType()
	{
		return $this->imageType;
	}

	public function getImageWidth()
	{
		return $this->imageWidth;
	}

	public function getImageHeight()
	{
		return $this->imageHeight;
	}

	public function isValid(&$errors = [])
	{
		$this->transformImage();

		$errors = [];

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

		$isImage = $this->isImage();
		$isVideo = $this->isVideo();

		if ($this->imageRequired && !$isImage)
		{
			$errors['image'] = \XF::phrase('uploaded_file_must_be_valid_image');
		}

		if ($this->allowedExtensions && !in_array($this->extension, $this->allowedExtensions, true))
		{
			if ($this->allowedExtensions)
			{
				$allowedExtensions = '.' . implode(', .', $this->allowedExtensions);
				$errors['extension'] = \XF::phrase('uploaded_file_does_not_have_an_allowed_extension_allowed_x', ['allowed' => $allowedExtensions]);
			}
			else
			{
				$errors['extension'] = \XF::phrase('uploaded_file_does_not_have_an_allowed_extension');
			}
		}
		else if (!$this->isImage && $this->hasImageExtension())
		{
			$errors['extension'] = \XF::phrase('the_uploaded_file_was_not_an_image_as_expected');
		}
		else if (!$this->isVideo && $this->hasVideoExtension() && $this->requireValidVideo)
		{
			$errors['extension'] = \XF::phrase('the_uploaded_file_was_not_a_video_as_expected');
		}

		$this->isValidMaxFileSize($errors, $isVideo);

		if ($isImage)
		{
			if ($this->imageContentUnsafe)
			{
				$errors['content'] = \XF::phrase('uploaded_image_contains_invalid_content');
			}

			if (
				($this->maxWidth && $this->imageWidth > $this->maxWidth)
				|| ($this->maxHeight && $this->imageHeight > $this->maxHeight)
			)
			{
				$errors['dimensions'] = \XF::phrase('uploaded_image_is_too_big');
			}
		}

		$errors = array_merge($this->extraErrors, $errors);

		return count($errors) == 0;
	}

	/**
	 * Performs basic validation that we have received a valid file from the upload
	 *
	 * @param $errors
	 *
	 * @return bool
	 */
	public function isValidFile(&$errors = [])
	{
		if ($this->uploadError)
		{
			$errors['server'] = $this->getServerUploadError();
			return false;
		}

		if (!$this->tempFile)
		{
			$errors['server'] = \XF::phrase('uploaded_file_failed_not_found');
			return false;
		}

		if (!$this->fileSize)
		{
			$errors['fileSize'] = \XF::phrase('uploaded_file_empty_please_try_a_different_file');
			return false;
		}

		return true;
	}

	/**
	 * Checks that the uploaded file is within maximum file size constraints for the appropriate type
	 *
	 * @param $errors
	 * @param $isVideo
	 *
	 * @return bool
	 */
	public function isValidMaxFileSize(&$errors = [], bool $isVideo = false)
	{
		if ($isVideo)
		{
			if ($this->maxVideoSize && $this->fileSize > $this->maxVideoSize)
			{
				$errors['fileSize'] = \XF::phrase('uploaded_file_is_too_large');
				return false;
			}
		}
		else
		{
			if ($this->maxFileSize && $this->fileSize > $this->maxFileSize)
			{
				$errors['fileSize'] = \XF::phrase('uploaded_file_is_too_large');
				return false;
			}
		}

		return true;
	}

	public function logError($key, $error)
	{
		$this->extraErrors[$key] = $error;
	}

	public function getFileWrapper()
	{
		if (!$this->tempFile)
		{
			throw new \LogicException("Cannot get file wrapper for invalid upload (no temp file)");
		}

		$wrapper = new FileWrapper($this->tempFile, $this->fileName);
		if (is_array($this->exif))
		{
			$wrapper->setExif($this->exif);
		}

		return $wrapper;
	}

	protected function hasVideoExtension()
	{
		$map = \XF::app()->inlineVideoTypes;
		return isset($map[$this->extension]);
	}

	protected function hasAudioExtension()
	{
		$map = \XF::app()->inlineAudioTypes;
		return isset($map[$this->extension]);
	}

	protected function hasImageExtension()
	{
		$map = $this->getImageExtensionMap();
		return isset($map[$this->extension]);
	}

	protected function getImageExtensionMap()
	{
		return [
			'gif' => IMAGETYPE_GIF,
			'jpg' => IMAGETYPE_JPEG,
			'jpeg' => IMAGETYPE_JPEG,
			'jpe' => IMAGETYPE_JPEG,
			'png' => IMAGETYPE_PNG,
			'webp' => IMAGETYPE_WEBP,
		];
	}

	protected function getServerUploadError()
	{
		switch ($this->uploadError)
		{
			case UPLOAD_ERR_INI_SIZE:
				return \XF::phrase('uploaded_file_is_too_large_for_server_to_process');

			case UPLOAD_ERR_FORM_SIZE:
				return \XF::phrase('uploaded_file_is_too_large');

			case UPLOAD_ERR_PARTIAL:
				return \XF::phrase('uploaded_file_failed_partial_upload');

			case UPLOAD_ERR_NO_FILE:
				return \XF::phrase('uploaded_file_failed_not_found');

			case UPLOAD_ERR_NO_TMP_DIR:
				return \XF::phrase('uploaded_file_failed_tmp_dir');

			case UPLOAD_ERR_CANT_WRITE:
				return \XF::phrase('uploaded_file_failed_cant_write');

			case UPLOAD_ERR_EXTENSION:
				return \XF::phrase('uploaded_file_failed_extension_stopped_upload');

			default:
				return null;
		}
	}
}
