<?php
/*
	Question2Answer by Gideon Greenspan and contributors
	http://www.question2answer.org/

	File: qa-include/Q2A/Storage/FileCacheDriver.php
	Description: File-based driver for caching system.


	This program is free software; you can redistribute it and/or
	modify it under the terms of the GNU General Public License
	as published by the Free Software Foundation; either version 2
	of the License, or (at your option) any later version.

	This program is distributed in the hope that it will be useful,
	but WITHOUT ANY WARRANTY; without even the implied warranty of
	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
	GNU General Public License for more details.

	More about this license: http://www.question2answer.org/license.php
*/

/**
 * Caches data (typically from database queries) to the filesystem.
 */
class Q2A_Storage_FileCacheDriver implements Q2A_Storage_CacheDriver
{
	private $enabled = false;
	private $error;
	private $cacheDir;

	/**
	 * Creates a new FileCache instance and checks we can write to the cache directory.
	 * @param array $config Configuration data, including cache storage directory.
	 */
	public function __construct($config)
	{
		if (!$config['enabled']) {
			return;
		}

		if (isset($config['dir'])) {
			$this->cacheDir = realpath($config['dir']);

			if (!is_writable($this->cacheDir)) {
				$this->error = qa_lang_html_sub('admin/caching_dir_error', $config['dir']);
			} elseif (strpos($this->cacheDir, realpath($_SERVER['DOCUMENT_ROOT'])) === 0 || strpos($this->cacheDir, realpath(QA_BASE_DIR)) === 0) {
				// check the folder is outside the public root - checks against server root and Q2A root, in order to handle symbolic links
				$this->error = qa_lang_html_sub('admin/caching_dir_public', $config['dir']);
			}
		} else {
			$this->error = qa_lang_html('admin/caching_dir_missing');
		}

		$this->enabled = empty($this->error);
	}

	/**
	 * Get the cached data for the supplied key.
	 * @param string $key The unique cache identifier.
	 *
	 * @return string The cached data, or null otherwise.
	 */
	public function get($key)
	{
		if (!$this->enabled) {
			return null;
		}

		$file = $this->getFilename($key);

		if (is_readable($file)) {
			$lines = file($file, FILE_IGNORE_NEW_LINES);
			$actualKey = array_shift($lines);

			// double check this is the correct data
			if ($key === $actualKey) {
				$expiry = array_shift($lines);

				if (is_numeric($expiry) && time() < $expiry) {
					$encData = implode("\n", $lines);
					// decode data, ignoring any notices
					$data = @unserialize($encData);
					if ($data !== false) {
						return $data;
					}
				}
			}
		}

		return null;
	}

	/**
	 * Store something in the cache along with the key and expiry time. Data gets 'serialized' to a string before storing.
	 * @param string $key The unique cache identifier.
	 * @param mixed $data The data to cache (in core Q2A this is usually an array).
	 * @param int $ttl Number of minutes for which to cache the data.
	 *
	 * @return bool Whether the file was successfully cached.
	 */
	public function set($key, $data, $ttl)
	{
		$success = false;
		$ttl = (int) $ttl;

		if ($this->enabled && $ttl > 0) {
			$encData = serialize($data);
			$expiry = time() + ($ttl * 60);
			$cache = $key . "\n" . $expiry . "\n" . $encData;

			$file = $this->getFilename($key);
			$dir = dirname($file);
			if (is_dir($dir) || mkdir($dir, 0777, true)) {
				$success = @file_put_contents($file, $cache) !== false;
			}
		}

		return $success;
	}

	/**
	 * Delete an item from the cache.
	 * @param string $key The unique cache identifier.
	 *
	 * @return bool Whether the operation succeeded.
	 */
	public function delete($key)
	{
		if ($this->enabled) {
			$file = $this->getFilename($key);
			$dir = dirname($key);

			return $this->deleteFile($file);
		}

		return false;
	}

	/**
	 * Delete multiple items from the cache.
	 * @param int $limit Maximum number of items to process. 0 = unlimited
	 * @param int $start Offset from which to start (used for 'batching' deletes).
	 * @param bool $expiredOnly Delete cache only if it has expired.
	 *
	 * @return int Number of files deleted.
	 */
	public function clear($limit = 0, $start = 0, $expiredOnly = false)
	{
		$seek = $processed = $deleted = 0;

		// fetch directories first to lower memory usage
		$cacheDirs = glob($this->cacheDir . '/*/*', GLOB_ONLYDIR);
		foreach ($cacheDirs as $dir) {
			$cacheFiles = glob($dir . '/*');
			foreach ($cacheFiles as $file) {
				if ($seek < $start) {
					$seek++;
					continue;
				}

				$wasDeleted = false;
				if ($expiredOnly) {
					if (is_readable($file)) {
						$fp = fopen($file, 'r');
						$key = fgets($fp);
						$expiry = (int) trim(fgets($fp));
						if (is_numeric($expiry) && time() > $expiry) {
							$wasDeleted = $this->deleteFile($file);
						}
					}
				} else {
					$wasDeleted = $this->deleteFile($file);
				}

				if ($wasDeleted) {
					$deleted++;
				}

				$processed++;
				if ($processed >= $limit) {
					break 2;
				}
			}
		}

		// return how many files were deleted - caller can figure out how many to skip next time
		return $deleted;
	}

	/**
	 * Whether caching is available.
	 *
	 * @return bool
	 */
	public function isEnabled()
	{
		return $this->enabled;
	}

	/**
	 * Get the last error.
	 *
	 * @return string
	 */
	public function getError()
	{
		return $this->error;
	}

	/**
	 * Get current statistics for the cache.
	 *
	 * @return array Array of stats: 'files' => number of files, 'size' => total file size in bytes.
	 */
	public function getStats()
	{
		if (!$this->enabled) {
			return array('files' => 0, 'size' => 0);
		}

		$totalFiles = 0;
		$totalBytes = 0;
		$dirIter = new RecursiveDirectoryIterator($this->cacheDir);
		foreach (new RecursiveIteratorIterator($dirIter) as $file) {
			if (strpos($file->getFilename(), '.') === 0) {
				// TODO: use FilesystemIterator::SKIP_DOTS once we're on minimum PHP 5.3
				continue;
			}

			$totalFiles++;
			$totalBytes += $file->getSize();
		}

		return array(
			'files' => $totalFiles,
			'size' => $totalBytes,
		);
	}

	/**
	 * Delete a specific file
	 * @param string $file Filename to delete.
	 *
	 * @return bool Whether the file was deleted successfully.
	 */
	private function deleteFile($file)
	{
		if (is_writable($file)) {
			return @unlink($file) === true;
		}

		return false;
	}

	/**
	 * Generates filename for cache key, of the form `1/23/123abc`
	 * @param string $key The unique cache key.
	 *
	 * @return string
	 */
	private function getFilename($key)
	{
		$filename = sha1($key);
		return $this->cacheDir . '/' . substr($filename, 0, 1) . '/' . substr($filename, 1, 2) . '/' . $filename;
	}
}