<?php

use Intellex\Upload\UploadFile;

/**
 * Class Upload
 * Handles the upload across whole CMS.
 */
class Upload extends AppModel {

	/** @var string The name of the Model. */
	var $name = 'Upload';

	/** @var string The name used to transport temporal uploads from HTTP forms. */
	const POST_PLACEHOLDER = 'UploadPlaceholder1507199329';

	/** @var string The glue used to stich path ang its security hash. */
	const HASH_GLUE = '::';

	/** @var string Indicates that the uploaded file is an image. */
	const IMAGE = 'image';

	/** @var string Indicates that the uploaded file is a document. */
	const DOC = 'doc';

	/** @var string Indicates that the uploaded file is a plain text document. */
	const TXT = 'doc';

	/** @var string Indicates that the type of uploaded file cannot be determined. */
	const UNKNOWN = null;

	/** @const int The width for preview in forms. */
	const PREVIEW_WIDTH = 600;

	/** @const int The height for preview in forms. */
	const PREVIEW_HEIGHT = 480;

	/** @var string[] The list of allowed extensions. */
	public static $ALLOWED_EXTENSIONS = [ 'jpg', 'jpeg', 'gif', 'png', 'zip', 'tar', 'gz', 'tgz', 'bz', 'bz2', 'flv', 'pdf', 'doc', 'docx', 'ppt', 'odt', 'xls', 'xlsx', 'wav', 'mp3', 'ogg', 'wmv', 'wma', 'txt', 'ai', 'cdr', 'js', 'css', 'mp4', 'webm', 'svg' ];

	/** @var string[] The list of mimetypes that are considered as images. */
	public static $IMAGE_TYPES = [ 'image/jpeg', 'image/png', 'image/gif' ];

	/** @var array The default config. */
	public static $config = [];

	/**
	 * Move temporal image to its final position.
	 *
	 * @param UploadFile $file        The file to move.
	 * @param string     $name        The desired name of the new file.
	 * @param string     $model       The model of the upload.
	 * @param string     $association The association of the upload.
	 *
	 * @return UploadFile The moved file.
	 */
	public static function moveFile($file, $name, $model, $association) {
		return new UploadFile($file->copyTo(static::getUploadDir($model, $association) . $name));
	}

	/**
	 * Get the path to the directory for supplied upload.
	 *
	 * @param string $model       The model of the upload.
	 * @param string $association The association of the upload.
	 *
	 * @return string The path to the directory where file should be stored.
	 */
	public static function getUploadDir($model, $association) {
		return WWW_ROOT . UPLOAD_DIR . DS . $model . DS . $association . DS . date('Y-m') . DS;
	}

	/**
	 * Get the preview URL for a file stored in temporal directory.
	 *
	 * @param string $token     The token which represents a file in the temporal directory.
	 * @param string $extension The extension to add to the preview URL.
	 *
	 * @return string The URL to the token.
	 */
	public static function getPreviewURLForToken($token, $extension = null) {
		return '/upload/preview/' . urlencode($token) . ($extension ? "?ext=.{$extension}" : null);
	}

	/** @inheritdoc */
	function beforeSave($options = []) {

		try {

			// Whitelist the fields
			$whitelist = [ 'id', 'token', 'manual', 'filename', 'extension', 'remove', 'model', 'association', 'foreign_key', 'alt', 'meta', 'ordering' ];
			foreach ($this->data[$this->alias] as $column => $value) {
				if (!in_array($column, $whitelist)) {
					unset($this->data[$this->alias][$column]);
				}
			}
			$data = $this->data[$this->alias];

			// Extract interesting keys from meta data
			if (key_exists('meta', $data)) {
				$specials = [ 'alt', 'locale', 'ordering' ];
				foreach ($specials as $key) {
					if (empty($data[$key]) && is_array($data['meta']) && key_exists($key, $data['meta'])) {
						$data[$key] = $data['meta'][$key];
						unset($data['meta'][$key]);
					}
				}
			}

			// Get config
			$config = static::$config[$data['model']][$data['association']];
			$config['extensions'] = array_intersect(static::$ALLOWED_EXTENSIONS, $config['extensions']);

			// Get previous state
			$previous = !empty($data['id']) ? $this->find('first', $data['id']) : null;

			// New file
			if (empty($data['id']) || !empty($data['token'])) {
				$file = null;
				$action = null;
				$filename = null;

				// From token
				if (!empty($data['token'])) {
					$action = 'moveTo';

					// Make sure filename is usable
					static::assertFilenameIsUsable($data['filename']);
					$filename = $data['filename'];

					// Validate both supplied extension and read from mime type
					$file = static::loadFileFromToken($data['token']);
					static::assert(in_array($file->getExtension(), $config['extensions']), __('Allowed extensions are: %s', implode(', ', $config['extensions'])));
					static::assert(in_array($file->getExtension(true), $config['extensions']), __('Allowed extensions are: %s', implode(', ', $config['extensions'])));

					// Manual
				} else if (!empty($data['manual'])) {
					$action = 'copyTo';

					// Validate
					if (strpos($data['manual'], static::HASH_GLUE)) {
						list($path, $signature) = explode(static::HASH_GLUE, $data['manual'], 2);
						if ($signature === Upload::hashPath($path, false)) {
							$file = new UploadFile($path);
						}
					}
				}

				// Save file
				if ($file && in_array($action, [ 'moveTo', 'copyTo' ])) {

					// Default filename
					if ($filename === null) {
						$filename = $file->getFilename();
					}

					// Move file
					$name = $filename . '.' . $file->getExtension(true);
					$destination = new UploadFile(static::getUploadDir($data['model'], $data['association']) . $name);
					$destination->assureUniqueFilename();
					$file->$action($destination);
					chmod($file->getPath(), 0664);

					// Set data
					$data['file'] = substr($file->getPath(), strlen(WWW_ROOT) - 1);
					$data['path'] = dirname($data['file']) . DS;
					$data['filename'] = $file->getFilename();
					$data['extension'] = $file->getExtension(true);
					$data['filesize'] = $file->getSize();
					$data['mimetype'] = $file->getMimetype();
					$data['extra'] = $file->getExtra();
					$data['meta'] = isset($data['meta']) ? serialize($data['meta']) : 'a:0:{}';

					// Dates
					$data['modified_by'] = $data['created_by'] = AuthComponent::user('id');
					$data['modified'] = $data['created'] = date('Y-m-d H:i:s');
				}

				// Existing files
			} else {

				// Delete
				if (key_exists('remove', $data) && $data['remove'] === 'remove') {
					return $this->deleteUpload($previous, $data);

					// Rename
				} else if ($data['filename'] !== $previous[$this->alias]['filename']) {

					// Make sure filename is usable
					static::assertFilenameIsUsable($data['filename']);

					// Validate extension
					static::assert(in_array($data['extension'], $config['extensions']), __('Allowed extensions are: %s', implode(', ', $config['extensions'])));

					// Move the file
					$path = static::getUploadDir($data['model'], $data['association']);
					$existing = new UploadFile(WWW_ROOT . $previous[$this->alias]['file']);
					$destination = new UploadFile($path . $data['filename'] . '.' . $previous[$this->alias]['extension']);
					$existing->moveTo($destination->assureUniqueFilename());

					// Update the data
					$data['file'] = substr($existing->getPath(), strlen(WWW_ROOT) - 1);
					$data['path'] = dirname($data['file']);
					$data['filename'] = $existing->getFilename();
					$data['extension'] = $existing->getExtension();
				}
			}

		} catch (Exception $ex) {
			$this->invalidate('file', $ex->getMessage());
			return false;
		}

		// Auto: Check if image
		if (isset($file) && key_exists('mimetype', $data)) {
			$data['is_image'] = static::isImage($file->getMimetype());
		}

		// Auto: Set the width and height
		if (key_exists('extra', $data) && preg_match('~^\s*(?P<width>\d+)\s*(?:px)?\s*x\s*(?P<height>\d+)\s*(?:px)\s*$~', $data['extra'], $dimenstion)) {
			$data['width'] = $dimenstion['width'];
			$data['height'] = $dimenstion['height'];
		}

		// Handle cross-platform
		$pathColumns = [ 'file', 'path' ];
		foreach ($pathColumns as $column) {
			if (key_exists($column, $data)) {
				$data[$column] = str_replace(DS, '/', $data[$column]);
			}
		}

		$this->data[$this->alias] = $data;
		return true;
	}

	/**
	 * Check if the supplied string can be used as filename.
	 *
	 * @param string $filename The filename to test.
	 *
	 * @throws UploadNotValidException If assert fails.
	 */
	private static function assertFilenameIsUsable($filename) {
		static::assert($filename !== '', __('Please specify name of the file'));
		static::assert(UploadFile::isValidFilename($filename), __('Name of file cannot contain following characters: < > : " | ? *'));
	}

	/**
	 * Assert that a test is passed.
	 *
	 * @param boolean $test    The test to check.
	 * @param string  $message The exception message if test fails.
	 *
	 * @throws UploadNotValidException If assert fails.
	 */
	private static function assert($test, $message) {
		if (!$test) {
			throw new UploadNotValidException($message);
		}
	}

	/**
	 * Get the file stored in temporal directory.
	 *
	 * @param string $token The token which represents a file in the temporal directory.
	 *
	 * @return Intellex\Upload\UploadFile The URL to the token.
	 */
	public static function loadFileFromToken($token) {
		return \Intellex\Upload\Handler::loadFile($token, TMP . 'upload', SECURITY_SALT);
	}

	/**
	 * Prepare the data from the supplied token.
	 *
	 * @param string      $model       The model to attach to.
	 * @param string      $association The association to use.
	 * @param string      $token       The token of the temporal file.
	 * @param string|null $filename    The desired filename, or null to generate random filename.
	 * @param mixed       $meta        Additional meta information about the uploaded file.
	 * @param int         $foreignKey  The foreign key that is used to remove previous single
	 *                                 uploads, set to null for new records or multiple
	 *                                 associations.
	 *
	 * @return array The prepared data.
	 */
	public static function prepareUploadFromToken($model, $association, $token, $filename = null, $meta = [], $foreignKey = null) {
		$id = null;

		// Default filename
		if (empty($filename)) {
			$filename = getRandomString(32);
		}

		// For single
		if ($foreignKey) {
			$conditions = [
				'model'       => $model,
				'association' => $association,
				'foreign_key' => $foreignKey,
				'is_deleted'  => false ];

			// If there is a locale
			if (key_exists('locale', $meta)) {
				$conditions['locale'] = $meta['locale'];
			}

			// Get the existing upload
			$upload = ClassRegistry::init('Upload')->find('first', [
				'recursive'  => -1,
				'fields'     => 'id',
				'conditions' => $conditions
			]);
			if ($upload) {
				$id = res(res($upload));
			}
		}

		return compact('id', 'model', 'association', 'token', 'filename', 'meta');
	}

	/**
	 * Delete an upload from filesystem.
	 *
	 * @param array $upload The info about upload from database.*
	 * @param array $data   The data that should be saved.
	 *
	 * @return bool Always returns true.
	 */
	private function deleteUpload($upload, $data) {

		// Delete by existing upload
		if ($upload) {
			try {
				$existing = new UploadFile(WWW_ROOT . $upload[$this->alias]['file']);
				$existing->delete();
			} catch (Exception $e) {
				CakeLog::error('Error while deleting upload: ' . $e->getMessage());
			}
		}

		// Delete temporal file
		if (!empty($data['token'])) {
			try {
				$file = static::loadFileFromToken($data['token']);
				$file->delete();
			} catch (Exception $e) {
				CakeLog::error('Error while deleting temporal upload: ' . $e->getMessage());
			}
		}

		// Mark as deleted
		$this->data[$this->alias]['is_deleted'] = true;

		return true;
	}

	/**
	 * Manually add a file from remote URL to the uploads.
	 *
	 * @param string $url         The URL to the file to add.
	 * @param int    $key         The foreign key of the model
	 * @param string $model       The name of the model.
	 * @param string $association The name of the association.
	 * @param mixed  $meta        Additional meta data, special keys are: 'alt' and 'ordering'.
	 * @param bool   $single      True for single uploads, false for multiple.
	 *
	 * @return array|null The data saved in the database, or null on error.
	 * @throws Exception
	 */
	public static function createFromRemoteURL($url, $key, $model, $association, $meta, $single = false) {
		$upload = null;

		// Download
		$data = file_get_contents($url);
		if (!$data) {
			throw new Exception("Unable to create uploads as remote URL does not return any data: `{$url}`.");
		}

		// Write to temporal file
		$file = new UploadFile(tempnam(TMP, 'remote_'));
		$file->write(file_get_contents($url));

		// Insert using manual
		$upload = static::createFromLocalFile($file->getPath(), $key, $model, $association, $meta, $single);

		// Remove temporal file
		try {
			$file->delete();
		} catch (Exception $ex) {
			$path = $file->getPath();
			CakeLog::error("Unable to remove temporal file after creating upload from remote URL: `{$path}`.");
		}

		return $upload;
	}

	/**
	 * Manually add a file from local filesystem to the uploads.
	 *
	 * @param string $file        The path to the file to save as upload.
	 * @param int    $key         The foreign key of the model
	 * @param string $model       The name of the model.
	 * @param string $association The name of the association.
	 * @param mixed  $meta        Additional meta data, special keys are: 'alt', 'locale' and
	 *                            'ordering'.
	 * @param bool   $single      True for single uploads, false for multiple.
	 *
	 * @return array|null The data saved in the database, or null on error.
	 * @throws Exception
	 */
	public static function createFromLocalFile($file, $key, $model, $association, $meta = [], $single = false) {
		$instance = ClassRegistry::init([ 'class' => 'Upload', 'alias' => $association, 'table' => 'uploads' ]);

		// Prepare the data
		$data = [
			'model'       => $model,
			'foreign_key' => $key,
			'association' => $association,
			'meta'        => $meta,
			'manual'      => Upload::hashPath($file, true)
		];

		// Save
		$upload = $instance->save([ $association => $data ]);
		if (!$upload) {
			throw new Exception("Unable to create file from `{$file}`.");
		}

		// Mark previous uploads as deleted
		if ($single) {
			$instance->updateAll(
				[ $instance->alias . ' . is_deleted' => true ],
				[
					$instance->alias . '.id <> '      => $upload[$association]['id'],
					$instance->alias . '.model'       => $model,
					$instance->alias . '.association' => $association,
					$instance->alias . '.foreign_key' => $key ]
			);
		}

		$instance->clearCache();
		return $upload;
	}

	/**
	 * Check if the supplied mime type is an image.
	 *
	 * @param string $type The mime type to check
	 *
	 * @return bool True if the mime type is of image.
	 */
	public static function isImage($type) {
		return in_array($type, static::$IMAGE_TYPES);
	}

	/**
	 * Hash a path so it can be verified later.
	 *
	 * @param string $path The path to hash.
	 * @param bool   $glue True to return both path and hash, glued with static::HASH_GLUE, or
	 *                     false to return only hash.
	 *
	 * @return string Tha hashed representation of a supplied path.
	 */
	public static function hashPath($path, $glue = false) {
		return ($glue ? $path . static::HASH_GLUE : null) . hash('sha256', $path . SECURITY_SALT);
	}

}

class UploadNotValidException extends RuntimeException {
}
