<?php
/**
* @version	$Id: cache_manager.php 15073 2012-01-18 14:25:18Z alex $
* @package	In-Portal
* @copyright	Copyright (C) 1997 - 2011 Intechnic. All rights reserved.
* @license      GNU/GPL
* In-Portal is Open Source software.
* This means that this software may have been modified pursuant
* the GNU General Public License, and as distributed it includes
* or is derivative of works licensed under the GNU General Public License
* or other free or open source software licenses.
* See http://www.in-portal.org/license for copyright notices and details.
*/

defined('FULL_PATH') or die('restricted access!');

class kCacheManager extends kBase implements kiCacheable {

	/**
	 * Used variables from SystemSettings table
	 *
	 * @var Array
	 * @access protected
	 */
	protected $configVariables = Array();

	/**
	 * Used variables from SystemSettings table retrieved from unit cache
	 *
	 * @var Array
	 * @access protected
	 */
	protected $originalConfigVariables = Array ();

	/**
	 * IDs of config variables used in current run (for caching)
	 *
	 * @var Array
	 * @access protected
	 */
	protected $configIDs = Array ();

	/**
	 * IDs of config variables retrieved from unit cache
	 *
	 * @var Array
	 * @access protected
	 */
	protected $originalConfigIDs = Array ();

	/**
	 * Object of memory caching class
	 *
	 * @var kCache
	 * @access protected
	 */
	protected $cacheHandler = null;

	protected $temporaryCache = Array (
		'registerAggregateTag' => Array (),
		'registerScheduledTask' => Array (),
		'registerHook' => Array (),
		'registerBuildEvent' => Array (),
		'registerAggregateTag' => Array (),
	);

	/**
	 * Name of database table, where configuration settings are stored
	 *
	 * @var string
	 * @access protected
	 */
	protected $settingTableName = '';

	/**
	 * Set's references to kApplication and kDBConnection class instances
	 *
	 * @param kApplication $application
	 * @access public
	 */
	public function __construct($application = null)
	{
		parent::__construct($application);

		$this->settingTableName = TABLE_PREFIX . 'SystemSettings';

		if ( defined('IS_INSTALL') && IS_INSTALL ) {
			// table substitution required, so "root" can perform login to upgrade to 5.2.0, where setting table was renamed
			if ( !$this->Application->TableFound(TABLE_PREFIX . 'SystemSettings') ) {
				$this->settingTableName = TABLE_PREFIX . 'ConfigurationValues';
			}
		}
	}
	/**
	 * Creates caching manager instance
	 *
	 * @access public
	 */
	public function InitCache()
	{
		$this->cacheHandler =& $this->Application->makeClass('kCache');
	}

	/**
	 * Returns cache key, used to cache phrase and configuration variable IDs used on current page
	 *
	 * @return string
	 * @access protected
	 */
	protected function getCacheKey()
	{
		// TODO: maybe language part isn't required, since same phrase from different languages have one ID now
		return $this->Application->GetVar('t') . $this->Application->GetVar('m_theme') . $this->Application->GetVar('m_lang') . $this->Application->isAdmin;
	}

	/**
	 * Loads phrases and configuration variables, that were used on this template last time
	 *
	 * @access public
	 */
	public function LoadApplicationCache()
	{
		$phrase_ids = $config_ids = Array ();

		$sql = 'SELECT PhraseList, ConfigVariables
				FROM ' . TABLE_PREFIX . 'PhraseCache
				WHERE Template = ' . $this->Conn->qstr( md5($this->getCacheKey()) );
		$res = $this->Conn->GetRow($sql);

		if ($res) {
			if ( $res['PhraseList'] ) {
				$phrase_ids = explode(',', $res['PhraseList']);
			}

			if ( $res['ConfigVariables'] ) {
				$config_ids = array_diff( explode(',', $res['ConfigVariables']), $this->originalConfigIDs);
			}
		}

		$this->Application->Phrases->Init('phrases', '', null, $phrase_ids);
		$this->configIDs = $this->originalConfigIDs = $config_ids;

		$this->InitConfig();
	}

	/**
	 * Updates phrases and configuration variables, that were used on this template
	 *
	 * @access public
	 */
	public function UpdateApplicationCache()
	{
		$update = false;

		//something changed
		$update = $update || $this->Application->Phrases->NeedsCacheUpdate();
		$update = $update || (count($this->configIDs) && $this->configIDs != $this->originalConfigIDs);

		if ($update) {
			$fields_hash = Array (
				'PhraseList' => implode(',', $this->Application->Phrases->Ids),
				'CacheDate' => adodb_mktime(),
				'Template' => md5( $this->getCacheKey() ),
				'ConfigVariables' => implode(',', array_unique($this->configIDs)),
			);

			$this->Conn->doInsert($fields_hash, TABLE_PREFIX . 'PhraseCache', 'REPLACE');
		}
	}

	/**
	 * Loads configuration variables, that were used on this template last time
	 *
	 * @access protected
	 */
	protected function InitConfig()
	{
		if (!$this->originalConfigIDs) {
			return ;
		}

		$sql = 'SELECT VariableValue, VariableName
				FROM ' . $this->settingTableName . '
			 	WHERE VariableId IN (' . implode(',', $this->originalConfigIDs) . ')';
		$config_variables = $this->Conn->GetCol($sql, 'VariableName');

		$this->configVariables = array_merge($this->configVariables, $config_variables);
	}

	/**
	 * Returns configuration option value by name
	 *
	 * @param string $name
	 * @return string
	 * @access public
	 */
	public function ConfigValue($name)
	{
		$site_domain_override = Array (
			'DefaultEmailSender' => 'AdminEmail',
			'DefaultEmailRecipients' => 'DefaultEmailRecipients',
		);

		if ( isset($site_domain_override[$name]) ) {
			$res = $this->Application->siteDomainField($site_domain_override[$name]);

			if ( $res ) {
				return $res;
			}
		}

		if ( array_key_exists($name, $this->configVariables) ) {
			return $this->configVariables[$name];
		}

		if ( defined('IS_INSTALL') && IS_INSTALL && !$this->Application->TableFound($this->settingTableName, true) ) {
			return false;
		}

		$this->Conn->nextQueryCachable = true;
		$sql = 'SELECT VariableId, VariableValue
				FROM ' . $this->settingTableName . '
				WHERE VariableName = ' . $this->Conn->qstr($name);
		$res = $this->Conn->GetRow($sql);

		if ( $res !== false ) {
			$this->configIDs[] = $res['VariableId'];
			$this->configVariables[$name] = $res['VariableValue'];

			return $res['VariableValue'];
		}

		trigger_error('Usage of undefined configuration variable "<strong>' . $name . '</strong>"', E_USER_NOTICE);

		return false;
	}

	/**
	 * Changes value of individual configuration variable (+resets cache, when needed)
	 *
	 * @param string $name
	 * @param string $value
	 * @param bool $local_cache_only
	 * @return string
	 * @access public
	 */
	public function SetConfigValue($name, $value, $local_cache_only = false)
	{
		$this->configVariables[$name] = $value;

		if ( $local_cache_only ) {
			return;
		}

		$fields_hash = Array ('VariableValue' => $value);
		$this->Conn->doUpdate($fields_hash, $this->settingTableName, 'VariableName = ' . $this->Conn->qstr($name));

		if ( array_key_exists($name, $this->originalConfigVariables) && $value != $this->originalConfigVariables[$name] ) {
			$this->DeleteUnitCache();
		}
	}

	/**
	 * Loads data, that was cached during unit config parsing
	 *
	 * @return bool
	 * @access public
	 */
	public function LoadUnitCache()
	{
		if ( $this->Application->isCachingType(CACHING_TYPE_MEMORY) ) {
			$data = $this->Application->getCache('master:configs_parsed', false, CacheSettings::$unitCacheRebuildTime);
		}
		else {
			$data = $this->Application->getDBCache('configs_parsed', CacheSettings::$unitCacheRebuildTime);
		}

		if ( $data ) {
			$cache = unserialize($data); // 126 KB all modules
			unset($data);

			$this->Application->InitManagers();

			$this->Application->setFromCache($cache);

			$aggregator =& $this->Application->recallObject('TagsAggregator', 'kArray');
			/* @var $aggregator kArray */

			$aggregator->setFromCache($cache);
			$this->setFromCache($cache);
			unset($cache);

			return true;
		}

		if ( $this->Application->isCachingType(CACHING_TYPE_MEMORY) ) {
			$this->Application->rebuildCache('master:configs_parsed', kCache::REBUILD_NOW, CacheSettings::$unitCacheRebuildTime);
		}
		else {
			$this->Application->rebuildDBCache('configs_parsed', kCache::REBUILD_NOW, CacheSettings::$unitCacheRebuildTime);
		}

		return false;
	}

	/**
	 * Empties factory and event manager cache (without storing changes)
	 */
	public function EmptyUnitCache()
	{
		// maybe discover keys automatically from corresponding classes
		$cache_keys = Array (
			'Factory.Files', 'Factory.realClasses', 'Factory.Dependencies',
			'ConfigReader.prefixFiles',
			'EventManager.beforeHooks', 'EventManager.afterHooks', 'EventManager.scheduledTasks', 'EventManager.buildEvents',
			'Application.ReplacementTemplates', 'Application.RewriteListeners', 'Application.ModuleInfo',
			'Application.ConfigHash', 'Application.ConfigCacheIds',
		);

		$empty_cache = Array ();

		foreach ($cache_keys as $cache_key) {
			$empty_cache[$cache_key] = Array ();
		}

		$this->Application->setFromCache($empty_cache);
		$this->setFromCache($empty_cache);

		// otherwise ModulesHelper indirectly used from includeConfigFiles won't work
		$this->Application->RegisterDefaultClasses();
	}

	/**
	 * Updates data, that was parsed from unit configs this time
	 *
	 * @access public
	 */
	public function UpdateUnitCache()
	{
		$aggregator =& $this->Application->recallObject('TagsAggregator', 'kArray');
		/* @var $aggregator kArray */

		$this->preloadConfigVars(); // preloading will put to cache

		$cache = array_merge(
			$this->Application->getToCache(),
			$aggregator->getToCache(),
			$this->getToCache()
		);

		$cache_rebuild_by = SERVER_NAME . ' (' . getenv('REMOTE_ADDR') . ') - ' . adodb_date('d/m/Y H:i:s');

		if ($this->Application->isCachingType(CACHING_TYPE_MEMORY)) {
			$this->Application->setCache('master:configs_parsed', serialize($cache));
			$this->Application->setCache('master:last_cache_rebuild', $cache_rebuild_by);
		}
		else {
			$this->Application->setDBCache('configs_parsed', serialize($cache));
			$this->Application->setDBCache('last_cache_rebuild', $cache_rebuild_by);
		}
	}

	public function delayUnitProcessing($method, $params)
	{
		if ($this->Application->InitDone) {
			// init already done -> call immediately (happens during installation)
			$function = Array (&$this->Application, $method);
			call_user_func_array($function, $params);

			return ;
		}

		$this->temporaryCache[$method][] = $params;
	}

	public function applyDelayedUnitProcessing()
	{
		foreach ($this->temporaryCache as $method => $method_calls) {
			$function = Array (&$this->Application, $method);

			foreach ($method_calls as $method_call) {
				call_user_func_array($function, $method_call);
			}

			$this->temporaryCache[$method] = Array ();
		}
	}

	/**
	 * Deletes all data, that was cached during unit config parsing (excluding unit config locations)
	 *
	 * @param Array $config_variables
	 * @access public
	 */
	public function DeleteUnitCache($config_variables = null)
	{
		if ( isset($config_variables) && !array_intersect(array_keys($this->originalConfigVariables), $config_variables) ) {
			// prevent cache reset, when given config variables are not in unit cache
			return;
		}

		if ( $this->Application->isCachingType(CACHING_TYPE_MEMORY) ) {
			$this->Application->rebuildCache('master:configs_parsed', kCache::REBUILD_LATER, CacheSettings::$unitCacheRebuildTime);
		}
		else {
			$this->Application->rebuildDBCache('configs_parsed', kCache::REBUILD_LATER, CacheSettings::$unitCacheRebuildTime);
		}
	}

	/**
	 * Deletes cached section tree, used during permission checking and admin console tree display
	 *
	 * @return void
	 * @access public
	 */
	public function DeleteSectionCache()
	{
		if ( $this->Application->isCachingType(CACHING_TYPE_MEMORY) ) {
			$this->Application->rebuildCache('master:sections_parsed', kCache::REBUILD_LATER, CacheSettings::$sectionsParsedRebuildTime);
		}
		else {
			$this->Application->rebuildDBCache('sections_parsed', kCache::REBUILD_LATER, CacheSettings::$sectionsParsedRebuildTime);
		}
	}

	/**
	 * Preloads 21 widely used configuration variables, so they will get to cache for sure
	 *
	 * @access protected
	 */
	protected function preloadConfigVars()
	{
		$config_vars = Array (
			// session related
			'SessionTimeout', 'SessionCookieName', 'SessionCookieDomains', 'SessionBrowserSignatureCheck',
			'SessionIPAddressCheck', 'CookieSessions', 'KeepSessionOnBrowserClose', 'User_GuestGroup',
			'User_LoggedInGroup', 'RegistrationUsernameRequired',

			// output related
			'UseModRewrite', 'UseContentLanguageNegotiation', 'UseOutputCompression', 'OutputCompressionLevel',
			'Config_Site_Time', 'SystemTagCache',

			// tracking related
			'UseChangeLog', 'UseVisitorTracking', 'ModRewriteUrlEnding', 'ForceModRewriteUrlEnding',
			'RunScheduledTasksFromCron',
		);

		$escaped_config_vars = $this->Conn->qstrArray($config_vars);

		$sql = 'SELECT VariableId, VariableName, VariableValue
				FROM ' . $this->settingTableName . '
				WHERE VariableName IN (' . implode(',', $escaped_config_vars) . ')';
		$data = $this->Conn->Query($sql, 'VariableId');

		foreach ($data as $variable_id => $variable_info) {
			$this->configIDs[] = $variable_id;
			$this->configVariables[ $variable_info['VariableName'] ] = $variable_info['VariableValue'];
		}
	}

	/**
	 * Sets data from cache to object
	 *
	 * Used for cases, when ConfigValue is called before LoadApplicationCache method (e.g. session init, url engine init)
	 *
	 * @param Array $data
	 * @access public
	 */
	public function setFromCache(&$data)
	{
		$this->configVariables = $this->originalConfigVariables = $data['Application.ConfigHash'];
		$this->configIDs = $this->originalConfigIDs = $data['Application.ConfigCacheIds'];
	}

	/**
	 * Gets object data for caching
	 * The following caches should be reset based on admin interaction (adjusting config, enabling modules etc)
	 *
	 * @access public
	 * @return Array
	 */
	public function getToCache()
	{
		return Array (
			'Application.ConfigHash' => $this->configVariables,
			'Application.ConfigCacheIds' => $this->configIDs,

			// not in use, since it only represents template specific values, not global ones
			// 'Application.Caches.ConfigVariables' => $this->originalConfigIDs,
		);
	}

	/**
	 * Returns caching type (none, memory, temporary)
	 *
	 * @param int $caching_type
	 * @return bool
	 * @access public
	 */
	public function isCachingType($caching_type)
	{
		return $this->cacheHandler->getCachingType() == $caching_type;
	}

	/**
	 * Prints caching statistics
	 *
	 * @access public
	 */
	public function printStatistics()
	{
		$this->cacheHandler->printStatistics();
	}

	/**
	 * Returns cached $key value from cache named $cache_name
	 *
	 * @param int $key key name from cache
	 * @param bool $store_locally store data locally after retrieved
	 * @param int $max_rebuild_seconds
	 * @return mixed
	 * @access public
	 */
	public function getCache($key, $store_locally = true, $max_rebuild_seconds = 0)
	{
		return $this->cacheHandler->getCache($key, $store_locally, $max_rebuild_seconds);
	}

	/**
	 * Adds new value to cache $cache_name and identified by key $key
	 *
	 * @param int $key key name to add to cache
	 * @param mixed $value value of cached record
	 * @param int $expiration when value expires (0 - doesn't expire)
	 * @return bool
	 * @access public
	 */
	public function setCache($key, $value, $expiration = 0)
	{
		return $this->cacheHandler->setCache($key, $value, $expiration);
	}

	/**
	 * Sets rebuilding mode for given cache
	 *
	 * @param string $name
	 * @param int $mode
	 * @param int $max_rebuilding_time
	 * @return void
	 * @access public
	 */
	public function rebuildCache($name, $mode = null, $max_rebuilding_time = 0)
	{
		$this->cacheHandler->rebuildCache($name, $mode, $max_rebuilding_time);
	}

	/**
	 * Deletes key from cache
	 *
	 * @param string $key
	 * @return void
	 * @access public
	 */
	public function deleteCache($key)
	{
		$this->cacheHandler->delete($key);
	}

	/**
	 * Reset's all memory cache at once
	 *
	 * @return void
	 * @access public
	 */
	public function resetCache()
	{
		$this->cacheHandler->reset();
	}

	/**
	 * Returns value from database cache
	 *
	 * @param string $name key name
	 * @param int $max_rebuild_seconds
	 * @return mixed
	 * @access public
	 */
	public function getDBCache($name, $max_rebuild_seconds = 0)
	{
		if ( $this->_getDBCache($name . '_rebuild') ) {
			// cache rebuild requested -> rebuild now
			$this->deleteDBCache($name . '_rebuild');

			return false;
		}

		// no serials in cache key OR cache is outdated
		$wait_seconds = $max_rebuild_seconds;

		while (true) {
			$cache = $this->_getDBCache($name);
			$rebuilding = $this->_getDBCache($name . '_rebuilding');

			if ( ($cache === false) && (!$rebuilding || $wait_seconds == 0) ) {
				// cache missing and nobody rebuilding it -> rebuild; enough waiting for cache to be ready
				return false;
			}
			elseif ( $cache !== false ) {
				// cache present -> return it
				return $cache;
			}

			$wait_seconds -= kCache::WAIT_STEP;
			sleep(kCache::WAIT_STEP);
		}

		return false;
	}

	/**
	 * Returns value from database cache
	 *
	 * @param string $name key name
	 * @return mixed
	 * @access protected
	 */
	protected function _getDBCache($name)
	{
		$this->Conn->nextQueryCachable = true;

		$sql = 'SELECT Data, Cached, LifeTime
				FROM ' . TABLE_PREFIX . 'SystemCache
				WHERE VarName = ' . $this->Conn->qstr($name);
		$data = $this->Conn->GetRow($sql);

		if ($data) {
			$lifetime = (int)$data['LifeTime']; // in seconds
			if (($lifetime > 0) && ($data['Cached'] + $lifetime < adodb_mktime())) {
				// delete expired
				$this->Conn->nextQueryCachable = true;

				$sql = 'DELETE FROM ' . TABLE_PREFIX . 'SystemCache
						WHERE VarName = ' . $this->Conn->qstr($name);
				$this->Conn->Query($sql);

				return false;
			}

			return $data['Data'];
		}

		return false;
	}

	/**
	 * Sets value to database cache
	 *
	 * @param string $name
	 * @param mixed $value
	 * @param int|bool $expiration
	 * @return void
	 * @access public
	 */
	public function setDBCache($name, $value, $expiration = false)
	{
		$this->deleteDBCache($name . '_rebuilding');
		$this->_setDBCache($name, $value, $expiration);
	}

	/**
	 * Sets value to database cache
	 *
	 * @param string $name
	 * @param mixed $value
	 * @param int|bool $expiration
	 * @access protected
	 */
	protected function _setDBCache($name, $value, $expiration = false)
	{
		if ((int)$expiration <= 0) {
			$expiration = -1;
		}

		$fields_hash = Array (
			'VarName' => $name,
			'Data' => &$value,
			'Cached' => adodb_mktime(),
			'LifeTime' => (int)$expiration,
		);

		$this->Conn->nextQueryCachable = true;
		$this->Conn->doInsert($fields_hash, TABLE_PREFIX . 'SystemCache', 'REPLACE');
	}

	/**
	 * Sets rebuilding mode for given cache
	 *
	 * @param string $name
	 * @param int $mode
	 * @param int $max_rebuilding_time
	 * @return void
	 * @access public
	 */
	public function rebuildDBCache($name, $mode = null, $max_rebuilding_time = 0)
	{
		if ( !isset($mode) || $mode == kCache::REBUILD_NOW ) {
			$this->_setDBCache($name . '_rebuilding', 1, $max_rebuilding_time);
			$this->deleteDBCache($name . '_rebuild');
		}
		elseif ( $mode == kCache::REBUILD_LATER ) {
			$this->_setDBCache($name . '_rebuild', 1, 0);
			$this->deleteDBCache($name . '_rebuilding');
		}
	}

	/**
	 * Deletes key from database cache
	 *
	 * @param string $name
	 * @return void
	 * @access public
	 */
	public function deleteDBCache($name)
	{
		$sql = 'DELETE FROM ' . TABLE_PREFIX . 'SystemCache
				WHERE VarName = ' . $this->Conn->qstr($name);
		$this->Conn->Query($sql);
	}

	/**
	 * Increments serial based on prefix and it's ID (optional)
	 *
	 * @param string $prefix
	 * @param int $id ID (value of IDField) or ForeignKeyField:ID
	 * @param bool $increment
	 * @return string
	 * @access public
	 */
	public function incrementCacheSerial($prefix, $id = null, $increment = true)
	{
		$pascal_case_prefix = implode('', array_map('ucfirst', explode('-', $prefix)));
		$serial_name = $pascal_case_prefix . (isset($id) ? 'IDSerial:' . $id : 'Serial');

		if ($increment) {
			if (defined('DEBUG_MODE') && DEBUG_MODE && $this->Application->isDebugMode()) {
				$this->Application->Debugger->appendHTML('Incrementing serial: <strong>' . $serial_name . '</strong>.');
			}

			$this->setCache($serial_name, (int)$this->getCache($serial_name) + 1);

			if (!defined('IS_INSTALL') || !IS_INSTALL) {
				// delete cached mod-rewrite urls related to given prefix and id
				$delete_clause = isset($id) ? $prefix . ':' . $id : $prefix;

				$sql = 'DELETE FROM ' . TABLE_PREFIX . 'CachedUrls
						WHERE Prefixes LIKE ' . $this->Conn->qstr('%|' . $delete_clause . '|%');
				$this->Conn->Query($sql);
			}
		}

		return $serial_name;
	}

	/**
	 * Returns cached category informaton by given cache name. All given category
	 * information is recached, when at least one of 4 caches is missing.
	 *
	 * @param int $category_id
	 * @param string $name cache name = {filenames, category_designs, category_tree}
	 * @return string
	 * @access public
	 */
	public function getCategoryCache($category_id, $name)
	{
		$serial_name = '[%CIDSerial:' . $category_id . '%]';
		$cache_key = $name . $serial_name;
		$ret = $this->getCache($cache_key);

		if ($ret === false) {
			if (!$category_id) {
				// don't query database for "Home" category (ID = 0), because it doesn't exist in database
				return false;
			}

			// this allows to save 2 sql queries for each category
			$this->Conn->nextQueryCachable = true;
			$sql = 'SELECT NamedParentPath, CachedTemplate, TreeLeft, TreeRight
					FROM ' . TABLE_PREFIX . 'Categories
					WHERE CategoryId = ' . (int)$category_id;
			$category_data = $this->Conn->GetRow($sql);

			if ($category_data !== false) {
				// only direct links to category pages work (symlinks, container pages and so on won't work)
				$this->setCache('filenames' . $serial_name,				$category_data['NamedParentPath']);
				$this->setCache('category_designs' . $serial_name,		ltrim($category_data['CachedTemplate'], '/'));
				$this->setCache('category_tree' . $serial_name,			$category_data['TreeLeft'] . ';' . $category_data['TreeRight']);
			}
		}

		return $this->getCache($cache_key);
	}
}