<?php
/**
 * Fragment Cache
 * (c) 2010 Procurios
 * License: http://www.opensource.org/licenses/mit-license.php
 */

/**
 * Use this interface to turn existing classes into fragments.
 * For an explanation of the methods, @see Fragment.
 */
interface Fragmentable
{
	public function setFragmentParameters($fragmentParameters);
	public function setFragmentData($key, $value = null);
	public function getFragmentData($key = null, $default = null);
	public function getDataSourceIds();
	public function getTTL();
	public function isValid();
	public function getOutput();
	public function execute();
}

/**
 * Base class for fragments. A fragment is a piece of code whose output is cached.
 */
abstract class Fragment implements Fragmentable
{
	/** @var array Input parameters that modify this fragment */
	protected $fragmentParameters;
	/** @var array State of the fragment */
	protected $fragmentData = array();

	/**
	 * Sets the input parameters that modify this fragment
	 * @param array $objectParameters
	 */
	public function setFragmentParameters($fragmentParameters)
	{
		$this->fragmentParameters = $fragmentParameters;
	}

	/**
	 * Sets the state date of this fragment.
	 * This function can be used in two ways:
	 *
	 * 1. setFragmentData('key1', 'value1') // single key
	 * 2. setFragmentData(array('k1' => 'v1', 'k2' => 'v2')) // all keys
	 *
	 * @param string $key
	 * @param mixed $value
	 */
	public function setFragmentData($key, $value = null)
	{
		if (is_null($value)) {
			$this->fragmentData = $key;
		} else {
			$this->fragmentData[$key] = $value;
		}
	}

	/**
	 * Gets the state date of this fragment.
	 * This function can be used in two ways:
	 *
	 * 1. getFragmentData('key1') // single key (with optional default value)
	 * 2. getFragmentData() // all keys
	 *
	 * @param string $key
	 * @param mixed $value
	 */
	public function getFragmentData($key = null, $default = null)
	{
		if (is_null($key)) {
			return $this->fragmentData;
		} else {
			return isset($this->fragmentData[$key]) ? $this->fragmentData[$key] : $default;
		}
	}

	/**
	 * Returns the ids of the datasources this fragment depends upon.
	 * @return array
	 */
	public function getDataSourceIds()
	{
		return array();
	}

	/**
	 * Returns the time (in seconds) this fragment is valid.
	 * @return int|null Time in seconds or null (does not expire).
	 */
	public function getTTL()
	{
		return null;
	}

	/**
	 * Overload this function to determine if the cache is still valid, at run time,
	 * each time the cache is requested.
	 * Try to keep this function as light as possible.
	 *
	 * @return bool
	 */
	public function isValid()
	{
		return true;
	}

	/**
	 * Returns the output of the fragment that will be cached.
	 * @return string
	 */
	public function getOutput()
	{
		return '';
	}

	/**
	 * Perfoms code that needs to be executed even when the output has been cached.
	 */
	public function execute()
	{
	}
}

/**
 * This class manages the physical storage of the caches and the indexes to the caches.
 * Override any or all of the functions to suit your needs
 *
 * The following three functions form the heart of the store: here is where the data is actually
 * stored, retrieved, and removed: setData(), getData(), clearData(), clearAllData()
 */
class FragmentCacheStore
{
	/**
	 * Returns the cached output and the fragment metadata, or false if no cache info is available.
	 *
	 * @param string $cacheId
	 * @return array|bool
	 */
	public function getCacheInfo($cacheId)
	{
		return $this->getData($cacheId);
	}

	/**
	 * Stores the cached output and fragment metadata for a given cache id.
	 * Indexes the datasource dependencies to clear caches faster when datasources change.
	 *
	 * @param string $cacheId
	 * @param array $info
	 */
	public function setCacheInfo($cacheId, array $info)
	{
		$this->setData($cacheId, $info);

		// index dependencies for faster lookup
		foreach ($info['datasources'] as $dsid) {
			$this->addDataSourceDependency($cacheId, $dsid);
		}
	}

	/**
	 * Clears the cache cacheId; clears the fragments that depend on it.
	 * Removes the index on the datasources dependencies.
	 * @param string $cacheId
	 */
	public function clearCache($cacheId)
	{
		$fragmentInfo = $this->getCacheInfo($cacheId);
		if ($fragmentInfo) {
			$dsids = $fragmentInfo['datasources'];
			$this->clearData($cacheId);

			// remove the cache id from the dependency index
			foreach ($dsids as $dsid) {
				$this->removeDataSourceDependency($cacheId, $dsid);
			}

			// clear the parent fragments of this cache
			foreach ($fragmentInfo['parents'] as $parentCacheId) {
				$this->clearCache($parentCacheId);
			}
		}
	}

	public function dataSourceChanged($dsid)
	{
		$alldeps = $this->getDependencies($dsid);
		foreach ($alldeps as $cacheId) {
			$this->clearCache($cacheId);
		}
	}

	public function createCacheId($classname, $parameters)
	{
		return $classname . '___' . implode('___', $parameters);
	}

	protected function getDependencies($dsid)
	{
		return $this->getData('dep_' . $dsid, array());
	}

	/**
	 * Checks if the index for a given datastore is still available.
	 * (it might have been removed from the store)
	 * @param string $dsid
	 * @return bool
	 */
	protected function indexExists($dsid, $cacheId)
	{
		return in_array($cacheId, $this->getDependencies($dsid));
	}

	protected function setDependencies($dsid, $alldeps)
	{
		if (empty($alldeps)) {
			$this->clearData('dep_' . $dsid);
		} else {
			$this->setData('dep_' . $dsid, $alldeps);
		}
	}

	protected function addDataSourceDependency($cacheId, $dsid)
	{
		$alldeps = $this->getDependencies($dsid);
		if (!in_array($cacheId, $alldeps)) {
			$alldeps[] = $cacheId;
		}
		$this->setDependencies($dsid, $alldeps);
	}

	protected function removeDataSourceDependency($cacheId, $dsid)
	{
		$alldeps = $this->getDependencies($dsid);
		if (($index = array_search($cacheId, $alldeps)) !== false) {
			unset($alldeps[$index]);
		}
		$this->setDependencies($dsid, $alldeps);
	}
}

/**
 * This is the API for all fragment cache functions.
 */
class FragmentCache
{
	/** @var array A stack to store the current path of fragments */
	protected static $fragmentStack = array();

	/**
	 * Returns the cached output of fragment $classname, modified by $parameters.
	 * If not in cache, it will be cached now.
	 * Executable fragment code is executed, including that of child fragments.
	 *
	 * @param string $classname Classname of Fragment file
	 * @param array $parameters
	 * @return string
	 */
	public static function getCache($classname, $parameters = array())
	{
		return self::getOrCreateCache($classname, $parameters, false);
	}

	/**
	 * Rebuilds the cache. This function can be used the recreate or "prime" the cache offline, in a background process,
	 * so that no user needs to wait for it in a normal request.
	 * @param string $classname Classname of Fragment file
	 * @param array $parameters
	 */
	public static function recreateCache($classname, $parameters = array())
	{
		return self::getOrCreateCache($classname, $parameters, true);
	}

	/**
	 * Returns the cached output of fragment $classname, modified by $parameters.
	 * If not in cache, or when $forceBuild is set, it will be cached now.
	 *
	 * @param string $classname Classname of Fragment file
	 * @param array $parameters
	 * @param bool $forceBuild
	 * @return string
	 */
	protected static function getOrCreateCache($classname, $parameters = array(), $forceBuild)
	{
		// create this cache's id
		$cacheId = self::getStore()->createCacheId($classname, $parameters);
		// fetch this cache's info (if possible)
		$fragmentInfo = $savedCacheInfo = self::getStore()->getCacheInfo($cacheId);
		// the parent fragment will be needed moreoften, so fetch it here
		if (count(self::$fragmentStack) > 0) {
			$parentFragment = &self::$fragmentStack[count(self::$fragmentStack) - 1];
		} else {
			$parentFragment = null;
		}

		// can we use the cache?
		if ($fragmentInfo) {

			// instantiate the fragment, restore the runtime parameters and execute the executable code
			$Fragment = self::buildFragment($fragmentInfo);

			// cache is primed; but is it still valid?
			if ($forceBuild) {
				// explicit invalidation
				$cacheIsValid = false;
			} else {
				$cacheIsValid = self::isFragmentValid($Fragment, $fragmentInfo);
			}

			// fragment data may have changed when calling isValid()
			$fragmentInfo['data'] = $Fragment->getFragmentData();

		} else {

			// create a new fragment
			// no data found: create a new fragment
			$Fragment = new $classname();
			$Fragment->setFragmentParameters($parameters);

			$cacheIsValid = false;
			$fragmentInfo = array();

		}

		if ($cacheIsValid) {

			// this fragment's info may have been created by another fragment parent;
			// in that case, current fragment parent needs to be added to the info
			if ($parentFragment) {
				if (!in_array($parentFragment['id'], $fragmentInfo['parents'])) {
					$fragmentInfo['parents'][] = $parentFragment['id'];
					self::getStore()->setCacheInfo($cacheId, $fragmentInfo);
					$parentFragment['children'][] = $cacheId;
				}
			}

			// cache is valid: get the output
			$output = $fragmentInfo['output'];
			// and execute the side effects and that of the child fragments
			self::executeCode($Fragment, $fragmentInfo);

		} else {

			// push this fragment to the fragment stack
			array_push(self::$fragmentStack, array('id' => $cacheId, 'children' => array()));
			// check for fragments including themselves
			if (count(self::$fragmentStack) > 255) {
				trigger_error('Fragments nested too deep.', E_USER_ERROR);
			}

			// tell the fragment to create its output
			// this may cause other calls to FragmentCache::getCache()
			// and this creates child fragments
			$output = $Fragment->getOutput();
			// execute the side effects
			$Fragment->execute();

			// pop the container with child fragment ids
			$fragmentInfo = array_pop(self::$fragmentStack);

			// create the cache info
			$datasources = $Fragment->getDataSourceIds();
			$ttl = $Fragment->getTTL();
			$fragmentInfo = array_merge($fragmentInfo, array(
				'classname' => $classname,
				'classfile' => __FILE__,
				'parameters' => $parameters,
				'children' => $fragmentInfo['children'],
				'datasources' => $datasources,
				'output' => $output,
				'data' => $Fragment->getFragmentData()
			));
			// time to live defined? store the timeout time for this cache
			if ($ttl !== null) {
				$fragmentInfo['timeout'] = microtime(true) + $ttl;
			}
			// save the calling parent as the (first) parent of this fragment
			if ($parentFragment) {
				$fragmentInfo['parents'] = array($parentFragment['id']);

				// add this fragment's cache id to the child fragment container of the parent
				$parentFragment['children'][] = $cacheId;
			}
		}

		// fragment info changed? store it
		if ($savedCacheInfo != $fragmentInfo) {
			self::getStore()->setCacheInfo($cacheId, $fragmentInfo);
		}

		return $output;
	}

	/**
	 * Builds the fragment based on its information.
	 * @param array $fragmentInfo
	 * @return Fragmentable
	 */
	protected static function buildFragment(array $fragmentInfo)
	{
		require_once($fragmentInfo['classfile']);
		$Fragment = new $fragmentInfo['classname']();
		$Fragment->setFragmentParameters($fragmentInfo['parameters']);

		// set stored custom fragment data
		$data = isset($fragmentInfo['data']) ? $fragmentInfo['data'] : array();
		$Fragment->setFragmentData($data);

		return $Fragment;
	}

	/**
	 * Is the given $Fragment still valid? The fragment is also invalid if one of its children is invalid.
	 * @param Fragmentable $Fragment
	 * @param array $fragmentInfo
	 * @return bool
	 */
	protected static function isFragmentValid(Fragmentable $Fragment, array $fragmentInfo)
	{
		// check custom validity code
		$cacheIsValid = $Fragment->isValid();

		// or has it timed out?
		if (isset($fragmentInfo['timeout'])) {
			if (microtime(true) >= $fragmentInfo['timeout']) {
				$cacheIsValid = false;
			}
		}

		foreach ($fragmentInfo['children'] as $childId) {
			$childInfo = self::getStore()->getCacheInfo($childId);
			$ChildFragment = self::buildFragment($childInfo);
			if (!self::isFragmentValid($ChildFragment, $childInfo)) {
				$cacheIsValid = false;
				break;
			}
		}

		return $cacheIsValid;
	}

	/**
	 * If a fragment cache by the given id is available,
	 * this function will echo its output.
	 * If the cache has not been created before, the following output will be buffered until
	 * the next FragmentCache::endCache() is called.
	 *
	 * Calls to beginCache may be nested.
	 *
	 * @param string $cacheId
	 * @param float $time-to-live Time in (seconds) after which the cache expires
	 * @return bool Is a cache available and its output echoed?
	 */
	public static function beginCache($cacheId = null, $ttl = null)
	{
		static $ids = array();

		// no id given? generate one based on the file and the position in it
		if ($cacheId === null) {
			// create a unique ID based on the position in the file where the cache is used
			$backtrace = debug_backtrace();
			// $backtrace[0] provides data from the calling function
			$cacheId = md5($backtrace[0]['file'] . '#' . $backtrace[0]['line']);
		}

		$fragmentInfo = self::getStore()->getCacheInfo($cacheId);
		$cacheIsValid = false;
		if ($fragmentInfo) {
			$cacheIsValid = true;
			if (isset($fragmentInfo['timeout'])) {
				if (microtime(true) >= $fragmentInfo['timeout']) {
					$cacheIsValid = false;
				}
			}
		}
		if ($cacheIsValid) {
			echo $fragmentInfo['output'];
			return true;
		} else {
			array_push(self::$fragmentStack, array('id' => $cacheId, 'children' => array(), 'ttl' => $ttl));
			ob_start();
			return false;
		}
	}

	/**
	 * This function should only be called when a paired FragmentCache::beginCache() returns false.
	 * It flushes the output buffer and stores the string in cache.
	 */
	public static function endCache()
	{
		$output = ob_get_flush();
		$fragmentInfo = array_pop(self::$fragmentStack);
		$timeout = $fragmentInfo['ttl'] ? (microtime(true) + $fragmentInfo['ttl']) : null;
		$cacheId = $fragmentInfo['id'];
		self::getStore()->setCacheInfo($cacheId, array(
			'classname' => 'Fragment',
			'timeout' => $timeout,
			'classfile' => __FILE__,
			'parameters' => array(),
			'datasources' => array(),
			'output' => $output
		));
	}

	/**
	 * Executes the execute() code of this fragment and its child fragments.
	 * Child fragments may live in other files, and these are automatically included
	 * (make sure these files don't contain any global code, because it will be executed in the process).
	 *
	 * @param Fragmentable $Fragment
	 * @param array $fragmentInfo
	 */
	protected static function executeCode(Fragmentable $Fragment, array $fragmentInfo)
	{
		// recursively execute child fragment code
		foreach ($fragmentInfo['children'] as $childId) {
			$childInfo = self::getStore()->getCacheInfo($childId);
			$ChildFragment = self::buildFragment($childInfo);
			self::executeCode($ChildFragment, $childInfo);
		}

		$Fragment->execute();
	}

	/**
	 * Notifies the fragment cache that a given datasource ($dsid) has changed.
	 * This will clear all caches that depend on it.
	 *
	 * @param string $dsid
	 */
	public static function dataSourceChanged($dsid)
	{
		self::getStore()->dataSourceChanged($dsid);
	}

	public static function clearCache($cacheId)
	{
		self::getStore()->clearCache($cacheId);
	}

	public static function clearAllCaches()
	{
		self::getStore()->clearAllData();
	}

	/**
	 * Returns the store that physically stores and retrieves caches.
	 * @return FragmentCacheStore
	 */
	protected static function getStore()
	{
		static $Store = null;

		if ($Store === null) {
			$Store = new FragmentCacheStoreFile();
		}

		return $Store;
	}
}

/**
 * A FragmentCacheStore implemented in a file system.
 * If the directory $cacheDir could not be created, the store breaks.
 */
class FragmentCacheStoreFile extends FragmentCacheStore
{
	protected static $cacheDir = '/tmp/fragmentcache/';

	/**
	 * Creates the store and makes sure the caching directory exists.
	 */
	public function __construct()
	{
		// check if the user has emptied the cache dir inadvertently
		if (self::$cacheDir == "") {
			trigger_error('Cache dir has no value.', E_USER_ERROR);
		}

		if (!file_exists(self::$cacheDir)) {
			$success = mkdir(self::$cacheDir, 0700, true);
			if (!$success) {
				trigger_error('Failed to create cache dir.', E_USER_ERROR);
			}
		}
	}

	protected function getData($id, $default = null)
	{
		$contents = @file_get_contents(self::$cacheDir . '/' . $id . '.fc');
		return $contents === false ? $default : json_decode($contents, true);
	}

	protected function setData($id, $value)
	{
		$json = json_encode($value);
		file_put_contents(self::$cacheDir . '/' . $id . '.fc', $json);
	}

	protected function clearData($id)
	{
		@unlink(self::$cacheDir . '/' . $id . '.fc');
	}

	/**
	 * Removes all caches and the datasource dependency index.
	 */
	public function clearAllData()
	{
		foreach (glob(self::$cacheDir . '/*.fc') as $filename) {
			@unlink($filename);
		}
	}
}

