<?php
/**
 * @package     Joomla.Plugin
 * @subpackage  Filesystem.Local
 *
 * @copyright   (C) 2016 Open Source Matters, Inc. <https://www.joomla.org>
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\Plugin\Filesystem\Local\Adapter;

\defined('_JEXEC') or die;

use Joomla\CMS\Date\Date;
use Joomla\CMS\Factory;
use Joomla\CMS\Filesystem\File;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Filesystem\Path;
use Joomla\CMS\Helper\MediaHelper;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Image\Exception\UnparsableImageException;
use Joomla\CMS\Image\Image;
use Joomla\CMS\Language\Text;
use Joomla\CMS\String\PunycodeHelper;
use Joomla\CMS\Uri\Uri;
use Joomla\Component\Media\Administrator\Adapter\AdapterInterface;
use Joomla\Component\Media\Administrator\Exception\FileNotFoundException;
use Joomla\Component\Media\Administrator\Exception\InvalidPathException;

/**
 * Local file adapter.
 *
 * @since  4.0.0
 */
class LocalAdapter implements AdapterInterface
{
	/**
	 * The root path to gather file information from.
	 *
	 * @var string
	 *
	 * @since  4.0.0
	 */
	private $rootPath = null;

	/**
	 * The file_path of media directory related to site
	 *
	 * @var string
	 *
	 * @since  4.0.0
	 */
	private $filePath = null;

	/**
	 * The absolute root path in the local file system.
	 *
	 * @param   string  $rootPath  The root path
	 * @param   string  $filePath  The file path of media folder
	 *
	 * @since   4.0.0
	 */
	public function __construct(string $rootPath, string $filePath)
	{
		if (!file_exists($rootPath))
		{
			throw new \InvalidArgumentException;
		}

		$this->rootPath = Path::clean(realpath($rootPath), '/');
		$this->filePath = $filePath;
	}

	/**
	 * Returns the requested file or folder. The returned object
	 * has the following properties available:
	 * - type:          The type can be file or dir
	 * - name:          The name of the file
	 * - path:          The relative path to the root
	 * - extension:     The file extension
	 * - size:          The size of the file
	 * - create_date:   The date created
	 * - modified_date: The date modified
	 * - mime_type:     The mime type
	 * - width:         The width, when available
	 * - height:        The height, when available
	 *
	 * If the path doesn't exist a FileNotFoundException is thrown.
	 *
	 * @param   string  $path  The path to the file or folder
	 *
	 * @return  \stdClass
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	public function getFile(string $path = '/'): \stdClass
	{
		// Get the local path
		$basePath = $this->getLocalPath($path);

		// Check if file exists
		if (!file_exists($basePath))
		{
			throw new FileNotFoundException;
		}

		return $this->getPathInformation($basePath);
	}

	/**
	 * Returns the folders and files for the given path. The returned objects
	 * have the following properties available:
	 * - type:          The type can be file or dir
	 * - name:          The name of the file
	 * - path:          The relative path to the root
	 * - extension:     The file extension
	 * - size:          The size of the file
	 * - create_date:   The date created
	 * - modified_date: The date modified
	 * - mime_type:     The mime type
	 * - width:         The width, when available
	 * - height:        The height, when available
	 *
	 * If the path doesn't exist a FileNotFoundException is thrown.
	 *
	 * @param   string  $path  The folder
	 *
	 * @return  \stdClass[]
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	public function getFiles(string $path = '/'): array
	{
		// Get the local path
		$basePath = $this->getLocalPath($path);

		// Check if file exists
		if (!file_exists($basePath))
		{
			throw new FileNotFoundException;
		}

		// Check if the path points to a file
		if (is_file($basePath))
		{
			return [$this->getPathInformation($basePath)];
		}

		// The data to return
		$data = [];

		// Read the folders
		foreach (Folder::folders($basePath) as $folder)
		{
			$data[] = $this->getPathInformation(Path::clean($basePath . '/' . $folder));
		}

		// Read the files
		foreach (Folder::files($basePath) as $file)
		{
			$data[] = $this->getPathInformation(Path::clean($basePath . '/' . $file));
		}

		// Return the data
		return $data;
	}

	/**
	 * Returns a resource to download the path.
	 *
	 * @param   string  $path  The path to download
	 *
	 * @return  resource
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	public function getResource(string $path)
	{
		return fopen($this->rootPath . '/' . $path, 'r');
	}

	/**
	 * Creates a folder with the given name in the given path.
	 *
	 * It returns the new folder name. This allows the implementation
	 * classes to normalise the file name.
	 *
	 * @param   string  $name  The name
	 * @param   string  $path  The folder
	 *
	 * @return  string
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	public function createFolder(string $name, string $path): string
	{
		$name = $this->getSafeName($name);

		$localPath = $this->getLocalPath($path . '/' . $name);

		Folder::create($localPath);

		return $name;
	}

	/**
	 * Creates a file with the given name in the given path with the data.
	 *
	 * It returns the new file name. This allows the implementation
	 * classes to normalise the file name.
	 *
	 * @param   string  $name  The name
	 * @param   string  $path  The folder
	 * @param   binary  $data  The data
	 *
	 * @return  string
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	public function createFile(string $name, string $path, $data): string
	{
		$name = $this->getSafeName($name);

		$localPath = $this->getLocalPath($path . '/' . $name);

		$this->checkContent($localPath, $data);

		File::write($localPath, $data);

		return $name;
	}

	/**
	 * Updates the file with the given name in the given path with the data.
	 *
	 * @param   string  $name  The name
	 * @param   string  $path  The folder
	 * @param   binary  $data  The data
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	public function updateFile(string $name, string $path, $data)
	{
		$localPath = $this->getLocalPath($path . '/' . $name);

		if (!File::exists($localPath))
		{
			throw new FileNotFoundException;
		}

		$this->checkContent($localPath, $data);

		File::write($localPath, $data);
	}

	/**
	 * Deletes the folder or file of the given path.
	 *
	 * @param   string  $path  The path to the file or folder
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	public function delete(string $path)
	{
		$localPath = $this->getLocalPath($path);

		if (is_file($localPath))
		{
			if (!File::exists($localPath))
			{
				throw new FileNotFoundException;
			}

			$success = File::delete($localPath);
		}
		else
		{
			if (!Folder::exists($localPath))
			{
				throw new FileNotFoundException;
			}

			$success = Folder::delete($localPath);
		}

		if (!$success)
		{
			throw new \Exception('Delete not possible!');
		}
	}

	/**
	 * Returns the folder or file information for the given path. The returned object
	 * has the following properties:
	 * - type:          The type can be file or dir
	 * - name:          The name of the file
	 * - path:          The relative path to the root
	 * - extension:     The file extension
	 * - size:          The size of the file
	 * - create_date:   The date created
	 * - modified_date: The date modified
	 * - mime_type:     The mime type
	 * - width:         The width, when available
	 * - height:        The height, when available
	 * - thumb_path     The thumbnail path of file, when available
	 *
	 * @param   string  $path  The folder
	 *
	 * @return  \stdClass
	 *
	 * @since   4.0.0
	 */
	private function getPathInformation(string $path): \stdClass
	{
		// Prepare the path
		$path = Path::clean($path, '/');

		// The boolean if it is a dir
		$isDir = is_dir($path);

		$createDate   = $this->getDate(filectime($path));
		$modifiedDate = $this->getDate(filemtime($path));

		// Set the values
		$obj            = new \stdClass;
		$obj->type      = $isDir ? 'dir' : 'file';
		$obj->name      = $this->getFileName($path);
		$obj->path      = str_replace($this->rootPath, '', $path);
		$obj->extension = !$isDir ? File::getExt($obj->name) : '';
		$obj->size      = !$isDir ? filesize($path) : '';
		$obj->mime_type = MediaHelper::getMimeType($path, MediaHelper::isImage($obj->name));
		$obj->width     = 0;
		$obj->height    = 0;

		// Dates
		$obj->create_date             = $createDate->format('c', true);
		$obj->create_date_formatted   = HTMLHelper::_('date', $createDate, Text::_('DATE_FORMAT_LC5'));
		$obj->modified_date           = $modifiedDate->format('c', true);
		$obj->modified_date_formatted = HTMLHelper::_('date', $modifiedDate, Text::_('DATE_FORMAT_LC5'));

		if (MediaHelper::isImage($obj->name))
		{
			// Get the image properties
			try
			{
				$props       = Image::getImageFileProperties($path);
				$obj->width  = $props->width;
				$obj->height = $props->height;

				// Todo : Change this path to an actual thumbnail path
				$obj->thumb_path = $this->getUrl($obj->path);
			}
			catch (UnparsableImageException $e)
			{
				// Ignore the exception - it's an image that we don't know how to parse right now
			}
		}

		return $obj;
	}

	/**
	 * Returns a Date with the correct Joomla timezone for the given date.
	 *
	 * @param   string  $date  The date to create a Date from
	 *
	 * @return  Date
	 *
	 * @since   4.0.0
	 */
	private function getDate($date = null): Date
	{
		$dateObj = Factory::getDate($date);

		$timezone = Factory::getApplication()->get('offset');
		$user     = Factory::getUser();

		if ($user->id)
		{
			$userTimezone = $user->getParam('timezone');

			if (!empty($userTimezone))
			{
				$timezone = $userTimezone;
			}
		}

		if ($timezone)
		{
			$dateObj->setTimezone(new \DateTimeZone($timezone));
		}

		return $dateObj;
	}

	/**
	 * Copies a file or folder from source to destination.
	 *
	 * It returns the new destination path. This allows the implementation
	 * classes to normalise the file name.
	 *
	 * @param   string  $sourcePath       The source path
	 * @param   string  $destinationPath  The destination path
	 * @param   bool    $force            Force to overwrite
	 *
	 * @return  string
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	public function copy(string $sourcePath, string $destinationPath, bool $force = false): string
	{
		// Get absolute paths from relative paths
		$sourcePath      = Path::clean($this->getLocalPath($sourcePath), '/');
		$destinationPath = Path::clean($this->getLocalPath($destinationPath), '/');

		if (!file_exists($sourcePath))
		{
			throw new FileNotFoundException;
		}

		$name     = $this->getFileName($destinationPath);
		$safeName = $this->getSafeName($name);

		// If the safe name is different normalise the file name
		if ($safeName != $name)
		{
			$destinationPath = substr($destinationPath, 0, -\strlen($name)) . '/' . $safeName;
		}

		// Check for existence of the file in destination
		// if it does not exists simply copy source to destination
		if (is_dir($sourcePath))
		{
			$this->copyFolder($sourcePath, $destinationPath, $force);
		}
		else
		{
			$this->copyFile($sourcePath, $destinationPath, $force);
		}

		// Get the relative path
		$destinationPath = str_replace($this->rootPath, '', $destinationPath);

		return $destinationPath;
	}

	/**
	 * Copies a file
	 *
	 * @param   string  $sourcePath       Source path of the file or directory
	 * @param   string  $destinationPath  Destination path of the file or directory
	 * @param   bool    $force            Set true to overwrite files or directories
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	private function copyFile(string $sourcePath, string $destinationPath, bool $force = false)
	{
		if (is_dir($destinationPath))
		{
			// If the destination is a folder we create a file with the same name as the source
			$destinationPath = $destinationPath . '/' . $this->getFileName($sourcePath);
		}

		if (file_exists($destinationPath) && !$force)
		{
			throw new \Exception('Copy file is not possible as destination file already exists');
		}

		if (!File::copy($sourcePath, $destinationPath))
		{
			throw new \Exception('Copy file is not possible');
		}
	}

	/**
	 * Copies a folder
	 *
	 * @param   string  $sourcePath       Source path of the file or directory
	 * @param   string  $destinationPath  Destination path of the file or directory
	 * @param   bool    $force            Set true to overwrite files or directories
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	private function copyFolder(string $sourcePath, string $destinationPath, bool $force = false)
	{
		if (file_exists($destinationPath) && !$force)
		{
			throw new \Exception('Copy folder is not possible as destination folder already exists');
		}

		if (is_file($destinationPath) && !File::delete($destinationPath))
		{
			throw new \Exception('Copy folder is not possible as destination folder is a file and can not be deleted');
		}

		if (!Folder::copy($sourcePath, $destinationPath, '', $force))
		{
			throw new \Exception('Copy folder is not possible');
		}
	}

	/**
	 * Moves a file or folder from source to destination.
	 *
	 * It returns the new destination path. This allows the implementation
	 * classes to normalise the file name.
	 *
	 * @param   string  $sourcePath       The source path
	 * @param   string  $destinationPath  The destination path
	 * @param   bool    $force            Force to overwrite
	 *
	 * @return  string
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	public function move(string $sourcePath, string $destinationPath, bool $force = false): string
	{
		// Get absolute paths from relative paths
		$sourcePath      = Path::clean($this->getLocalPath($sourcePath), '/');
		$destinationPath = Path::clean($this->getLocalPath($destinationPath), '/');

		if (!file_exists($sourcePath))
		{
			throw new FileNotFoundException;
		}

		$name     = $this->getFileName($destinationPath);
		$safeName = $this->getSafeName($name);

		// If transliterating could not happen, and all characters except of the file extension are filtered out, then throw an error.
		if ($safeName === pathinfo($sourcePath, PATHINFO_EXTENSION))
		{
			throw new \Exception(Text::_('COM_MEDIA_ERROR_MAKESAFE'));
		}

		// If the safe name is different normalise the file name
		if ($safeName != $name)
		{
			$destinationPath = substr($destinationPath, 0, -\strlen($name)) . $safeName;
		}

		if (is_dir($sourcePath))
		{
			$this->moveFolder($sourcePath, $destinationPath, $force);
		}
		else
		{
			$this->moveFile($sourcePath, $destinationPath, $force);
		}

		// Get the relative path
		$destinationPath = str_replace($this->rootPath, '', $destinationPath);

		return $destinationPath;
	}

	/**
	 * Moves a file
	 *
	 * @param   string  $sourcePath       Absolute path of source
	 * @param   string  $destinationPath  Absolute path of destination
	 * @param   bool    $force            Set true to overwrite file if exists
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	private function moveFile(string $sourcePath, string $destinationPath, bool $force = false)
	{
		if (is_dir($destinationPath))
		{
			// If the destination is a folder we create a file with the same name as the source
			$destinationPath = $destinationPath . '/' . $this->getFileName($sourcePath);
		}

		if (!MediaHelper::checkFileExtension(pathinfo($destinationPath, PATHINFO_EXTENSION)))
		{
			throw new \Exception('Move file is not possible as the extension is invalid');
		}

		if (file_exists($destinationPath) && !$force)
		{
			throw new \Exception('Move file is not possible as destination file already exists');
		}

		if (!File::move($sourcePath, $destinationPath))
		{
			throw new \Exception('Move file is not possible');
		}
	}

	/**
	 * Moves a folder from source to destination
	 *
	 * @param   string  $sourcePath       Source path of the file or directory
	 * @param   string  $destinationPath  Destination path of the file or directory
	 * @param   bool    $force            Set true to overwrite files or directories
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	private function moveFolder(string $sourcePath, string $destinationPath, bool $force = false)
	{
		if (file_exists($destinationPath) && !$force)
		{
			throw new \Exception('Move folder is not possible as destination folder already exists');
		}

		if (is_file($destinationPath) && !File::delete($destinationPath))
		{
			throw new \Exception('Move folder is not possible as destination folder is a file and can not be deleted');
		}

		if (is_dir($destinationPath))
		{
			// We need to bypass exception thrown in JFolder when destination exists
			// So we only copy it in forced condition, then delete the source to simulate a move
			if (!Folder::copy($sourcePath, $destinationPath, '', true))
			{
				throw new \Exception('Move folder to an existing destination failed');
			}

			// Delete the source
			Folder::delete($sourcePath);

			return;
		}

		// Perform usual moves
		$value = Folder::move($sourcePath, $destinationPath);

		if ($value !== true)
		{
			throw new \Exception($value);
		}
	}

	/**
	 * Returns a url which can be used to display an image from within the "images" directory.
	 *
	 * @param   string  $path  Path of the file relative to adapter
	 *
	 * @return  string
	 *
	 * @since   4.0.0
	 */
	public function getUrl(string $path): string
	{
		return Uri::root() . $this->getEncodedPath($this->filePath . $path);
	}

	/**
	 * Returns the name of this adapter.
	 *
	 * @return  string
	 *
	 * @since   4.0.0
	 */
	public function getAdapterName(): string
	{
		return $this->filePath;
	}

	/**
	 * Search for a pattern in a given path
	 *
	 * @param   string  $path       The base path for the search
	 * @param   string  $needle     The path to file
	 * @param   bool    $recursive  Do a recursive search
	 *
	 * @return  \stdClass[]
	 *
	 * @since   4.0.0
	 */
	public function search(string $path, string $needle, bool $recursive = false): array
	{
		$pattern = Path::clean($this->getLocalPath($path) . '/*' . $needle . '*');

		if ($recursive)
		{
			$results = $this->rglob($pattern);
		}
		else
		{
			$results = glob($pattern);
		}

		$searchResults = [];

		foreach ($results as $result)
		{
			$searchResults[] = $this->getPathInformation($result);
		}

		return $searchResults;
	}

	/**
	 * Do a recursive search on a given path
	 *
	 * @param   string  $pattern  The pattern for search
	 * @param   int     $flags    Flags for search
	 *
	 * @return  array
	 *
	 * @since   4.0.0
	 */
	private function rglob(string $pattern, int $flags = 0): array
	{
		$files = glob($pattern, $flags);

		foreach (glob(\dirname($pattern) . '/*', GLOB_ONLYDIR | GLOB_NOSORT) as $dir)
		{
			$files = array_merge($files, $this->rglob($dir . '/' . $this->getFileName($pattern), $flags));
		}

		return $files;
	}

	/**
	 * Replace spaces on a path with %20
	 *
	 * @param   string  $path  The Path to be encoded
	 *
	 * @return  string
	 *
	 * @since   4.0.0
	 * @throws  FileNotFoundException
	 */
	private function getEncodedPath(string $path): string
	{
		return str_replace(" ", "%20", $path);
	}

	/**
	 * Creates a safe file name for the given name.
	 *
	 * @param   string  $name  The filename
	 *
	 * @return  string
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	private function getSafeName(string $name): string
	{
		// Make the filename safe
		if (!$name = File::makeSafe($name))
		{
			throw new \Exception(Text::_('COM_MEDIA_ERROR_MAKESAFE'));
		}

		// Transform filename to punycode
		$name = PunycodeHelper::toPunycode($name);

		// Get the extension
		$extension = File::getExt($name);

		// Normalise extension, always lower case
		if ($extension)
		{
			$extension = '.' . strtolower($extension);
		}

		$nameWithoutExtension = substr($name, 0, \strlen($name) - \strlen($extension));

		return $nameWithoutExtension . $extension;
	}

	/**
	 * Performs various check if it is allowed to save the content with the given name.
	 *
	 * @param   string  $localPath     The local path
	 * @param   string  $mediaContent  The media content
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	private function checkContent(string $localPath, string $mediaContent)
	{
		$name = $this->getFileName($localPath);

		// The helper
		$helper = new MediaHelper;

		// @todo find a better way to check the input, by not writing the file to the disk
		$tmpFile = Path::clean(\dirname($localPath) . '/' . uniqid() . '.' . File::getExt($name));

		if (!File::write($tmpFile, $mediaContent))
		{
			throw new \Exception(Text::_('JLIB_MEDIA_ERROR_UPLOAD_INPUT'), 500);
		}

		$can = $helper->canUpload(['name' => $name, 'size' => \strlen($mediaContent), 'tmp_name' => $tmpFile], 'com_media');

		File::delete($tmpFile);

		if (!$can)
		{
			throw new \Exception(Text::_('JLIB_MEDIA_ERROR_UPLOAD_INPUT'), 403);
		}
	}

	/**
	 * Returns the file name of the given path.
	 *
	 * @param   string  $path  The path
	 *
	 * @return  string
	 *
	 * @since   4.0.0
	 * @throws  \Exception
	 */
	private function getFileName(string $path): string
	{
		$path = Path::clean($path);

		// Basename does not work here as it strips out certain characters like upper case umlaut u
		$path = explode(DIRECTORY_SEPARATOR, $path);

		// Return the last element
		return array_pop($path);
	}

	/**
	 * Returns the local filesystem path for the given path.
	 *
	 * Throws an InvalidPathException if the path is invalid.
	 *
	 * @param   string  $path  The path
	 *
	 * @return  string
	 *
	 * @since   4.0.0
	 * @throws  InvalidPathException
	 */
	private function getLocalPath(string $path): string
	{
		try
		{
			return Path::check($this->rootPath . '/' . $path);
		}
		catch (\Exception $e)
		{
			throw new InvalidPathException($e->getMessage());
		}
	}
}
