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

namespace Akeeba\Component\AkeebaBackup\Administrator\Model;

defined('_JEXEC') || die;

use Akeeba\Component\AkeebaBackup\Administrator\Mixin\GetErrorsFromExceptionsTrait;
use Akeeba\Component\AkeebaBackup\Administrator\Model\Exceptions\FrozenRecordError;
use Akeeba\Engine\Factory;
use Akeeba\Engine\Platform;
use Akeeba\Engine\Postproc\Exception\RangeDownloadNotSupported;
use Exception;
use Joomla\CMS\Factory as JoomlaFactory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use RuntimeException;

#[\AllowDynamicProperties]
class RemotefilesModel extends BaseDatabaseModel
{
	use GetErrorsFromExceptionsTrait;

	/**
	 * The fragment size for chunked downloads. Default: 1MB
	 */
	public const DOWNLOAD_FRAGMENT_SIZE = 1048576;

	/**
	 * Returns information about the capabilities of the post-processing engine used with a specific backup record.
	 *
	 * @param   int  $id
	 *
	 * @return  array
	 * @throws  Exception
	 */
	public function getCapabilities(int $id): array
	{
		$postProcEngineName = $this->getPostProcEngineNameForRecord($id, false);

		if ($postProcEngineName == 'none')
		{
			// There's no file stored remotely. Get the post-proc engine from the profile.
			$postProcEngineName = $this->getPostProcEngineNameForRecord($id, true);
		}

		$postProcEngine = Factory::getPostprocEngine($postProcEngineName);

		return [
			'engine'            => $postProcEngineName,
			'delete'            => $postProcEngine->supportsDelete(),
			'downloadToFile'    => $postProcEngine->supportsDownloadToFile(),
			'downloadToBrowser' => $postProcEngine->supportsDownloadToBrowser(),
			'inlineDownload'    => $postProcEngine->doesInlineDownloadToBrowser(),
		];
	}

	/**
	 * Returns the definitions of the Manage Remotely Stored Files action for a given backup record.
	 *
	 * Returns an icon definition list for the applicable actions on this backup record
	 *
	 * @param   int  $id  The backup record ID to return action definitions for
	 *
	 * @return  array The action definitions
	 * @throws  Exception
	 */
	public function getActions(int $id): array
	{
		$actions = [
			'downloadToFile'    => false,
			'delete'            => false,
			'downloadToBrowser' => 0,
		];

		$postProcEngineName = $this->getPostProcEngineNameForRecord($id);
		$postProcEngine     = Factory::getPostprocEngine($postProcEngineName);
		$stat               = Platform::getInstance()->get_statistics($id);

		// Does the engine support local d/l and we need to d/l the file locally?
		if ($postProcEngine->supportsDownloadToFile() && !$stat['filesexist'])
		{
			$actions['downloadToFile'] = true;
		}

		// Does the engine support remote deletes?
		if ($postProcEngine->supportsDelete())
		{
			$actions['delete'] = true;
		}

		// Does the engine support downloads to browser?
		if ($postProcEngine->supportsDownloadToBrowser())
		{
			$actions['downloadToBrowser'] = max(1, $stat['multipart']);
		}

		return $actions;
	}

	/**
	 * Downloads a remote file back to the site's server
	 *
	 * @param   int  $id    The backup record ID to fetch back to server
	 * @param   int  $part  Which part file of the backup record should I fetch back?
	 * @param   int  $frag  Which fragment of the backup record should I fetch back?
	 *
	 * @return  bool  true when we're done downloading, false if we have more work to do
	 * @throws  Exception On error
	 */
	public function downloadToServer(int $id, int $part = -1, int $frag = -1): bool
	{
		// Gather the necessary information to perform the download
		$backupRecord        = Platform::getInstance()->get_statistics($id);
		$remoteFilenameParts = explode('://', $backupRecord['remote_filename']);
		$engine              = Factory::getPostprocEngine($remoteFilenameParts[0]);
		$remoteFilepath      = $remoteFilenameParts[1];
		// Note that single part archives have $backupRecord['multipart'] == 0. We need that to be 1.
		$totalNumberOfParts = max($backupRecord['multipart'], 1);

		// Timer initialization
		$config = Factory::getConfiguration();
		$timer  = Factory::getTimer();
		$start  = $timer->getRunningTime();

		$app = JoomlaFactory::getApplication();

		// If we are starting a new download we need to reset the statistics in the session
		if ($part == -1)
		{
			// Total size of the files to download
			$app->getSession()->set('akeebabackup.dl_totalsize', $backupRecord['total_size']);
			// Cumulative bytes downloaded so far
			$app->getSession()->set('akeebabackup.dl_donesize', 0);
			// Convert part -1 to 0, indicating it's the very first part
			$part = 0;
			// Indicate this is going to be the very first fragment of the file to download
			$frag = -1;
		}

		while (true)
		{
			/**
			 * If we are trying to download a part that's higher than the number of parts in the archive we're all done.
			 *
			 * Remember: $part is the 0-based index (first part is zero). $totalNumberOfParts is the 1-based count of
			 * items in the collection.
			 */
			if ($part >= $totalNumberOfParts)
			{
				// Fall through to the return code which also updates the backup record
				break;
			}

			// Get the remote and local filenames
			$basename       = basename($remoteFilepath);
			$extension      = strtolower(str_replace(".", "", strrchr($basename, ".")));
			$partExtension  = ($part == 0) ? $extension : substr($extension, 0, 1) . sprintf('%02u', $part);
			$remoteFilepath = substr($remoteFilenameParts[1], 0, -strlen($extension)) . $partExtension;
			$localFilepath  = $config->get('akeeba.basic.output_directory') . '/' . basename($remoteFilepath);

			/**
			 * If $frag == -1 I am starting to download a new backup archive part. Therefore I need to initialize the
			 * local file.
			 */
			if ($frag == -1)
			{
				Platform::getInstance()->unlink($localFilepath);

				$fp = @fopen($localFilepath, 'w');

				if ($fp === false)
				{
					throw new RuntimeException(Text::sprintf('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTOPENFILE', $localFilepath), 500);
				}

				@fclose($fp);

				// Set the frag to 0 to let the download proceed correctly.
				$frag = 0;
			}

			// Calculate the offset to start downloading from and try to download the next fragment
			$from           = $frag * self::DOWNLOAD_FRAGMENT_SIZE;
			$tempFilepath   = $localFilepath . '.tmp';
			$allowMultipart = true;

			try
			{
				// Try to do a multipart download. If it's not supported, do a single part download.
				try
				{
					$engine->downloadToFile($remoteFilepath, $tempFilepath, $from, self::DOWNLOAD_FRAGMENT_SIZE);
				}
				catch (RangeDownloadNotSupported $e)
				{
					$allowMultipart = false;

					$engine->downloadToFile($remoteFilepath, $tempFilepath);
				}
			}
			catch (Exception $e)
			{
				// Failed download
				if (
					(($part < $backupRecord['multipart']) || (($backupRecord['multipart'] == 0) && ($part == 0))) &&
					($frag == 0)
				)
				{
					// Failure to download the part's beginning = failure to download. Period.
					throw new RuntimeException(Text::_('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTDOWNLOAD'), 500, $e);
				}

				// We tried reading past the end of a part file. Go to the next part.
				$part++;
				$frag = -1;
				continue;
			}

			// Add the currently downloaded fragment's size to the running total size of downloaded files
			$downloadedFragmentSize = (int) @filesize($tempFilepath);
			$currentTotal           = $app->getSession()->get('akeebabackup.dl_donesize', 0);
			$app->getSession()->set('akeebabackup.dl_donesize', $currentTotal + $downloadedFragmentSize);

			if (!$allowMultipart)
			{
				// Single part download: move the temporary file to the local file
				$this->moveTempFile($tempFilepath, $localFilepath);

				// Go to the start of the next part
				$part++;
				$frag = -1;

				break;
			}

			// Multipart download: try to combine the just downloaded fragment (in a temp file) with the local file
			$this->combineTemporaryAndLocalFile($tempFilepath, $localFilepath);

			// Indicate we need to download the next fragment
			$frag++;

			// Do I have enough time to try another fragment?
			$end          = $timer->getRunningTime();
			$requiredTime = max(1.1 * ($end - $start), !isset($requiredTime) ? 1.0 : $requiredTime);

			if ($timer->getTimeLeft() < $requiredTime)
			{
				break;
			}

			$start = $end;
		}

		// We set these variables in the model state to allow the View to access them
		$this->setState('id', $id);
		$this->setState('part', $part);
		$this->setState('frag', $frag);

		/**
		 * If we are trying to download a part that's higher than the number of parts in the archive we're all done.
		 *
		 * Remember: $part is the 0-based index (first part is zero). $totalNumberOfParts is the 1-based count of
		 * items in the collection.
		 */
		if ($part >= $totalNumberOfParts)
		{
			// Update the backup record, indicating the files now exist locally
			$backupRecord['filesexist'] = 1;

			Platform::getInstance()->set_or_update_statistics($id, $backupRecord);

			// Tell the called that we're all done with the downloads.
			return true;
		}

		// Tell the caller more steps are required to download the files
		return false;
	}

	/**
	 * Delete the files stored in the remote storage service
	 *
	 * @param   int  $id    The backup record we're deleting remote stored files for
	 * @param   int  $part  The backup part whose remotely stored file we're deleting
	 *
	 * @return  array  Information about the progress
	 * @throws  Exception On error
	 */
	public function deleteRemoteFiles(int $id, int $part = -1): array
	{
		$ret = [
			'finished' => false,
			'id'       => $id,
			'part'     => $part,
		];

		// Gather the necessary information to perform the delete
		$stat = Platform::getInstance()->get_statistics($id);

		if ($stat['frozen'])
		{
			throw new FrozenRecordError(Text::_('COM_AKEEBABACKUP_BUADMIN_FROZENRECORD_ERROR'));
		}

		$remoteFilenameParts = explode('://', $stat['remote_filename']);
		$engine              = Factory::getPostprocEngine($remoteFilenameParts[0]);
		$remote_filename     = $remoteFilenameParts[1];

		// Start timing ourselves
		$timer = Factory::getTimer(); // The core timer object
		$start = $timer->getRunningTime(); // Mark the start of this download
		$break = false; // Don't break the step

		while ($timer->getTimeLeft() && !$break && ($part < $stat['multipart']))
		{
			// Get the remote filename
			$basename  = basename($remote_filename);
			$extension = strtolower(str_replace(".", "", strrchr($basename, ".")));

			$new_extension = $extension;

			if ($part > 0)
			{
				$new_extension = substr($extension, 0, 1) . sprintf('%02u', $part);
			}

			$remote_filename = substr($remote_filename, 0, -strlen($extension)) . $new_extension;

			// Do we have to initialize the process?
			if ($part == -1)
			{
				// Init
				$part = 0;
			}

			// Try to delete the part
			$required_time = 1.0;

			try
			{
				$engine->delete($remote_filename);
			}
			catch (Exception $e)
			{
				throw new RuntimeException(Text::_('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTDELETE'), 500, $e);
			}

			// Successful delete
			$end = $timer->getRunningTime();
			$part++;

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

			if ($timer->getTimeLeft() < $required_time)
			{
				$break = true;
			}

			$start = $end;
		}

		if ($part >= $stat['multipart'])
		{
			// Just finished!
			$stat['remote_filename'] = '';

			Platform::getInstance()->set_or_update_statistics($id, $stat);
			$ret['finished'] = true;

			return $ret;
		}

		// More work to do...
		$ret['id']   = $id;
		$ret['part'] = $part;

		return $ret;
	}

	/**
	 * Appends the contents of the temporary file to the local file
	 *
	 * @param   string  $tempFilepath   Temporary file to read from
	 * @param   string  $localFilepath  Local file to append to
	 * @param   int     $chunkLength    Perform the appent up to this many bytes at a time
	 *
	 * @return  void
	 *
	 * @throws  RuntimeException If something has gone wrong
	 */
	private function combineTemporaryAndLocalFile(string $tempFilepath,
	                                              string $localFilepath,
	                                              int $chunkLength = 262144)
	{
		try
		{
			$localFilePointer = @fopen($localFilepath, 'a');

			if ($localFilePointer === false)
			{
				throw new RuntimeException(Text::sprintf('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTOPENFILE', $localFilepath), 500);
			}

			$tempFilePointer = fopen($tempFilepath, 'r');

			// Um, weird, I can't open the temp file.
			if ($tempFilePointer === false)
			{
				throw new RuntimeException(sprintf('Can not read data from temporary file %s', $tempFilepath));
			}

			while (!feof($tempFilePointer))
			{
				$data = fread($tempFilePointer, $chunkLength);

				if ($data === false)
				{
					throw new RuntimeException(sprintf('Can not read data from temporary file %s', $tempFilepath));
				}

				$dataLength = $this->akstrlen($data);
				$written    = fwrite($localFilePointer, $data);

				if ($written != $dataLength)
				{
					throw new RuntimeException(Text::sprintf('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTOPENFILE', $localFilepath), 500);
				}
			}
		}
		finally
		{
			if (isset($tempFilePointer) && is_resource($tempFilePointer))
			{
				fclose($tempFilePointer);
			}

			if (isset($localFilePointer) && is_resource($localFilePointer))
			{
				fclose($localFilePointer);
			}

			Platform::getInstance()->unlink($tempFilepath);
		}
	}

	private function akstrlen(string $string): int
	{
		return function_exists('mb_strlen') ? mb_strlen($string, '8bit') : strlen($string);
	}

	/**
	 * Move a temporary file into a local file path
	 *
	 * @param   string  $tempFilepath   The temporary file to move from
	 * @param   string  $localFilepath  The local file to move into
	 *
	 * @return  void
	 * @throws  RuntimeException If the move fails
	 */
	private function moveTempFile(string $tempFilepath, string $localFilepath)
	{
		try
		{
			// Try to unlink the existing local file (it should exist, we already tried creating it as a zero byte file)
			Platform::getInstance()->unlink($localFilepath);

			// Move the temporary file to the local file
			if (!Platform::getInstance()->move($tempFilepath, $localFilepath))
			{
				throw new RuntimeException(Text::sprintf('COM_AKEEBABACKUP_REMOTEFILES_ERR_CANTOPENFILE', $localFilepath));
			}

		}
		finally
		{
			// Delete the temporary file
			Platform::getInstance()->unlink($tempFilepath);
		}
	}

	/**
	 * Returns the post-processing engine name for the given backup record ID.
	 *
	 * @param   int   $id                      The backup record ID
	 * @param   bool  $profileOverridesRecord  Return the engine name from the backup profile, not the backup record
	 *
	 * @return  string
	 * @throws  Exception
	 */
	private function getPostProcEngineNameForRecord(int $id, bool $profileOverridesRecord = false): string
	{
		// Load the stats record
		$stat = Platform::getInstance()->get_statistics($id);

		if (empty($stat))
		{
			return 'none';
		}

		if ($profileOverridesRecord)
		{
			/** @var ProfilesModel $profilesModel */
			$profilesModel      = $this->getMVCFactory()->createModel('Profiles', 'Administrator');
			$postProcPerProfile = $profilesModel->getPostProcessingEnginePerProfile();
			$profileId          = $stat['profile_id'];

			if (!array_key_exists($profileId, $postProcPerProfile))
			{
				return 'none';
			}

			return $postProcPerProfile[$profileId];
		}

		// Get the post-proc engine from the remote location
		$remote_filename = $stat['remote_filename'];

		if (empty($remote_filename))
		{
			return 'none';
		}

		$remoteFilenameParts = explode('://', $remote_filename, 2);

		return $remoteFilenameParts[0];
	}

}