<?php
/**
 * Joomla! Content Management System
 *
 * @copyright  (C) 2005 Open Source Matters, Inc. <https://www.joomla.org>
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\CMS\Cache;

\defined('JPATH_PLATFORM') or die;

use Joomla\Application\Web\WebClient;
use Joomla\CMS\Cache\Exception\CacheExceptionInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Filesystem\Path;
use Joomla\CMS\Session\Session;

/**
 * Joomla! Cache base object
 *
 * @since  1.7.0
 */
class Cache
{
	/**
	 * Storage handler
	 *
	 * @var    CacheStorage[]
	 * @since  1.7.0
	 */
	public static $_handler = array();

	/**
	 * Cache options
	 *
	 * @var    array
	 * @since  1.7.0
	 */
	public $_options;

	/**
	 * Constructor
	 *
	 * @param   array  $options  Cache options
	 *
	 * @since   1.7.0
	 */
	public function __construct($options)
	{
		$app = Factory::getApplication();

		$this->_options = array(
			'cachebase'    => $app->get('cache_path', JPATH_CACHE),
			'lifetime'     => (int) $app->get('cachetime'),
			'language'     => $app->get('language', 'en-GB'),
			'storage'      => $app->get('cache_handler', ''),
			'defaultgroup' => 'default',
			'locking'      => true,
			'locktime'     => 15,
			'checkTime'    => true,
			'caching'      => ($app->get('caching') >= 1),
		);

		// Overwrite default options with given options
		foreach ($options as $option => $value)
		{
			if (isset($options[$option]) && $options[$option] !== '')
			{
				$this->_options[$option] = $options[$option];
			}
		}

		if (empty($this->_options['storage']))
		{
			$this->setCaching(false);
		}
	}

	/**
	 * Returns a reference to a cache adapter object, always creating it
	 *
	 * @param   string  $type     The cache object type to instantiate
	 * @param   array   $options  The array of options
	 *
	 * @return  CacheController
	 *
	 * @since       1.7.0
	 * @deprecated  5.0 Use the cache controller factory instead
	 */
	public static function getInstance($type = 'output', $options = array())
	{
		@trigger_error(
			sprintf(
				'%s() is deprecated. The cache controller should be fetched from the factory.',
				__METHOD__
			),
			E_USER_DEPRECATED
		);

		return Factory::getContainer()->get(CacheControllerFactoryInterface::class)->createCacheController($type, $options);
	}

	/**
	 * Get the storage handlers
	 *
	 * @return  array
	 *
	 * @since   1.7.0
	 */
	public static function getStores()
	{
		$handlers = array();

		// Get an iterator and loop through the driver classes.
		$iterator = new \DirectoryIterator(__DIR__ . '/Storage');

		/** @type  $file  \DirectoryIterator */
		foreach ($iterator as $file)
		{
			$fileName = $file->getFilename();

			// Only load for php files.
			if (!$file->isFile() || $file->getExtension() !== 'php' || $fileName === 'CacheStorageHelper.php')
			{
				continue;
			}

			// Derive the class name from the type.
			$class = str_ireplace('.php', '', __NAMESPACE__ . '\\Storage\\' . ucfirst(trim($fileName)));

			// If the class doesn't exist we have nothing left to do but look at the next type. We did our best.
			if (!class_exists($class))
			{
				continue;
			}

			// Sweet!  Our class exists, so now we just need to know if it passes its test method.
			if ($class::isSupported())
			{
				// Connector names should not have file extensions.
				$handler = str_ireplace('Storage.php', '', $fileName);
				$handler = str_ireplace('.php', '', $handler);
				$handlers[] = strtolower($handler);
			}
		}

		return $handlers;
	}

	/**
	 * Set caching enabled state
	 *
	 * @param   boolean  $enabled  True to enable caching
	 *
	 * @return  void
	 *
	 * @since   1.7.0
	 */
	public function setCaching($enabled)
	{
		$this->_options['caching'] = $enabled;
	}

	/**
	 * Get caching state
	 *
	 * @return  boolean
	 *
	 * @since   1.7.0
	 */
	public function getCaching()
	{
		return $this->_options['caching'];
	}

	/**
	 * Set cache lifetime
	 *
	 * @param   integer  $lt  Cache lifetime
	 *
	 * @return  void
	 *
	 * @since   1.7.0
	 */
	public function setLifeTime($lt)
	{
		$this->_options['lifetime'] = $lt;
	}

	/**
	 * Check if the cache contains data stored by ID and group
	 *
	 * @param   string  $id     The cache data ID
	 * @param   string  $group  The cache data group
	 *
	 * @return  boolean
	 *
	 * @since   3.7.0
	 */
	public function contains($id, $group = null)
	{
		if (!$this->getCaching())
		{
			return false;
		}

		// Get the default group
		$group = $group ?: $this->_options['defaultgroup'];

		return $this->_getStorage()->contains($id, $group);
	}

	/**
	 * Get cached data by ID and group
	 *
	 * @param   string  $id     The cache data ID
	 * @param   string  $group  The cache data group
	 *
	 * @return  mixed  Boolean false on failure or a cached data object
	 *
	 * @since   1.7.0
	 */
	public function get($id, $group = null)
	{
		if (!$this->getCaching())
		{
			return false;
		}

		// Get the default group
		$group = $group ?: $this->_options['defaultgroup'];

		return $this->_getStorage()->get($id, $group, $this->_options['checkTime']);
	}

	/**
	 * Get a list of all cached data
	 *
	 * @return  mixed  Boolean false on failure or an object with a list of cache groups and data
	 *
	 * @since   1.7.0
	 */
	public function getAll()
	{
		if (!$this->getCaching())
		{
			return false;
		}

		return $this->_getStorage()->getAll();
	}

	/**
	 * Store the cached data by ID and group
	 *
	 * @param   mixed   $data   The data to store
	 * @param   string  $id     The cache data ID
	 * @param   string  $group  The cache data group
	 *
	 * @return  boolean
	 *
	 * @since   1.7.0
	 */
	public function store($data, $id, $group = null)
	{
		if (!$this->getCaching())
		{
			return false;
		}

		// Get the default group
		$group = $group ?: $this->_options['defaultgroup'];

		// Get the storage and store the cached data
		return $this->_getStorage()->store($id, $group, $data);
	}

	/**
	 * Remove a cached data entry by ID and group
	 *
	 * @param   string  $id     The cache data ID
	 * @param   string  $group  The cache data group
	 *
	 * @return  boolean
	 *
	 * @since   1.7.0
	 */
	public function remove($id, $group = null)
	{
		// Get the default group
		$group = $group ?: $this->_options['defaultgroup'];

		try
		{
			return $this->_getStorage()->remove($id, $group);
		}
		catch (CacheExceptionInterface $e)
		{
			if (!$this->getCaching())
			{
				return false;
			}

			throw $e;
		}
	}

	/**
	 * Clean cache for a group given a mode.
	 *
	 * group mode    : cleans all cache in the group
	 * notgroup mode : cleans all cache not in the group
	 *
	 * @param   string  $group  The cache data group
	 * @param   string  $mode   The mode for cleaning cache [group|notgroup]
	 *
	 * @return  boolean  True on success, false otherwise
	 *
	 * @since   1.7.0
	 */
	public function clean($group = null, $mode = 'group')
	{
		// Get the default group
		$group = $group ?: $this->_options['defaultgroup'];

		try
		{
			return $this->_getStorage()->clean($group, $mode);
		}
		catch (CacheExceptionInterface $e)
		{
			if (!$this->getCaching())
			{
				return false;
			}

			throw $e;
		}
	}

	/**
	 * Garbage collect expired cache data
	 *
	 * @return  boolean
	 *
	 * @since   1.7.0
	 */
	public function gc()
	{
		try
		{
			return $this->_getStorage()->gc();
		}
		catch (CacheExceptionInterface $e)
		{
			if (!$this->getCaching())
			{
				return false;
			}

			throw $e;
		}
	}

	/**
	 * Set lock flag on cached item
	 *
	 * @param   string  $id        The cache data ID
	 * @param   string  $group     The cache data group
	 * @param   string  $locktime  The default locktime for locking the cache.
	 *
	 * @return  \stdClass  Object with properties of lock and locklooped
	 *
	 * @since   1.7.0
	 */
	public function lock($id, $group = null, $locktime = null)
	{
		$returning = new \stdClass;
		$returning->locklooped = false;

		if (!$this->getCaching())
		{
			$returning->locked = false;

			return $returning;
		}

		// Get the default group
		$group = $group ?: $this->_options['defaultgroup'];

		// Get the default locktime
		$locktime = $locktime ?: $this->_options['locktime'];

		/*
		 * Allow storage handlers to perform locking on their own
		 * NOTE drivers with lock need also unlock or unlocking will fail because of false $id
		 */
		$handler = $this->_getStorage();

		if ($this->_options['locking'] == true)
		{
			$locked = $handler->lock($id, $group, $locktime);

			if ($locked !== false)
			{
				return $locked;
			}
		}

		// Fallback
		$curentlifetime = $this->_options['lifetime'];

		// Set lifetime to locktime for storing in children
		$this->_options['lifetime'] = $locktime;

		$looptime = $locktime * 10;
		$id2      = $id . '_lock';

		if ($this->_options['locking'] == true)
		{
			$data_lock = $handler->get($id2, $group, $this->_options['checkTime']);
		}
		else
		{
			$data_lock         = false;
			$returning->locked = false;
		}

		if ($data_lock !== false)
		{
			$lock_counter = 0;

			// Loop until you find that the lock has been released. That implies that data get from other thread has finished
			while ($data_lock !== false)
			{
				if ($lock_counter > $looptime)
				{
					$returning->locked = false;
					$returning->locklooped = true;
					break;
				}

				usleep(100);
				$data_lock = $handler->get($id2, $group, $this->_options['checkTime']);
				$lock_counter++;
			}
		}

		if ($this->_options['locking'] == true)
		{
			$returning->locked = $handler->store($id2, $group, 1);
		}

		// Revert lifetime to previous one
		$this->_options['lifetime'] = $curentlifetime;

		return $returning;
	}

	/**
	 * Unset lock flag on cached item
	 *
	 * @param   string  $id     The cache data ID
	 * @param   string  $group  The cache data group
	 *
	 * @return  boolean
	 *
	 * @since   1.7.0
	 */
	public function unlock($id, $group = null)
	{
		if (!$this->getCaching())
		{
			return false;
		}

		// Get the default group
		$group = $group ?: $this->_options['defaultgroup'];

		// Allow handlers to perform unlocking on their own
		$handler = $this->_getStorage();

		$unlocked = $handler->unlock($id, $group);

		if ($unlocked !== false)
		{
			return $unlocked;
		}

		// Fallback
		return $handler->remove($id . '_lock', $group);
	}

	/**
	 * Get the cache storage handler
	 *
	 * @return  CacheStorage
	 *
	 * @since   1.7.0
	 */
	public function &_getStorage()
	{
		$hash = md5(serialize($this->_options));

		if (isset(self::$_handler[$hash]))
		{
			return self::$_handler[$hash];
		}

		self::$_handler[$hash] = CacheStorage::getInstance($this->_options['storage'], $this->_options);

		return self::$_handler[$hash];
	}

	/**
	 * Perform workarounds on retrieved cached data
	 *
	 * @param   array   $data     Cached data
	 * @param   array   $options  Array of options
	 *
	 * @return  string  Body of cached data
	 *
	 * @since   1.7.0
	 */
	public static function getWorkarounds($data, $options = array())
	{
		$app      = Factory::getApplication();
		$document = Factory::getDocument();
		$body     = null;

		// Get the document head out of the cache.
		if (isset($options['mergehead']) && $options['mergehead'] == 1 && isset($data['head']) && !empty($data['head'])
			&& method_exists($document, 'mergeHeadData'))
		{
			$document->mergeHeadData($data['head']);
		}
		elseif (isset($data['head']) && method_exists($document, 'setHeadData'))
		{
			$document->setHeadData($data['head']);
		}

		// Get the document MIME encoding out of the cache
		if (isset($data['mime_encoding']))
		{
			$document->setMimeEncoding($data['mime_encoding'], true);
		}

		// If the pathway buffer is set in the cache data, get it.
		if (isset($data['pathway']) && \is_array($data['pathway']))
		{
			// Push the pathway data into the pathway object.
			$app->getPathway()->setPathway($data['pathway']);
		}

		// @todo check if the following is needed, seems like it should be in page cache
		// If a module buffer is set in the cache data, get it.
		if (isset($data['module']) && \is_array($data['module']))
		{
			// Iterate through the module positions and push them into the document buffer.
			foreach ($data['module'] as $name => $contents)
			{
				$document->setBuffer($contents, 'module', $name);
			}
		}

		// Set cached headers.
		if (isset($data['headers']) && $data['headers'])
		{
			foreach ($data['headers'] as $header)
			{
				$app->setHeader($header['name'], $header['value']);
			}
		}

		// The following code searches for a token in the cached page and replaces it with the proper token.
		if (isset($data['body']))
		{
			$token       = Session::getFormToken();
			$search      = '#<input type="hidden" name="[0-9a-f]{32}" value="1">#';
			$replacement = '<input type="hidden" name="' . $token . '" value="1">';

			$data['body'] = preg_replace($search, $replacement, $data['body']);
			$body         = $data['body'];
		}

		// Get the document body out of the cache.
		return $body;
	}

	/**
	 * Create workarounds for data to be cached
	 *
	 * @param   string  $data     Cached data
	 * @param   array   $options  Array of options
	 *
	 * @return  array  Data to be cached
	 *
	 * @since   1.7.0
	 */
	public static function setWorkarounds($data, $options = [])
	{
		$loptions = [
			'nopathway'  => 0,
			'nohead'     => 0,
			'nomodules'  => 0,
			'modulemode' => 0,
		];

		if (isset($options['nopathway']))
		{
			$loptions['nopathway'] = $options['nopathway'];
		}

		if (isset($options['nohead']))
		{
			$loptions['nohead'] = $options['nohead'];
		}

		if (isset($options['nomodules']))
		{
			$loptions['nomodules'] = $options['nomodules'];
		}

		if (isset($options['modulemode']))
		{
			$loptions['modulemode'] = $options['modulemode'];
		}

		$app      = Factory::getApplication();
		$document = Factory::getDocument();

		if ($loptions['nomodules'] != 1)
		{
			// Get the modules buffer before component execution.
			$buffer1 = $document->getBuffer();

			if (!\is_array($buffer1))
			{
				$buffer1 = [];
			}

			// Make sure the module buffer is an array.
			if (!isset($buffer1['module']) || !\is_array($buffer1['module']))
			{
				$buffer1['module'] = [];
			}
		}

		// View body data
		$cached['body'] = $data;

		// Document head data
		if ($loptions['nohead'] != 1 && method_exists($document, 'getHeadData'))
		{
			if ($loptions['modulemode'] == 1)
			{
				$headNow = $document->getHeadData();
				$unset   = ['title', 'description', 'link', 'links', 'metaTags'];

				foreach ($unset as $key)
				{
					unset($headNow[$key]);
				}

				// Sanitize empty data
				foreach (\array_keys($headNow) as $key)
				{
					if (!isset($headNow[$key]) || $headNow[$key] === [])
					{
						unset($headNow[$key]);
					}
				}

				$cached['head'] = $headNow;
			}
			else
			{
				$cached['head'] = $document->getHeadData();

				// Document MIME encoding
				$cached['mime_encoding'] = $document->getMimeEncoding();
			}
		}

		// Pathway data
		if ($app->isClient('site') && $loptions['nopathway'] != 1)
		{
			$cached['pathway'] = $data['pathway'] ?? $app->getPathway()->getPathway();
		}

		if ($loptions['nomodules'] != 1)
		{
			// @todo Check if the following is needed, seems like it should be in page cache
			// Get the module buffer after component execution.
			$buffer2 = $document->getBuffer();

			if (!\is_array($buffer2))
			{
				$buffer2 = [];
			}

			// Make sure the module buffer is an array.
			if (!isset($buffer2['module']) || !\is_array($buffer2['module']))
			{
				$buffer2['module'] = [];
			}

			// Compare the second module buffer against the first buffer.
			$cached['module'] = array_diff_assoc($buffer2['module'], $buffer1['module']);
		}

		// Headers data
		if (isset($options['headers']) && $options['headers'])
		{
			$cached['headers'] = $app->getHeaders();
		}

		return $cached;
	}

	/**
	 * Create a safe ID for cached data from URL parameters
	 *
	 * @return  string  MD5 encoded cache ID
	 *
	 * @since   1.7.0
	 */
	public static function makeId()
	{
		$app = Factory::getApplication();

		$registeredurlparams = new \stdClass;

		// Get url parameters set by plugins
		if (!empty($app->registeredurlparams))
		{
			$registeredurlparams = $app->registeredurlparams;
		}

		// Platform defaults
		$defaulturlparams = array(
			'format' => 'WORD',
			'option' => 'WORD',
			'view'   => 'WORD',
			'layout' => 'WORD',
			'tpl'    => 'CMD',
			'id'     => 'INT',
		);

		// Use platform defaults if parameter doesn't already exist.
		foreach ($defaulturlparams as $param => $type)
		{
			if (!property_exists($registeredurlparams, $param))
			{
				$registeredurlparams->$param = $type;
			}
		}

		$safeuriaddon = new \stdClass;

		foreach ($registeredurlparams as $key => $value)
		{
			$safeuriaddon->$key = $app->input->get($key, null, $value);
		}

		return md5(serialize($safeuriaddon));
	}

	/**
	 * Set a prefix cache key if device calls for separate caching
	 *
	 * @return  string
	 *
	 * @since   3.5
	 */
	public static function getPlatformPrefix()
	{
		// No prefix when Global Config is set to no platform specific prefix
		if (!Factory::getApplication()->get('cache_platformprefix', false))
		{
			return '';
		}

		$webclient = new WebClient;

		if ($webclient->mobile)
		{
			return 'M-';
		}

		return '';
	}

	/**
	 * Add a directory where Cache should search for handlers. You may either pass a string or an array of directories.
	 *
	 * @param   array|string  $path  A path to search.
	 *
	 * @return  array   An array with directory elements
	 *
	 * @since   1.7.0
	 */
	public static function addIncludePath($path = '')
	{
		static $paths;

		if (!isset($paths))
		{
			$paths = array();
		}

		if (!empty($path) && !\in_array($path, $paths))
		{
			array_unshift($paths, Path::clean($path));
		}

		return $paths;
	}
}
