<?php
/**
 * @package   FOF
 * @copyright Copyright (c)2010-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   GNU General Public License version 3, or later
 */

namespace FOF40\Download;

defined('_JEXEC') || die;

use FOF40\Container\Container;
use FOF40\Download\Exception\DownloadError;
use FOF40\Timer\Timer;
use Joomla\CMS\Language\Text;

class Download
{
	/**
	 * The component container object
	 *
	 * @var  Container
	 */
	protected $container;

	/**
	 * Parameters passed from the GUI when importing from URL
	 *
	 * @var  array
	 */
	private $params = [];

	/**
	 * The download adapter which will be used by this class
	 *
	 * @var  DownloadInterface
	 */
	private $adapter;

	/**
	 * Additional params that will be passed to the adapter while performing the download
	 *
	 * @var  array
	 */
	private $adapterOptions = [];

	/**
	 * Public constructor
	 *
	 * @param   Container  $c  The component container
	 */
	public function __construct(Container $c)
	{
		$this->container = $c;

		// Find the best fitting adapter
		$allAdapters = self::getFiles(__DIR__ . '/Adapter', [], ['AbstractAdapter.php']);
		$priority    = 0;

		foreach ($allAdapters as $adapterInfo)
		{
			/** @var Adapter\AbstractAdapter $adapter */
			$adapter = new $adapterInfo['classname'];

			if (!$adapter->isSupported())
			{
				continue;
			}

			if ($adapter->priority > $priority)
			{
				$this->adapter = $adapter;
				$priority      = $adapter->priority;
			}
		}

		// Load the language strings
		$c->platform->loadTranslations('lib_fof40');
	}

	/**
	 * This method will crawl a starting directory and get all the valid files
	 * that will be analyzed by __construct. Then it organizes them into an
	 * associative array.
	 *
	 * @param   string  $path           Folder where we should start looking
	 * @param   array   $ignoreFolders  Folder ignore list
	 * @param   array   $ignoreFiles    File ignore list
	 *
	 * @return  array   Associative array, where the `fullpath` key contains the path to the file,
	 *                  and the `classname` key contains the name of the class
	 */
	protected static function getFiles(string $path, array $ignoreFolders = [], array $ignoreFiles = []): array
	{
		$return = [];

		$files = self::scanDirectory($path, $ignoreFolders, $ignoreFiles);

		// Ok, I got the files, now I have to organize them
		foreach ($files as $file)
		{
			$clean = str_replace($path, '', $file);
			$clean = trim(str_replace('\\', '/', $clean), '/');

			$parts = explode('/', $clean);

			$return[] = [
				'fullpath'  => $file,
				'classname' => '\\FOF40\\Download\\Adapter\\' . ucfirst(basename($parts[0], '.php')),
			];
		}

		return $return;
	}

	/**
	 * Recursive function that will scan every directory unless it's in the
	 * ignore list. Files that aren't in the ignore list are returned.
	 *
	 * @param   string  $path           Folder where we should start looking
	 * @param   array   $ignoreFolders  Folder ignore list
	 * @param   array   $ignoreFiles    File ignore list
	 *
	 * @return  array   List of all the files
	 */
	protected static function scanDirectory(string $path, array $ignoreFolders = [], array $ignoreFiles = []): array
	{
		$return = [];

		$handle = @opendir($path);

		if (!$handle)
		{
			return $return;
		}

		while (($file = readdir($handle)) !== false)
		{
			if ($file == '.' || $file == '..')
			{
				continue;
			}

			$fullpath = $path . '/' . $file;

			if ((is_dir($fullpath) && in_array($file, $ignoreFolders)) || (is_file($fullpath) && in_array($file, $ignoreFiles)))
			{
				continue;
			}

			if (is_dir($fullpath))
			{
				$return = array_merge(self::scanDirectory($fullpath, $ignoreFolders, $ignoreFiles), $return);
			}
			else
			{
				$return[] = $path . '/' . $file;
			}
		}

		return $return;
	}

	/**
	 * Forces the use of a specific adapter
	 *
	 * @param   string  $className  The name of the class or the name of the adapter
	 */
	public function setAdapter(?string $className = null): void
	{
		if (is_null($className))
		{
			return;
		}

		$adapter = null;

		if (class_exists($className, true))
		{
			$adapter = new $className;
		}
		elseif (class_exists('\\FOF40\\Download\\Adapter\\' . ucfirst($className)))
		{
			$className = '\\FOF40\\Download\\Adapter\\' . ucfirst($className);
			$adapter   = new $className;
		}

		if (!is_object($adapter))
		{
			return;
		}

		if (!$adapter instanceof DownloadInterface)
		{
			return;
		}

		$this->adapter = $adapter;
	}

	/**
	 * Returns the name of the current adapter
	 *
	 * @return string
	 */
	public function getAdapterName(): string
	{
		if (is_object($this->adapter))
		{
			$class = get_class($this->adapter);

			return strtolower(str_ireplace('FOF40\\Download\\Adapter\\', '', $class));
		}

		return '';
	}

	/**
	 * Returns the additional options for the adapter
	 *
	 * @return array
	 *
	 * @codeCoverageIgnore
	 */
	public function getAdapterOptions(): array
	{
		return $this->adapterOptions;
	}

	/**
	 * Sets the additional options for the adapter
	 *
	 * @param   array  $options
	 *
	 * @codeCoverageIgnore
	 */
	public function setAdapterOptions(array $options): void
	{
		$this->adapterOptions = $options;
	}

	/**
	 * Download data from a URL and return it.
	 *
	 * Important note about ranges: byte ranges start at 0. This means that the first 500 bytes of a file are from 0
	 * to 499, NOT from 1 to 500. If you ask more bytes than there are in the file or a range which is invalid or does
	 * not exist this method will return false.
	 *
	 * @param   string  $url   The URL to download from
	 * @param   int     $from  Byte range to start downloading from. Use null (default) for start of file.
	 * @param   int     $to    Byte range to stop downloading. Use null to download the entire file ($from will be
	 *                         ignored!)
	 *
	 * @return  string  The downloaded data or null on failure
	 */
	public function getFromURL(string $url, ?int $from = null, ?int $to = null): ?string
	{
		try
		{
			return $this->adapter->downloadAndReturn($url, $from, $to, $this->adapterOptions);
		}
		catch (DownloadError $e)
		{
			return null;
		}
	}

	/**
	 * Performs the staggered download of file.
	 *
	 * @param   array  $params  A parameters array, as sent by the user interface
	 *
	 * @return  array  A return status array
	 */
	public function importFromURL(array $params): array
	{
		$this->params = $params;

		// Fetch data
		$url           = $this->getParam('url');
		$localFilename = $this->getParam('localFilename');
		$frag          = $this->getParam('frag', -1);
		$totalSize     = $this->getParam('totalSize', -1);
		$doneSize      = $this->getParam('doneSize', -1);
		$maxExecTime   = $this->getParam('maxExecTime', 5);
		$runTimeBias   = $this->getParam('runTimeBias', 75);
		$length        = $this->getParam('length', 1048576);

		if (empty($localFilename))
		{
			$localFilename = basename($url);

			if (strpos($localFilename, '?') !== false)
			{
				$paramsPos     = strpos($localFilename, '?');
				$localFilename = substr($localFilename, 0, $paramsPos - 1);

				$platformBaseDirectories = $this->container->platform->getPlatformBaseDirs();
				$tmpDir                  = $platformBaseDirectories['tmp'];
				$tmpDir                  = rtrim($tmpDir, '/\\');

				$localFilename = $tmpDir . '/' . $localFilename;
			}
		}

		// Init retArray
		$retArray = [
			"status"    => true,
			"error"     => '',
			"frag"      => $frag,
			"totalSize" => $totalSize,
			"doneSize"  => $doneSize,
			"percent"   => 0,
			"localfile" => $localFilename,
		];

		try
		{
			$timer = new Timer($maxExecTime, $runTimeBias);
			$start = $timer->getRunningTime(); // Mark the start of this download
			$break = false; // Don't break the step

			do
			{
				// Do we have to initialize the file?
				if ($frag == -1)
				{
					// Currently downloaded size
					$doneSize = 0;

					if (@file_exists($localFilename))
					{
						@unlink($localFilename);
					}

					// Delete and touch the output file
					$fp = @fopen($localFilename, 'w');

					if ($fp !== false)
					{
						@fclose($fp);
					}

					// Init
					$frag = 0;

					$retArray['totalSize'] = $this->adapter->getFileSize($url);

					if ($retArray['totalSize'] <= 0)
					{
						$retArray['totalSize'] = 0;
					}

					$totalSize = $retArray['totalSize'];
				}

				// Calculate from and length
				$from = $frag * $length;
				$to   = $length + $from - 1;

				// Try to download the first frag
				$required_time = 1.0;

				$error = '';

				try
				{
					$result = $this->adapter->downloadAndReturn($url, $from, $to, $this->adapterOptions);
				}
				catch (DownloadError $e)
				{
					$result = false;
					$error  = $e->getMessage();
				}

				if ($result === false)
				{
					// Failed download
					if ($frag == 0)
					{
						// Failure to download first frag = failure to download. Period.
						$retArray['status'] = false;
						$retArray['error']  = $error;

						return $retArray;
					}
					else
					{
						// Since this is a staggered download, consider this normal and finish
						$frag      = -1;
						$totalSize = $doneSize;
						$break     = true;
					}
				}

				// Add the currently downloaded frag to the total size of downloaded files
				if ($result !== false)
				{
					$fileSize = strlen($result);
					$doneSize += $fileSize;

					// Append the file
					$fp = @fopen($localFilename, 'a');

					if ($fp === false)
					{
						// Can't open the file for writing
						$retArray['status'] = false;
						$retArray['error']  = Text::sprintf('LIB_FOF40_DOWNLOAD_ERR_COULDNOTWRITELOCALFILE', $localFilename);

						return $retArray;
					}

					fwrite($fp, $result);
					fclose($fp);

					$frag++;

					if (($fileSize < $length) || ($fileSize > $length)
						|| (($totalSize == $doneSize) && ($totalSize > 0))
					)
					{
						// A partial download or a download larger than the frag size means we are done
						$frag = -1;
						//debugMsg("-- Import complete (partial download of last frag)");
						$totalSize = $doneSize;
						$break     = true;
					}
				}

				// Advance the frag pointer and mark the end
				$end = $timer->getRunningTime();

				// Do we predict that we have enough time?
				$required_time = max(1.1 * ($end - $start), $required_time);

				if ($required_time > (10 - $end + $start))
				{
					$break = true;
				}

				$start = $end;

			} while (($timer->getTimeLeft() > 0) && !$break);

			if ($frag == -1)
			{
				$percent = 100;
			}
			elseif ($doneSize <= 0)
			{
				$percent = 0;
			}
			elseif ($totalSize > 0)
			{
				$percent = 100 * ($doneSize / $totalSize);
			}
			else
			{
				$percent = 0;
			}

			// Update $retArray
			$retArray = [
				"status"    => true,
				"error"     => '',
				"frag"      => $frag,
				"totalSize" => $totalSize,
				"doneSize"  => $doneSize,
				"percent"   => $percent,
			];
		}
		catch (DownloadError $e)
		{
			$retArray['status'] = false;
			$retArray['error']  = $e->getMessage();
		}

		return $retArray;
	}

	/**
	 * Used to decode the $params array
	 *
	 * @param   string  $key      The parameter key you want to retrieve the value for
	 * @param   mixed   $default  The default value, if none is specified
	 *
	 * @return  mixed  The value for this parameter key
	 */
	private function getParam(string $key, $default = null)
	{
		if (array_key_exists($key, $this->params))
		{
			return $this->params[$key];
		}
		else
		{
			return $default;
		}
	}
}
