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

use Awf\Application\Application;
use Awf\Exception\App as AWFAppException;
use Awf\Mvc\Model;
use Awf\Registry\Registry;
use Awf\Text\Text;
use Solo\Container;
use Solo\Exception\Update\ConnectionError;
use Solo\Exception\Update\PlatformError;
use Solo\Exception\Update\StabilityError;
use Solo\Exception\Update\UpdateError;

/**
 * This class will take care of bridging WordPress update system and Akeeba Backup package, fetching the info from the
 * plugin and passing back to WordPress.
 */
abstract class AkeebaBackupWPUpdater
{
	/**
	 * Private static variable keys that belong to our frozen state, stored in a site transient.
	 */
	const STATE_KEYS = [
		'needsDownloadID', 'connectionError', 'platformError', 'downloadLink', 'cantUseWpUpdate', 'stabilityError',
	];

	/** @var bool Do I need the Download ID? */
	protected static $needsDownloadID = false;

	/** @var bool Did I have a connection error while */
	protected static $connectionError = false;

	/** @var bool Do I have a platform error? (Wrong PHP or WP version) */
	protected static $platformError = false;

	/** @var string    Stores the download link. In this way we can run our logic only on our download links */
	protected static $downloadLink;

	/** @var bool    Am I in an ancient version of WordPress, were the integrated system is not usable? */
	protected static $cantUseWpUpdate = false;

	/** @var bool    Do I have an update that's less stable than my preferred stability? */
	protected static $stabilityError = false;

	/**
	 * Retrieve the update information from Akeeba Backup for WordPress' update cache and report them back to WordPress
	 * in a format it understands.
	 *
	 * The returned information is cached by WordPress and used by checkinfo() to render the Akeeba Backup for WordPress
	 * update information in WordPress' Plugins page.
	 *
	 * @param   stdClass  $transient
	 *
	 * @return  stdClass
	 * @throws  AWFAppException
	 */
	public static function getupdates($transient)
	{
		global $wp_version;
		global $akeebaBackupWordPressLoadPlatform;

		if (is_multisite() && !is_network_admin())
		{
			return $transient;
		}

		/**
		 * On WordPress < 4.3 we can't use the integrated update system since the hook we're using to apply our Download
		 * ID is not available. Instead we warn the user and tell them to use our own update system.
		 */
		if (version_compare($wp_version, '4.3', 'lt'))
		{
			static::$cantUseWpUpdate = true;
			self::freezeState();

			return $transient;
		}

		/**
		 * When the plugin is deleted, Wordpress reloads the updates. Since this file was already in memory, its code
		 * runs even if Akeeba Backup's files are not installed anymore. The following is a sanity check to prevent
		 * a PHP fatal error during the uninstallation of the plugin.
		 */
		$akeebaBackupWordPressLoadPlatform = false;

		if (!file_exists(__DIR__ . '/../helpers/integration.php'))
		{
			self::freezeState();

			return $transient;
		}

		// Do I have to notify the user that the Download ID is missing?
		if (static::needsDownloadID())
		{
			static::$needsDownloadID = true;
		}

		$updateInfo = false;

		try
		{
			$updateInfo = static::getUpdateInfo();
		}
		catch (ConnectionError $e)
		{
			// mhm... an error occurred while connecting to the updates server. Let's notify the user
			static::$connectionError = true;
		}
		catch (PlatformError $e)
		{
			static::$platformError = true;
		}
		catch (StabilityError $e)
		{
			static::$stabilityError = true;
		}
		catch (AWFAppException $e)
		{
			static::$connectionError = true;
		}

		self::freezeState();

		if (!$updateInfo)
		{
			return $transient;
		}

		if (!$transient || !isset($transient->response))
		{
			// Double check that we actually have an object to interact with. Since the $transient data is pulled from the database
			// and could be manipulated by other plugins, we might have an unexpected value here
			if (!$transient)
			{
				$transient = new stdClass();
			}

			$transient->response = [];
		}

		$dirSlug = self::getPluginSlug();

		$obj              = new stdClass();
		$obj->slug        = $dirSlug;
		$obj->plugin      = $dirSlug . '/akeebabackupwp.php';
		$obj->new_version = $updateInfo->get('version');
		$obj->url         = $updateInfo->get('infourl');
		$obj->package     = $updateInfo->get('link');
		$obj->icons       = [
			'2x' => WP_PLUGIN_URL . '/' . AkeebaBackupWP::$dirName . '/app/media/logo/abwp-256.png',
			'1x' => WP_PLUGIN_URL . '/' . AkeebaBackupWP::$dirName . '/app/media/logo/abwp-128.png',
		];

		$transient->response[$dirSlug . '/akeebabackupwp.php'] = $obj;

		/**
		 * Since the event we're hooking to is a global one (triggered for every plugin) we have to store a reference
		 * of our download link. This way we can apply our logic only on our stuff and don't interfere with third party
		 * plugins.
		 */
		static::$downloadLink = $updateInfo->get('link');

		return $transient;
	}

	/**
	 * Used to render "View version x.x.x details" link from the plugins page.
	 * We hook to this event to redirect the connection from the WordPress directory to our site for updates
	 *
	 * @param $cur_info
	 * @param $action
	 * @param $arg
	 *
	 * @return object
	 */
	public static function checkinfo($cur_info, $action, $arg)
	{
		$dirSlug = self::getPluginSlug();

		if (!isset($arg->slug))
		{
			return $cur_info;
		}

		if ($arg->slug !== $dirSlug)
		{
			return $cur_info;
		}

		try
		{
			$updateInfo = static::getUpdateInfo();
		}
		catch (UpdateError $e)
		{
			$updateInfo = false;
		}
		catch (AWFAppException $e)
		{
			$updateInfo = false;
		}

		/**
		 * Sanity check.
		 *
		 * This if-block should never be triggered. We only ever reach this code if we have already determined there is
		 * an update available.
		 */
		if (!$updateInfo)
		{
			return $cur_info;
		}

		/**
		 * This is the information WordPress is using to render the Akeeba Backup for WordPress row in its Plugins page.
		 */
		$information = [
			// We leave the "name" index empty, so WordPress won't display the ugly title on top of our banner
			'name'          => '',
			'slug'          => $dirSlug,
			'author'        => 'Akeeba Ltd.',
			'homepage'      => 'https://www.akeeba.com',
			'last_updated'  => $updateInfo->get('date'),
			'version'       => $updateInfo->get('version'),
			'download_link' => $updateInfo->get('link'),
			'requires'      => '3.8',
			'tested'        => get_bloginfo('version'),
			'sections'      => [
				// 'description' => 'Something description',
				'release_notes' => $updateInfo->get('releasenotes'),
			],
			'banners'       => [
				'low'  => plugins_url() . '/' . $dirSlug . '/app/media/image/wordpressupdate_banner.jpg',
				'high' => false,
			],
		];

		return (object) $information;
	}

	/**
	 * @param   bool         $bailout
	 * @param   string       $package
	 * @param   WP_Upgrader  $upgrader
	 *
	 * @return WP_Error|false    An error if anything goes wrong or is missing, either case FALSE to keep the update
	 *                           process going
	 */
	public static function addDownloadID($bailout, $package, $upgrader)
	{
		// Process only our download links
		if ($package != static::$downloadLink)
		{
			return false;
		}

		// Do we need the Download ID (ie Pro version)?
		if (static::needsDownloadID())
		{
			return new WP_Error(403, 'Please insert your Download ID inside Akeeba Backup to fetch the updates for the Pro version');
		}

		// Our updater automatically sets the Download ID in the link, so there's no need to change anything inside the URL
		return false;
	}

	/**
	 * Helper function to change some update options on the fly. By default WordPress will delete the entire folder
	 * and abort if the folder already exists; by tweaking the options we can force WordPress to extract on top of the
	 * existing folder without deleting it first.
	 *
	 * @param   array  $options  Options to be used while upgrading our plugin
	 *
	 * @return    array    Updated options
	 */
	public static function packageOptions($options)
	{
		if (isset($options['hook_extra']) && isset($options['hook_extra']['plugin']))
		{
			// If this is our package, let's tell WordPress to extract on top of the existing folder,
			// without deleting anything
			if (stripos($options['hook_extra']['plugin'], 'akeebabackupwp.php') !== false)
			{
				$options['clear_destination']           = false;
				$options['abort_if_destination_exists'] = false;
			}
		}

		return $options;
	}

	/**
	 * Helper function to display some custom text AFTER the row regarding our update.
	 *
	 * This is typically used to communicate problems preventing the update information from being retrieved or used,
	 * meaning updates for our plugin are essentially broken.
	 *
	 * @param $plugin_file
	 * @param $plugin_data
	 * @param $status
	 */
	public static function updateMessage($plugin_file, $plugin_data, $status)
	{
		self::thawState();

		// Load enough of our plugin to display translated strings
		try
		{
			$container = static::loadAkeebaBackup();
			Text::loadLanguage(null, 'akeebabackup', '.com_akeebabackup.ini', false, $container->languagePath);
			Text::loadLanguage('en-GB', 'akeebabackup', '.com_akeebabackup.ini', false, $container->languagePath);

		}
		catch (Exception $e)
		{
			return;
		}

		$html     = '';
		$warnings = [];
		$dirSlug  = self::getPluginSlug();

		if (static::$cantUseWpUpdate)
		{
			$updateUrl = 'admin.php?page=' . $dirSlug . '/akeebabackupwp.php&view=update&force=1';

			$warnings[] = sprintf(
				"<p id=\"akeebabackupwp-error-update-noconnection\">%s</p>",
				Text::sprintf('SOLO_UPDATE_WORDPRESS_OLDER_THAN_43', $updateUrl)
			);
		}
		elseif (static::$needsDownloadID)
		{
			$warnings[] = sprintf(
				"<p id=\"akeebabackupwp-error-update-nodownloadid\">%s</p>",
				Text::_('SOLO_UPDATE_ERROR_NEEDSAUTH')
			);
		}
		elseif (static::$connectionError)
		{
			$updateUrl = 'admin.php?page=' . $dirSlug . '/akeebabackupwp.php&view=update&force=1';

			$warnings[] = sprintf(
				"<p id=\"akeebabackupwp-error-update-noconnection\">%s</p>",
				Text::sprintf('SOLO_UPDATE_WORDPRESS_CONNECTION_ERROR', $updateUrl)
			);
		}
		elseif (static::$platformError)
		{
			$warnings[] = sprintf(
				"<p id=\"akeebabackupwp-error-update-platform-mismatch\">%s</p>",
				Text::_('SOLO_UPDATE_PLATFORM_HEAD')
			);
		}
		elseif (static::$stabilityError)
		{
			/**
			 * There is an update available but it's less stable than the minimum stability preference.
			 *
			 * For example: a Beta is available but we are asked to only report stable versions.
			 *
			 * We deliberately don't show a warning. The whole point of the stability preference is to stop buggering
			 * the poor user during our pre-release runs (alphas, betas and occasional RC). In this case we just pretend
			 * there is no update available, just like we do in the interface of our plugin.
			 */
		}

		if ($warnings)
		{
			$warnings = implode('', $warnings);
			$msg      = Text::_('SOLO_UPDATE_WORDPRESS_WARNING');

			$html = <<<HTML
<tr class="">
	<th></th>
	<td></td>
	<td>
		<div style="border: 1px solid #F0AD4E;border-radius: 3px;background: #fdf5e9;padding:10px">
			<strong>$msg</strong><br/>
			$warnings		
		</div>
	</td>
</tr>
HTML;
		}

		if ($html)
		{
			echo $html;
		}
	}

	/**
	 * Includes all the required pieces to load Akeeba Backup from within a standard WordPress page
	 *
	 * @return Container|false
	 *
	 * @throws AWFAppException
	 */
	public static function loadAkeebaBackup()
	{
		static $localContainer;

		// Do not run the whole logic if we already have a valid Container
		if ($localContainer)
		{
			return $localContainer;
		}

		if (!defined('AKEEBASOLO'))
		{
			define('AKEEBASOLO', 1);
		}

		@include_once __DIR__ . '/../app/version.php';

		// Include the autoloader
		if (!include_once __DIR__ . '/../app/Awf/Autoloader/Autoloader.php')
		{
			return false;
		}

		global $akeebaBackupWordPressLoadPlatform;
		$akeebaBackupWordPressLoadPlatform = false;

		if (!file_exists(__DIR__ . '/../helpers/integration.php'))
		{
			return false;
		}

		/** @var Container $container */
		$container = require __DIR__ . '/../helpers/integration.php';

		// Ok, really don't know why but this function gets called TWICE. It seems to completely ignore the first result
		// (even if we report that there's an update) and calls it again. This means that the require_once above will be ignored.
		// I can't simply return the current $transient because it doesn't contain the updated info.
		// So I'll save a previous copy of the container and then use it later.
		if (!$localContainer)
		{
			$localContainer = $container;
		}

		if (!$localContainer)
		{
			return false;
		}

		// Get all info saved inside the configuration
		$dirSlug = self::getPluginSlug();
		$container->appConfig->loadConfiguration();
		$container->basePath = WP_PLUGIN_DIR . '/' . $dirSlug . '/app/Solo';

		// Require the application for the first time by passing all values. In this way we prime the internal cache and
		// we will be covered on cases where we fetch the application from the getInstance method instead of using the container
		$application = Application::getInstance('Solo', $container);

		// Set the update platform
		$application->setupUpdatePlatform();

		return $localContainer;
	}

	/**
	 * Fetches the update information from the remote server
	 *
	 * @return Registry|bool
	 * @throws AWFAppException
	 * @throws UpdateError
	 */
	private static function getUpdateInfo()
	{
		static $updates;

		// If I already have some update info, simply return them
		if ($updates)
		{
			return $updates;
		}

		$container = static::loadAkeebaBackup();

		if (!$container)
		{
			return false;
		}

		/** @var \Solo\Model\Update $updateModel */
		$updateModel = Model::getInstance($container->application_name, 'Update', $container);
		$updateModel->load(true);

		// No updates? Let's stop here
		if (!$updateModel->hasUpdate())
		{
			// Ok, we didn't have an update, but maybe there's another reason for it
			$updateInfo = $updateModel->getUpdateInformation();

			// Did we get a connection error?
			if ($updateInfo->get('loadedUpdate') == false)
			{
				throw new ConnectionError();
			}

			// We might have an update that does not match the stability preference, e.g. RC with min. stability Stable.
			if ($updateInfo->get('minstabilityMatch') == false)
			{
				throw new StabilityError();
			}

			// mhm... maybe we're on a old WordPress version?
			if (!$updateInfo->get('platformMatch', 0))
			{
				throw new PlatformError();
			}

			return false;
		}

		$updates = $updateModel->getUpdateInformation();

		return $updates;
	}

	private static function needsDownloadID()
	{
		$container = static::loadAkeebaBackup();

		// With the core version we're always good to go
		if (!AKEEBABACKUP_PRO)
		{
			return false;
		}

		// Do we need the Download ID (ie Pro version)?
		$dlid = $container->appConfig->get('options.update_dlid');

		if (!preg_match('/^([0-9]{1,}:)?[0-9a-f]{32}$/i', $dlid))
		{
			return true;
		}

		return false;
	}

	/**
	 * Freeze the update warnings state in carbonite
	 *
	 * Just joking. We create an array with the update warnings flags and save it as a site transient.
	 */
	private static function freezeState()
	{
		$frozenState = [];

		foreach (self::STATE_KEYS as $key)
		{
			if (isset(self::${$key}))
			{
				$frozenState[$key] = self::${$key};
			}
		}

		set_site_transient('akeebabackupwp_pluginupdate_frozenstate', $frozenState);
	}

	/**
	 * Unfreeze the update warnings state
	 *
	 * We read the site transient and restore the update warnings flags from it, if it's set.
	 */
	private static function thawState()
	{
		$frozenState = get_site_transient('akeebabackupwp_pluginupdate_frozenstate');

		if (empty($frozenState) || !is_array($frozenState))
		{
			return;
		}

		foreach (self::STATE_KEYS as $key)
		{
			if (isset(self::${$key}) && isset($frozenState[$key]))
			{
				self::${$key} = $frozenState[$key];
			}
		}
	}

	/**
	 * @return mixed|string
	 */
	private static function getPluginSlug()
	{
		$pluginsUrl   = plugins_url('', realpath(__DIR__ . '/../akeebabackupwp.php'))
			?: realpath(__DIR__ . '/..');
		$baseUrlParts = explode('/', $pluginsUrl);
		$dirSlug      = end($baseUrlParts);

		if (!empty($dirSlug) && ($dirSlug != '..'))
		{
			return $dirSlug;
		}

		$fullDir  = __DIR__;
		$dirParts = explode(DIRECTORY_SEPARATOR, $fullDir, 3);
		$dirSlug  = $dirParts[1] ?? 'akeebabackup';

		return $dirSlug;
	}

}
