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

namespace Solo\Model;

use Akeeba\Engine\Platform;
use Awf\Container\Container;
use Awf\Download\Download;
use Awf\Mvc\Model;
use Awf\Registry\Registry;
use Awf\Session\Exception;
use Awf\Text\Text;
use Awf\Uri\Uri;

class Update extends Model
{
	/** @var   string  The URL containing the INI update stream URL */
	protected $updateStreamURL = '';

	/** @var   Registry  A registry object holding the update information */
	protected $updateInfo = null;

	/** @var   string  The table where key-value information is stored */
	protected $tableName = '#__ak_storage';

	/** @var   string  The table field which stored the key of the key-value pairs */
	protected $keyField = 'tag';

	/** @var   string  The table field which stored the value of the key-value pairs */
	protected $valueField = 'data';

	/** @var   string  The key tag for the live update serialised information */
	protected $updateInfoTag = 'liveupdate';

	/** @var   string  The key tag for the last check timestamp */
	protected $lastCheckTag = 'liveupdate_lastcheck';

	/** @var   integer  The last update check UNIX timestamp */
	protected $lastCheck = null;

	/** @var   string   Currently installed version */
	protected $currentVersion = '';

	/** @var   string   Currently installed version's date stamp */
	protected $currentDateStamp = '';

	/** @var   string   Minimum stability for reporting updates */
	protected $minStability = 'alpha';

	protected $downloadId = '';

	/**
	 * How to determine if a new version is available.
	 *
	 * - `different` The version numbers are different
	 * - `vcompare` Latest version, based on `version_compare` checks between the two version numbers
	 * - `newest` Only checks the release dates; newer version wins, regardless of the version number
	 * - `smart` Latest version, or newest release date.
	 *
	 * @var   string
	 */
	protected $versionStrategy = 'smart';

	/**
	 * Update constructor.
	 *
	 * @param   Container  $container  The application container
	 */
	public function __construct(\Awf\Container\Container $container = null)
	{
		parent::__construct($container);

		$this->currentVersion   = defined('AKEEBABACKUP_VERSION') ? AKEEBABACKUP_VERSION : 'dev';
		$this->currentDateStamp = defined('AKEEBABACKUP_DATE') ? AKEEBABACKUP_DATE : gmdate('Y-m-d');
		$this->minStability     = $this->container->appConfig->get('options.minstability', 'stable');
		$this->downloadId       = $this->container->appConfig->get('options.update_dlid', '');

		// Set the update stream URL
		$pro = AKEEBABACKUP_PRO ? 'pro' : 'core';

		// The `updateStreamURL` comes from the integration.php file
		$this->updateStreamURL =
			(isset($container['updateStreamURL']) && !empty($container['updateStreamURL']))
				? $container->updateStreamURL
				: 'http://cdn.akeeba.com/updates/solo' . $pro . '.json';

		// Testing updates in development versions: define AKEEBABACKUP_UPDATE_BASEURL in version.php
		if (defined('AKEEBABACKUP_UPDATE_BASEURL'))
		{
			$pro = AKEEBABACKUP_PRO ? 'pro' : 'core';

			$this->updateStreamURL = AKEEBABACKUP_UPDATE_BASEURL . $pro . '.json';
		}

		$this->load(false);
	}

	/**
	 * Load the update information into the $this->updateInfo object.
	 *
	 * The update source is `$this->updateStreamURL`, unless the file `APATH_BASE  . 'update.json'` is present.
	 *
	 * Update information is cached. If the information is found in the cache, it will be returned from the cache unless
	 * either of the following conditions is true:
	 * - The cache is expired.
	 * - The `$force` flag is set.
	 * - The file `APATH_BASE  . 'update.json'` is present.
	 *
	 * @param   bool  $force  True to force reload the information from the source.
	 *
	 * @return  void
	 */
	public function load($force = false)
	{
		// Clear the update information and last update check timestamp
		$this->lastCheck  = null;
		$this->updateInfo = null;

		// Get a reference to the database
		$db = $this->container->db;

		/**
		 * Override for automated testing
		 *
		 * If the file update.ini exists (next to version.php) force reloading the update information.
		 */
		$fileTestingUpdates = APATH_BASE . '/update.json';

		if (file_exists($fileTestingUpdates))
		{
			$force           = true;
			$this->lastCheck = 0;
		}
		elseif (!$force)
		{
			// Get the last update timestamp
			$query           = $db->getQuery(true)
				->select($db->qn($this->valueField))
				->from($db->qn($this->tableName))
				->where($db->qn($this->keyField) . '=' . $db->q($this->lastCheckTag));
			$this->lastCheck = $db->setQuery($query)->loadResult();

			if (is_null($this->lastCheck))
			{
				$this->lastCheck = 0;
			}

			// Force-reload the update information if it's older than 6 hours
			$force = abs(time() - $this->lastCheck) >= 21600;
		}

		// Try to load from cache
		if (!$force)
		{
			$query   = $db->getQuery(true)
				->select($db->qn($this->valueField))
				->from($db->qn($this->tableName))
				->where($db->qn($this->keyField) . '=' . $db->q($this->updateInfoTag));
			$rawInfo = $db->setQuery($query)->loadResult();

			if (empty($rawInfo))
			{
				$force = true;
			}
			else
			{
				$this->updateInfo = new Registry();
				$this->updateInfo->loadString($rawInfo, 'JSON');
			}
		}

		// If it's stuck, and we are not forcibly retrying to reload, bail out
		if (!$force && !empty($this->updateInfo) && $this->updateInfo->get('stuck', false))
		{
			return;
		}

		// Maybe we are forced to load from a URL?
		// NOTE: DO NOT MERGE WITH PREVIOUS IF AS THE $force VARIABLE MAY BE MODIFIED THERE!
		if ($force)
		{
			$this->updateInfo = new Registry();
			$this->updateInfo->set('stuck', 1);
			$this->lastCheck = time();

			// Store last update check timestamp
			$this->replaceCommonStorageObject(
				(object) [
					$this->keyField   => $this->lastCheckTag,
					$this->valueField => $this->lastCheck,
				]
			);

			// Store update information
			$this->replaceCommonStorageObject(
				(object) [
					$this->keyField   => $this->updateInfoTag,
					$this->valueField => $this->updateInfo->toString('JSON'),
				]
			);

			// Simulate a PHP crash for automated testing
			if (defined('AKEEBA_TESTS_SIMULATE_STUCK_UPDATE') && AKEEBA_TESTS_SIMULATE_STUCK_UPDATE)
			{
				die(
				sprintf(
					'<p id="automated-testing-simulated-crash">This is a simulated crash for automated testing.</p></p>If you are seeing this outside of an automated testing scenario, please delete the line <code>define(\'AKEEBA_TESTS_SIMULATE_STUCK_UPDATE\', 1);</code> from the %s\version.php file</p>',
					APATH_BASE
				)
				);
			}

			// Try to fetch the update information
			$updateInformation = $this->fetchUpdates();

			if ($updateInformation === null)
			{
				// We are stuck. Darn it!
				return;
			}

			if (is_object($updateInformation))
			{
				$this->updateInfo->loadObject($updateInformation);
			}

			$this->updateInfo->set('loadedUpdate', ($updateInformation !== false) ? 1 : 0);
			$this->updateInfo->set('stuck', 0);

			// Determine the version stability if it was not provided
			$version   = $this->updateInfo->get('version', '');
			$stability = $this->updateInfo->get('maturity', '');
			if (
				!$this->updateInfo->get('stuck', 0)
				&& $this->updateInfo->get('loadedUpdate', 0)
				&& !empty($version)
				&& empty($stability)
			)
			{
				$this->updateInfo->set('maturity', $this->getStability($version));
			}

			// Since we had to load from a URL, commit the update information to db
			$this->replaceCommonStorageObject(
				(object) [
					$this->keyField   => $this->updateInfoTag,
					$this->valueField => $this->updateInfo->toString('JSON'),
				]
			);
		}

		// Check if an update is available and push it to the update information registry
		$this->updateInfo->set('hasUpdate', $this->hasUpdate());

		// Post-process the download URL, appending the Download ID (if defined)
		$link = $this->updateInfo->get('download', '');

		if (!empty($link) && !empty($this->downloadId))
		{
			$link = new Uri($link);
			$link->setVar('dlid', $this->downloadId);
			$this->updateInfo->set('download', $link->toString());
		}
	}

	/**
	 * Is there an update available?
	 *
	 * @return  bool
	 */
	public function hasUpdate()
	{
		if (!$this->updateInfo->get('loadedUpdate', 0))
		{
			return false;
		}

		$this->updateInfo->set('minstabilityMatch', 1);
		$this->updateInfo->set('platformMatch', 0);

		// Get the update information as an object
		$tempObject = $this->updateInfo->toObject();

		// Validate the minimum stability
		$this->updateInfo->set('minstabilityMatch', $this->filterStability($tempObject) ? 1 : 0);

		// Validate the platform compatibility
		$this->updateInfo->set(
			'platformMatch',
			$this->filterPhpVersion($tempObject) && $this->filterPlatformVersion($tempObject)
				? 1 : 0
		);

		// A Core version with a Download ID entered will always show an update available
		if (!AKEEBABACKUP_PRO && !empty($this->downloadId))
		{
			return true;
		}

		// Apply the version strategy filter
		return $this->filterVersion($tempObject);
	}

	/**
	 * Returns the update information
	 *
	 * @param   bool  $force  Should we force the fetch of new information?
	 *
	 * @return \Awf\Registry\Registry
	 */
	public function getUpdateInformation(bool $force = false): ?Registry
	{
		if (is_null($this->updateInfo))
		{
			$this->load($force);
		}

		return $this->updateInfo;
	}

	/**
	 * Try to prepare a world-writeable update.zip file in the temporary directory, or throw an exception if it's not
	 * possible.
	 *
	 * @return  void
	 *
	 * @throws  \Exception
	 */
	public function prepareDownload()
	{
		$tmpDir  = defined('AKEEBA_TESTS_UPDATE_TEMP_FOLDER') ? AKEEBA_TESTS_UPDATE_TEMP_FOLDER
			: $this->container['temporaryPath'];
		$tmpFile = $tmpDir . '/update.zip';

		$fs = $this->container->fileSystem;

		if (!is_dir($tmpDir))
		{
			throw new \Exception(Text::sprintf('SOLO_UPDATE_ERR_DOWNLOAD_INVALIDTMPDIR', $tmpDir), 500);
		}

		$fs->delete($tmpFile);

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

		if ($fp === false)
		{
			$nada = '';
			$fs->write($tmpFile, $nada);
		}
		else
		{
			@fclose($fp);
		}

		$fs->chmod($tmpFile, 0777);
	}

	/**
	 * Step through the download of the update archive.
	 *
	 * If the file APATH_BASE  . 'update.zip' file is present it is used instead (and removed immediately).
	 *
	 * @param   bool  $staggered  Should I try a staggered (multi-step) download? Default is true.
	 *
	 * @return  array  A return array giving the status of the staggered download
	 */

	public function stepDownload($staggered = true)
	{
		$this->load();

		// The restore script expects to find the update inside the temp directory
		$tmpDir        = defined('AKEEBA_TESTS_UPDATE_TEMP_FOLDER') ? AKEEBA_TESTS_UPDATE_TEMP_FOLDER
			: $this->container['temporaryPath'];
		$tmpDir        = rtrim($tmpDir, '/\\');
		$localFilename = $tmpDir . '/update.zip';

		/**
		 * Override for automated testing
		 *
		 * If the file APATH_BASE  . 'update.zip' file is present it is used instead (and removed immediately).
		 */
		$fileOverride = APATH_BASE . 'update.zip';

		if (is_file($fileOverride))
		{
			$size = filesize($localFilename);
			$frag = $this->getState('frag', 0);
			$frag++;

			$ret = [
				"status"    => true,
				"error"     => '',
				"frag"      => $frag,
				"totalSize" => $size,
				"doneSize"  => $size,
				"percent"   => 100,
				"errorCode" => 0,
			];

			// Fake stepped download: frag 1 causes 1 second delay, frag 2 moves the file
			switch ($frag)
			{
				case 0:
					sleep(1);
					$ret['doneSize'] = (int) ($size / 2);
					$ret['percent']  = 50;
					$this->setState('frag', $frag);

					break;

				default:
					$this->setState('frag', null);
					$this->container->fileSystem->move($fileOverride, $localFilename);

					break;
			}

			// Special case for automated tests: if the file is 0 bytes we will just throw an error :)
			if ($size == 0)
			{
				$retArray['status']    = false;
				$retArray['error']     = Text::sprintf(
					'AWF_DOWNLOAD_ERR_LIB_COULDNOTDOWNLOADFROMURL', '@test_override_file@'
				);
				$retArray['errorCode'] = 500;
				$this->container->fileSystem->delete($fileOverride);
			}

			return $ret;
		}

		/**
		 * Back to our regular code. Set up the file import parameters.
		 */
		$params = [
			'file'          => $this->updateInfo->get('download', ''),
			'frag'          => $this->getState('frag', -1),
			'totalSize'     => $this->getState('totalSize', -1),
			'doneSize'      => $this->getState('doneSize', -1),
			'localFilename' => $localFilename,
		];

		$download = new Download($this->container);

		if ($staggered)
		{
			// importFromURL expects the remote URL in the 'url' index
			$params['url'] = $params['file'];
			$retArray      = $download->importFromURL($params);

			// Better it
			unset($params['url']);
		}
		else
		{
			$retArray = [
				"status"    => true,
				"error"     => '',
				"frag"      => 1,
				"totalSize" => 0,
				"doneSize"  => 0,
				"percent"   => 0,
				"errorCode" => 0,
			];

			try
			{
				$result = $download->getFromURL($params['file']);

				if ($result === false)
				{
					throw new Exception(
						Text::sprintf('AWF_DOWNLOAD_ERR_LIB_COULDNOTDOWNLOADFROMURL', $params['file']), 500
					);
				}

				$tmpDir        = APATH_ROOT . '/tmp';
				$tmpDir        = rtrim($tmpDir, '/\\');
				$localFilename = $tmpDir . '/update.zip';

				$fs = $this->container->fileSystem;

				$fs->write($localFilename, $result);

				$retArray['status']    = true;
				$retArray['totalSize'] = strlen($result);
				$retArray['doneSize']  = $retArray['totalSize'];
				$retArray['percent']   = 100;
			}
			catch (\Exception $e)
			{
				$retArray['status']    = false;
				$retArray['error']     = $e->getMessage();
				$retArray['errorCode'] = $e->getCode();
			}
		}

		return $retArray;
	}

	/**
	 * Creates the restoration.ini file which is used during the update package's extraction. This file tells Akeeba
	 * Restore which package to read and where and how to extract it.
	 *
	 * @return  bool  True on success
	 */
	public function createRestorationINI()
	{
		// Get a password
		$password = base64_encode(random_bytes(32));

		$fs = $this->container->fileSystem;

		$this->setState('update_password', $password);

		// Also save the update_password in the session, we'll need it if this page is reloaded
		$this->container->segment->set('update_password', $password);

		// Get the absolute path to site's root
		$siteRoot = (isset($this->container['filesystemBase'])) ? $this->container['filesystemBase'] : APATH_BASE;
		$siteRoot = str_replace('\\', '/', $siteRoot);
		$siteRoot = str_replace('//', '/', $siteRoot);

		// On WordPress we need to go one level up
		if (defined('WPINC'))
		{
			$parts = explode('/', $siteRoot);
			array_pop($parts);
			$siteRoot = implode('/', $parts);
		}

		$tempdir = $this->container['temporaryPath'];

		$data = "<?php\ndefined('_AKEEBA_RESTORATION') or die();\n";
		$data .= '$restoration_setup = array(' . "\n";

		$ftpOptions = $this->getFTPOptions();
		$engine     = $ftpOptions['enable'] ? 'hybrid' : 'direct';
		$dryRun     = defined('AKEEBABACKUP_UPDATE_DRYRUN') ? '1' : '0';
		$destDir    = defined('AKEEBABACKUP_UPDATE_DRYRUN') ? $tempdir : $siteRoot;

		$data .= <<<ENDDATA
	'kickstart.security.password' => '$password',
	'kickstart.tuning.max_exec_time' => '5',
	'kickstart.tuning.run_time_bias' => '75',
	'kickstart.tuning.min_exec_time' => '0',
	'kickstart.procengine' => '$engine',
	'kickstart.setup.sourcefile' => '{$tempdir}/update.zip',
	'kickstart.setup.destdir' => '$destDir',
	'kickstart.setup.restoreperms' => '0',
	'kickstart.setup.filetype' => 'zip',
	'kickstart.setup.dryrun' => '$dryRun',
ENDDATA;

		// On WordPress we need to remove the akeebabackupwp prefix from the package
		if (defined('WPINC'))
		{
			$data .= "\n\t'kickstart.setup.removepath' => 'akeebabackupwp',\n";
		}

		if ($ftpOptions['enable'])
		{
			// Is the tempdir really writable?
			$writable = @is_writeable($tempdir);

			if ($writable)
			{
				// Let's be REALLY sure
				$fp = @fopen($tempdir . '/test.txt', 'w');
				if ($fp === false)
				{
					$writable = false;
				}
				else
				{
					fclose($fp);
					unlink($tempdir . '/test.txt');
				}
			}

			// If the tempdir is not writable, create a new writable subdirectory
			if (!$writable)
			{
				$newTemp = APATH_BASE . '/tmp/update_tmp';
				$fs->mkdir($newTemp, 0777);

				$tempdir = $newTemp;
			}

			// If we still have no writable directory, we'll try /tmp and the system's temp-directory
			$writable = @is_writeable($tempdir);

			if (!$writable && function_exists('sys_get_temp_dir'))
			{
				$tempdir = sys_get_temp_dir();
			}

			$data .= <<<ENDDATA
	'kickstart.ftp.ssl' => '0',
	'kickstart.ftp.passive' => '1',
	'kickstart.ftp.host' => '{$ftpOptions['host']}',
	'kickstart.ftp.port' => '{$ftpOptions['port']}',
	'kickstart.ftp.user' => '{$ftpOptions['user']}',
	'kickstart.ftp.pass' => '{$ftpOptions['pass']}',
	'kickstart.ftp.dir' => '{$ftpOptions['root']}',
	'kickstart.ftp.tempdir' => '$tempdir',
ENDDATA;
		}

		$data .= ');';


		$configPath = $siteRoot . '/restoration.php';

		if (defined('WPINC'))
		{
			$configPath = $siteRoot . '/app/restoration.php';
		}

		clearstatcache(true, $configPath);

		// Remove the old file, if it's there...
		if (file_exists($configPath))
		{
			$fs->delete($configPath);
		}

		// Write the new file
		$fs->write($configPath, $data);

		// Clear opcode caches for the generated .php file
		if (function_exists('opcache_invalidate'))
		{
			opcache_invalidate($configPath, true);
		}

		if (function_exists('apc_compile_file'))
		{
			apc_compile_file($configPath);
		}

		if (function_exists('wincache_refresh_if_changed'))
		{
			wincache_refresh_if_changed([$configPath]);
		}

		if (function_exists('xcache_asm'))
		{
			xcache_asm($configPath);
		}

		return true;
	}

	/**
	 * Returns an array with the configured FTP options
	 *
	 * @return  array
	 */
	public function getFTPOptions()
	{
		// Initialise from Joomla! Global Configuration
		$config = $this->container->appConfig;

		$retArray = [
			'enable'  => $config->get('fs.driver', 'file') == 'ftp',
			'host'    => $config->get('fs.host', 'localhost'),
			'port'    => $config->get('fs.port', '21'),
			'user'    => $config->get('fs.username', ''),
			'pass'    => $config->get('fs.password', ''),
			'root'    => $config->get('fs.directory', ''),
			'tempdir' => APATH_BASE . '/tmp',
		];

		return $retArray;
	}

	/**
	 * Finalises the update. Reserved for future use. DO NOT REMOVE.
	 */
	public function finalise()
	{
		// Clear the compiled templates
		$tmp = $this->container['temporaryPath'] . '/compiled_templates';
		$this->container->fileSystem->rmdir($tmp, true);
	}

	/**
	 * Get the currently used update stream URL
	 *
	 * @return string
	 */
	public function getUpdateStreamURL()
	{
		return $this->updateStreamURL;
	}

	/**
	 * Normalise the version number to a PHP-format version string.
	 *
	 * @param   string  $version  The whatever-format version number
	 *
	 * @return  string  A standard formatted version number
	 */
	public function sanitiseVersion($version)
	{
		$test                   = strtolower($version);
		$alphaQualifierPosition = strpos($test, 'alpha-');
		$betaQualifierPosition  = strpos($test, 'beta-');
		$betaQualifierPosition2 = strpos($test, '-beta');
		$rcQualifierPosition    = strpos($test, 'rc-');
		$rcQualifierPosition2   = strpos($test, '-rc');
		$rcQualifierPosition3   = strpos($test, 'rc');
		$devQualifiedPosition   = strpos($test, 'dev');

		if ($alphaQualifierPosition !== false)
		{
			$betaRevision = substr($test, $alphaQualifierPosition + 6);
			if (!$betaRevision)
			{
				$betaRevision = 1;
			}
			$test = substr($test, 0, $alphaQualifierPosition) . '.a' . $betaRevision;
		}
		elseif ($betaQualifierPosition !== false)
		{
			$betaRevision = substr($test, $betaQualifierPosition + 5);
			if (!$betaRevision)
			{
				$betaRevision = 1;
			}
			$test = substr($test, 0, $betaQualifierPosition) . '.b' . $betaRevision;
		}
		elseif ($betaQualifierPosition2 !== false)
		{
			$betaRevision = substr($test, $betaQualifierPosition2 + 5);

			if (!$betaRevision)
			{
				$betaRevision = 1;
			}

			$test = substr($test, 0, $betaQualifierPosition2) . '.b' . $betaRevision;
		}
		elseif ($rcQualifierPosition !== false)
		{
			$betaRevision = substr($test, $rcQualifierPosition + 5);
			if (!$betaRevision)
			{
				$betaRevision = 1;
			}
			$test = substr($test, 0, $rcQualifierPosition) . '.rc' . $betaRevision;
		}
		elseif ($rcQualifierPosition2 !== false)
		{
			$betaRevision = substr($test, $rcQualifierPosition2 + 3);

			if (!$betaRevision)
			{
				$betaRevision = 1;
			}

			$test = substr($test, 0, $rcQualifierPosition2) . '.rc' . $betaRevision;
		}
		elseif ($rcQualifierPosition3 !== false)
		{
			$betaRevision = substr($test, $rcQualifierPosition3 + 5);

			if (!$betaRevision)
			{
				$betaRevision = 1;
			}

			$test = substr($test, 0, $rcQualifierPosition3) . '.rc' . $betaRevision;
		}
		elseif ($devQualifiedPosition !== false)
		{
			$betaRevision = substr($test, $devQualifiedPosition + 6);
			if (!$betaRevision)
			{
				$betaRevision = '';
			}
			$test = substr($test, 0, $devQualifiedPosition) . '.dev' . $betaRevision;
		}

		return $test;
	}

	public function getStability($version)
	{
		$versionParts    = explode('.', $version);
		$lastVersionPart = array_pop($versionParts);

		if (substr($lastVersionPart, 0, 1) == 'a')
		{
			return 'alpha';
		}

		if (substr($lastVersionPart, 0, 1) == 'b')
		{
			return 'beta';
		}

		if (substr($lastVersionPart, 0, 2) == 'rc')
		{
			return 'rc';
		}

		if (substr($lastVersionPart, 0, 3) == 'dev')
		{
			return 'alpha';
		}

		return 'stable';
	}

	/**
	 * Checks if there is an update taking into account only the release date. If the release date is the same then it
	 * takes into account the version.
	 *
	 * @param   string  $version
	 * @param   string  $date
	 *
	 * @return  bool
	 */
	private function hasUpdateByNewest($version, $date)
	{
		if (empty($this->currentDateStamp))
		{
			$mine = $this->container->dateFactory('2000-01-01 00:00:00');
		}
		else
		{
			try
			{
				$mine = $this->container->dateFactory($this->currentDateStamp);
			}
			catch (\Exception $e)
			{
				$mine = $this->container->dateFactory('2000-01-01 00:00:00');
			}
		}

		$theirs = $this->container->dateFactory($date);

		/**
		 * Do we have the same time? This happens when we release two versions in the same day. In such cases we have to
		 * check vs the version number.
		 */
		if ($mine->toUnix() == $theirs->toUnix())
		{
			return $this->hasUpdateByVersion($version, $date);
		}

		return ($theirs->toUnix() > $mine->toUnix());
	}

	/**
	 * Checks if there is an update by comparing the version numbers using version_compare()
	 *
	 * @param   string  $version
	 * @param   string  $date
	 *
	 * @return  bool
	 */
	private function hasUpdateByVersion($version, $date)
	{
		$mine = $this->currentVersion;

		if (empty($mine))
		{
			$mine = '0.0.0';
		}

		if (empty($version))
		{
			$version = '0.0.0';
		}

		return version_compare($version, $mine, 'gt');
	}

	/**
	 * Checks if there is an update by looking for a different version number
	 *
	 * @param   string  $version
	 *
	 * @return  bool
	 */
	private function hasUpdateByDifferentVersion($version, $date)
	{
		$mine = $this->currentVersion;

		if (empty($mine))
		{
			$mine = '0.0.0';
		}

		if (empty($version))
		{
			$version = '0.0.0';
		}

		return version_compare($version, $mine, 'gt');
	}

	private function hasUpdateByDateAndVersion($version, $date)
	{
		$isCurrentDev = in_array(substr($this->currentVersion, 0, 3), ['dev', 'rev']);
		$isUpdateDev  = in_array(substr($version, 0, 3), ['dev', 'rev']);

		// Development (rev*) to numbered version; numbered to development; or development to development: use the date
		if ($isCurrentDev || $isUpdateDev)
		{
			return $this->hasUpdateByNewest($version, $date);
		}

		// Identical version number? Use the date
		if ($version == $this->currentVersion)
		{
			return $this->hasUpdateByNewest($version, $date);
		}

		// Otherwise only by version number
		return $this->hasUpdateByVersion($version, $date);
	}

	/**
	 * Store information in the `#__akeeba_common` table.
	 *
	 * @param   object  $o  The object to store
	 *
	 * @return  void
	 * @since   8.1.0
	 */
	private function replaceCommonStorageObject(object $o)
	{
		$db = $this->container->db;

		$query = $db->getQuery(true)
			->delete($db->quoteName($this->tableName))
			->where($db->quoteName($this->keyField) . ' = ' . $db->quote($o->{$this->keyField}));
		$db->setQuery($query)->execute();

		$db->insertObject($this->tableName, $o, $this->keyField);
	}


	/**
	 * Fetch the updates from the external source.
	 *
	 * @return  object|false|null  NULL stuck updates. FALSE could not retrieve updates. Otherwise, best match update
	 *                             object
	 * @since   8.1.0
	 */
	private function fetchUpdates()
	{
		// First, check for the existence of the file used in automated testing
		$fileTestingUpdates = APATH_BASE . '/update.json';

		if (is_file($fileTestingUpdates))
		{
			$rawInfo = @file_get_contents($fileTestingUpdates);
			$this->container->fileSystem->delete($fileTestingUpdates);

			try
			{
				return json_decode($rawInfo, false, 512, JSON_THROW_ON_ERROR) ?: new \stdClass();
			}
			catch (\JsonException $e)
			{
				return new \stdClass();
			}
		}

		$options     = [];
		$proxyParams = Platform::getInstance()->getProxySettings();

		if ($proxyParams['enabled'])
		{
			$options['proxy'] = [
				'host' => $proxyParams['host'],
				'port' => $proxyParams['port'],
				'user' => $proxyParams['user'],
				'pass' => $proxyParams['pass'],
			];
		}

		$download = new Download($this->container);
		$download->setAdapterOptions($options);

		$rawInfo = $download->getFromURL($this->updateStreamURL);

		if ($rawInfo === false)
		{
			return $rawInfo;
		}

		try
		{
			$versionsList = json_decode($rawInfo, false, 512, JSON_THROW_ON_ERROR);
		}
		catch (\JsonException $e)
		{
			return null;
		}

		// Let's get the latest update (highest available version, whatever it may be)
		$latestVersion = array_reduce(
			$versionsList,
			fn(?object $carry, object $item) => ($carry === null)
				? $item
				: (version_compare($item->version, $carry->version, 'gt') ? $item : $carry),
			null
		);

		// Filter by PHP
		$versionsList = array_filter($versionsList, [$this, 'filterPhpVersion']);

		// Filter by platform (only on WordPress and ClassicPress)
		$versionsList = array_filter($versionsList, [$this, 'filterPlatformVersion']);

		// Filter by version (must be newer or equal to myself)
		$versionsList = array_filter($versionsList, [$this, 'filterVersion']);

		// Filter by stability (must be at least as stable as the minimum requested stability)
		$versionsList = array_filter($versionsList, [$this, 'filterStability']);

		// No updates left? Keep the latest update, whatever it is.
		if (empty($versionsList))
		{
			return $latestVersion;
		}

		// Otherwise, return the newest version
		return array_reduce(
			$versionsList,
			fn(?object $carry, object $item) => ($carry === null)
				? $item
				: (version_compare($item->version, $carry->version, 'gt') ? $item : $carry),
			null
		);
	}

	private function filterPlatformVersion(object $item): bool
	{
		if (!defined('ABSPATH'))
		{
			return true;
		}

		$platform       = function_exists('classicpress_version') ? 'classicpress' : 'wordpress';
		$currentVersion = $this->container->segment->get('platformVersionForUpdates', '0.0');

		if (!isset($item->platforms) || empty($item->platforms)
		    || !isset($item->platforms->{$platform})
		    || empty($item->platforms->{$platform})
		    || !is_array($item->platforms->{$platform})
		)
		{
			return false;
		}

		foreach ($item->platforms->{$platform} as $version)
		{
			if (substr($version, -1) === '+')
			{
				if (version_compare($currentVersion, substr($version, 0, -1), 'ge'))
				{
					return true;
				}

				continue;
			}

			if (strpos($currentVersion, $version . '.') === 0)
			{
				return true;
			}
		}

		return false;
	}

	private function filterStability(object $item): bool
	{
		switch ($this->minStability)
		{
			case 'alpha':
				return in_array($item->maturity, ['stable', 'rc', 'beta', 'alpha']);

			case 'beta':
				return in_array($item->maturity, ['stable', 'rc', 'beta']);

			case 'rc':
				return in_array($item->maturity, ['stable', 'rc']);

			default:
				return $item->maturity === 'stable';
		}
	}

	private function filterPhpVersion(object $item): bool
	{
		/**
		 * The minimum PHP version check takes place against the current PHP version, or the minimum currently supported.
		 *
		 * This is a deliberate choice when running under WordPress. Some sites have CLI scripts running under obsolete
		 * versions of PHP which are too old for our software. Since updates are retrieved under these versions, the
		 * update detected is incompatible with that version, therefore it's cached as incompatible. When the user views
		 * the update information on the web, under a modern PHP version, they don't understand why their latest and
		 * greatest PHP version is not supported and reported as too old.tia
		 */
		$testAgainst = PHP_VERSION;

		if (class_exists(\AkeebaBackupWP::class))
		{
			$testAgainst = version_compare(\AkeebaBackupWP::$minimumPHP, PHP_VERSION, 'gt')
				? \AkeebaBackupWP::$minimumPHP
				: PHP_VERSION;
		}

		$parts        = explode('.', $testAgainst);
		$majorVersion = $parts[0] ?? 0;
		$minorVersion = $parts[1] ?? '0';

		if ($majorVersion === 0 || intval($majorVersion) != $majorVersion)
		{
			$majorVersion = 8;
			$minorVersion = 0;
		}

		return in_array($majorVersion . '.' . $minorVersion, $item->php ?: []);
	}

	private function filterVersion(object $item): bool
	{
		// Apply the version strategy
		$version = $item->version ?? null;
		$date    = $item->date ?? null;

		switch ($this->versionStrategy)
		{
			case 'newest':
				return !empty($date) && $this->hasUpdateByNewest($version, $date);

			case 'vcompare':
				return !empty($version) && $this->hasUpdateByVersion($version, $date);

			case 'different':
				return !empty($version) && $this->hasUpdateByDifferentVersion($version, $date);

			case 'smart':
			default:
				return !empty($date) && !empty($version) && $this->hasUpdateByDateAndVersion($version, $date);
		}
	}
}
