<?php
namespace Engine\Cache;

/**
 * The class is used to manage the cache stored on disk.
 * @author Gabriel Grosu
 */
class Disk implements \Engine\Cache\BaseInterface
{

	/**
	 * The full location where the cache files are stored.
	 * @var String
	 */
	protected $cachePath = '';

	/**
	 * The class constructor.
	 */
	public function __construct()
	{
		$CFG = \Engine\Loader_Cfg::getInstance()->getGlobal();
		// realpath is used because cwd may change in __destructor(s) - \App\Entity\Cache caches the entity object in destructors.
		$this->cachePath = realpath(MODULE_REL_PATH.$CFG['paths']['files']).'/'.$CFG['cache']['disk']['location']; // TODO: [nonMVC] MODULE_REL_PATH can be eliminated after all non-MVC modules are eliminated
	}

	/**
	 * Sets a new path for the cache - this is relative to TINA files/ fir.
	 * @param String $path
	 */
	public function setPath($path)
	{
		// create dir (if it does not exist)
		\Engine\Dir::create($path);
		// realpath is used because cwd may change in __destructor(s) - \App\Entity\Cache caches the entity object in destructors.
		$this->cachePath = realpath($path).'/';
		if (strlen($this->cachePath)<2) {
			throw new \Exception("Invalid cache path set");
		}
	}

	/**
	 * Get the cache path
	 * @return String
	 */
	public function getPath()
	{
		return $this->cachePath;
	}

	/**
	 * Returns some associative array of data determined from the key
	 * @param Array $key
	 */
	private function getFileDetails($key)
	{
		$return = [];

		$keyHash = md5($key);
		$keyDir = substr($keyHash, 0, 2);

		// the full file location
		$return['fileLocation'] = 	$this->cachePath . $keyDir.'/';

		$return['fileName'] = 		$keyHash;

		return $return;
	}

	/**
	 * Sets a value in the cache for a certain key.
	 * @param String $key
	 * @param Mixed $value
	 * @param String $expireTime
	 */
	public function set($key, $value, $expireTime=2592000) :void	// 2592000 = one month
	{
		if (!$key) {
			return ;
		}

		if ($value===null) {
			return ;
		}

		if (!is_int($expireTime) or $expireTime<0) {
			throw new \Exception( sprintf('Invalid format for `expireTime` when using cache class - it must be numeric, but the current value is `%s`', $expireTime) );
		}

		$params = $this->getFileDetails($key);

		// handle expireTime
		$expireTimestamp = TIMESTAMP + $expireTime;

		// attempt to create the directory chain
		if (!is_dir($params['fileLocation'])) {
			if (@mkdir($params['fileLocation'], 0777, true)) {	// using @ to suppress errors in uTests when running tests in parallel
				@chmod($params['fileLocation'], 0777);
			}
		}

		// get serialized data to be cached - do not cache if it exceeds 64MB
		$serializedValue = serialize($value);
		if (strlen($serializedValue)>64*1024*1024) {
			\Log::getInstance()->warning('Disk cache class will not store values larger than 64MB. Maybe check why you need to cache such a large piece of information; cache key: '.$key);
			return ;
		}

		$oldmask = umask(000); // give everybody permissions. As close to 0777 as we can get
		file_put_contents($params['fileLocation'].$params['fileName'], $expireTimestamp.$serializedValue, LOCK_EX);
		umask($oldmask);
	}

	/**
	 * Gets a value stored in the cache for a certain key.
	 * @param String $key
	 * @return Mixed - the stored value or null if the key was not found
	 */
	public function get($key)
	{
		if (!$key)  {
			return null;
		}

		$params = $this->getFileDetails($key);

		if (!file_exists($params['fileLocation'].$params['fileName']) || !is_readable($params['fileLocation'].$params['fileName'])) {
			return null;
		}

		// read contents
		$f = fopen($params['fileLocation'].$params['fileName'], 'rb');

		flock($f, LOCK_SH);	// shared lock - read-only
		$contents = fread($f, 64*1024*1024);	// read 64Mb of data, max
		flock($f, LOCK_UN);
		fclose($f);

		if (!$contents) {
			return null;
		}

		// get and check expiration time
		$expirationTimestamp = (int)substr($contents, 0, 10);
		if ($expirationTimestamp < TIMESTAMP) {
			// delete the expired cache from disk
			unlink($params['fileLocation'].$params['fileName']);
			return null;
		}

		// get contents, unserialize it and return it
		$value = @unserialize(substr($contents, 10));
		if ($value===false) {
			return null;
		}

		return $value;
	}

	/**
	 * Deletes the value stored in the cache for a certain key.
	 * @param Mixed $key - if it is an array - deletes all those keys; if it is a string - deleted only that key
	 */
	public function delete($key) :void
	{
		if (!$key)  {
			return ;
		}

		$params = $this->getFileDetails($key);
		@unlink($params['fileLocation'].$params['fileName']);
	}

	/**
	 * Deletes the whole cache content.
	 * @return Boolean - indicating if the cache was completely destroyed
	 * @see \Engine\Cache\BaseInterface::flush()
	 */
	public function flush() :bool
	{
		return \Engine\Dir::deleteContents($this->cachePath);
	}

	/**
	 * Returns an associative array of details for a certain key: the file location, the expire timestamp
	 * @return Array
	 */
	public function getInfo($key)
	{
		$return = [];
		// associate the key
		$return['key'] = $key;

		// get more details
		$params = $this->getFileDetails($key);
		$fullFileName = $params['fileLocation'].$params['fileName'];

		// CHECK IF THE FILE EXISTS
		if (file_exists($fullFileName) && is_readable($fullFileName)) {
			$return['fileExists'] = true;
			$return['fileLocation'] = $params['fileLocation'];
			$return['fileName'] = $params['fileName'];

			// get and check expiration time
			$f = fopen($return['fileLocation'].$return['fileName'], 'rb');
			$contents = fread($f, 128);	// read 128 bytes, we only care about the header
			$expirationTimestamp = (int)substr($contents, 0, 10);
			$return['expirationTimestamp'] = $expirationTimestamp;
			$return['expired'] = ($expirationTimestamp < TIMESTAMP);
		}
		else {
			$return['fileExists'] = false;
			$return['fileLocation'] = $params['fileLocation'];
			$return['fileName'] = $params['fileName'];
		}

		return $return;
	}
}
