<?php
/**
 * @package        solo
 * @copyright Copyright (c)2014-2018 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license        GNU GPL version 3 or later
 */

namespace Solo\Model;

use Awf\Container\Container;
use Awf\Date\Date;
use Awf\Download\Download;
use Awf\Mvc\Model;
use Awf\Registry\Registry;
use Awf\Session\Exception;
use Awf\Session\Randval;
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-valueinformation is stored */
	protected $tableName = '';

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

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

	/** @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' = if the version number is different,
	 * the remote version is newer, 'vcompare' = use version compare between the two versions, 'newest' =
	 * compare the release dates to find the newest. I suggest using 'different' on most cases.
	 *
	 * @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
		if (isset($container['updateStreamURL']))
		{
			$this->updateStreamURL = $container->updateStreamURL;
		}
		else
		{
			$pro = AKEEBABACKUP_PRO ? 'pro' : 'core';

			$this->updateStreamURL = 'http://cdn.akeebabackup.com/updates/solo' . $pro . '.ini';
		}

		// 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 . '.ini';
		}

		$this->tableName       = '#__ak_storage';
		$this->keyField        = 'tag';
		$this->valueField      = 'data';
		$this->versionStrategy = 'smart';

		$this->load(false);
	}

	/**
	 * Load the update information into the $this->updateInfo object. The update information will be returned from the
	 * cache. If the cache is expired, the $force flag is set or the APATH_BASE  . 'update.ini' file is present the
	 * update information will be reloaded from the source. The update source normally is $this->updateStreamURL. If
	 * the APATH_BASE  . 'update.ini' file is present it's used as the update source instead.
	 *
	 * In short, the APATH_BASE  . 'update.ini' file allows you to override update sources for testing purposes.
	 *
	 * @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;

		// 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;
		}

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

		if (file_exists($fileTestingUpdates))
		{
			$force = true;
		}

		// Do I have to forcible reload from a URL?
		if (!$force)
		{
			// Force reload if more than 6 hours have elapsed
			if (abs(time() - $this->lastCheck) >= 21600)
			{
				$force = true;
			}
		}

		// 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
			$o = (object) array(
				$this->keyField   => $this->lastCheckTag,
				$this->valueField => $this->lastCheck,
			);

			$result = false;

			try
			{
				$result = $db->insertObject($this->tableName, $o, $this->keyField);
			}
			catch (\Exception $e)
			{
				$result = false;
			}

			if (!$result)
			{
				try
				{
					$result = $db->updateObject($this->tableName, $o, $this->keyField);
				}
				catch (\Exception $e)
				{
					$result = false;
				}
			}

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

			$result = false;

			try
			{
				$result = $db->insertObject($this->tableName, $o, $this->keyField);
			}
			catch (\Exception $e)
			{
				$result = false;
			}

			if (!$result)
			{
				try
				{
					$result = $db->updateObject($this->tableName, $o, $this->keyField);
				}
				catch (\Exception $e)
				{
					$result = false;
				}
			}

			// Simulate a PHP crash for automated testing
			if (defined('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
			try
			{
				/**
				 * Override for automated testing
				 *
				 * If the file update.ini exists (next to version.php) we use its contents as the update source, without
				 * accessing the update information URL at all. The file is immediately removed.
				 */
				if (is_file($fileTestingUpdates))
				{
					$rawInfo = @file_get_contents($fileTestingUpdates);

					$this->container->fileSystem->delete($fileTestingUpdates);
				}
				else
				{
					$download = new Download($this->container);
					$rawInfo  = $download->getFromURL($this->updateStreamURL);
				}

				$this->updateInfo->loadString($rawInfo, 'INI');
				$this->updateInfo->set('loadedUpdate', ($rawInfo !== false) ? 1 : 0);
				$this->updateInfo->set('stuck', 0);
			}
			catch (\Exception $e)
			{
				// We are stuck. Darn.

				return;
			}

			// If not stuck, loadedUpdate is 1, version key exists and stability key does not exist / is empty, determine the version stability
			$version   = $this->updateInfo->get('version', '');
			$stability = $this->updateInfo->get('stability', '');
			if (
				!$this->updateInfo->get('stuck', 0)
				&& $this->updateInfo->get('loadedUpdate', 0)
				&& !empty($version)
				&& empty($stability)
			)
			{
				$this->updateInfo->set('stability', $this->getStability($version));
			}

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

			$result = false;

			try
			{
				$result = $db->insertObject($this->tableName, $o, $this->keyField);
			}
			catch (\Exception $e)
			{
				$result = false;
			}

			if (!$result)
			{
				try
				{
					$result = $db->updateObject($this->tableName, $o, $this->keyField);
				}
				catch (\Exception $e)
				{
					$result = false;
				}
			}
		}

		// 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('link', '');

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

	/**
	 * Is there an update available?
	 *
	 * @return  bool
	 */
	public function hasUpdate()
	{
		$this->updateInfo->set('minstabilityMatch', 1);
		$this->updateInfo->set('platformMatch', 0);

		// Validate the minimum stability
		$stability = strtolower($this->updateInfo->get('stability'));

		switch ($this->minStability)
		{
			case 'alpha':
			default:
				// Reports any stability level as an available update
				break;

			case 'beta':
				// Do not report alphas as available updates
				if (in_array($stability, array('alpha')))
				{
					$this->updateInfo->set('minstabilityMatch', 0);

					return false;
				}
				break;

			case 'rc':
				// Do not report alphas and betas as available updates
				if (in_array($stability, array('alpha', 'beta')))
				{
					$this->updateInfo->set('minstabilityMatch', 0);

					return false;
				}
				break;

			case 'stable':
				// Do not report alphas, betas and rcs as available updates
				if (in_array($stability, array('alpha', 'beta', 'rc')))
				{
					$this->updateInfo->set('minstabilityMatch', 0);

					return false;
				}
				break;
		}

		// Validate the platform compatibility
		$platforms = explode(',', $this->updateInfo->get('platforms', ''));

		if (!empty($platforms))
		{
			$phpVersionParts   = explode('.', PHP_VERSION, 3);
			$currentPHPVersion = $phpVersionParts[0] . '.' . $phpVersionParts[1];

			$platformFound = false;

			$requirePlatformName = $this->container->segment->get('platformNameForUpdates', 'php');
			$currentPlatform     = $this->container->segment->get('platformVersionForUpdates', $currentPHPVersion);

			// Check for the platform
			foreach ($platforms as $platform)
			{
				$platform      = trim($platform);
				$platform      = strtolower($platform);
				$platformParts = explode('/', $platform, 2);

				if ($platformParts[0] != $requirePlatformName)
				{
					continue;
				}

				if ((substr($platformParts[1], -1) == '+') && version_compare($currentPlatform, substr($platformParts[1], 0, -1), 'ge'))
				{
					$this->updateInfo->set('platformMatch', 1);
					$platformFound = true;
				}
				elseif ($platformParts[1] == $currentPlatform)
				{
					$this->updateInfo->set('platformMatch', 1);
					$platformFound = true;
				}
			}

			// If we are running inside a CMS perform a second check for the PHP version
			if ($platformFound && ($requirePlatformName != 'php'))
			{
				$this->updateInfo->set('platformMatch', 0);
				$platformFound = false;

				foreach ($platforms as $platform)
				{
					$platform      = trim($platform);
					$platform      = strtolower($platform);
					$platformParts = explode('/', $platform, 2);

					if ($platformParts[0] != 'php')
					{
						continue;
					}

					if ($platformParts[1] == $currentPHPVersion)
					{
						$this->updateInfo->set('platformMatch', 1);
						$platformFound = true;
					}
				}
			}

			if (!$platformFound)
			{
				return false;
			}
		}

		// If the user had the Core version but has entered a Download ID we will always display an update as being
		// available
		if (!AKEEBABACKUP_PRO && !empty($this->downloadId))
		{
			return true;
		}

		// Apply the version strategy
		$version = $this->updateInfo->get('version', null);
		$date    = $this->updateInfo->get('date', null);

		if (empty($version) || empty($date))
		{
			return false;
		}

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

				break;

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

				break;

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

				break;

			case 'smart':
				return $this->hasUpdateByDateAndVersion($version, $date);
				break;
		}

		return false;
	}

	/**
	 * Returns the update information
	 *
	 * @param   bool $force Should we force the fetch of new information?
	 *
	 * @return \Awf\Registry\Registry
	 */
	public function getUpdateInformation($force = false)
	{
		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, 'wb');

		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 = array(
				"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 = array(
			'file'          => $this->updateInfo->get('link', ''),
			'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 = array(
				"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
		$randVal  = new Randval(new \Awf\Utils\Phpfunc());
		$password = base64_encode($randVal->generate(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);
		}

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

		if (function_exists('wincache_refresh_if_changed'))
		{
			wincache_refresh_if_changed(array($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 = array(
			'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()
	{
		// Reserved for future use. DO NOT REMOVE.
	}

	/**
	 * 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 = new Date('2000-01-01 00:00:00');
		}
		else
		{
			try
			{
				$mine = new Date($this->currentDateStamp);
			}
			catch (\Exception $e)
			{
				$mine = new Date('2000-01-01 00:00:00');
			}
		}

		$theirs = new Date($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 != $mine);
	}

	private function hasUpdateByDateAndVersion($version, $date)
	{
		$isCurrentDev = in_array(substr($this->currentVersion, 0, 3), array('dev', 'rev'));
		$isUpdateDev = in_array(substr($version, 0, 3), array('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);
	}
}
