<?php 
/**
 * Pimcore
 *
 * This source file is subject to the GNU General Public License version 3 (GPLv3)
 * For the full copyright and license information, please view the LICENSE.md and gpl-3.0.txt
 * files that are distributed with this source code.
 *
 * @copyright  Copyright (c) 2009-2016 pimcore GmbH (http://www.pimcore.org)
 * @license    http://www.pimcore.org/license     GNU General Public License version 3 (GPLv3)
 */

namespace Pimcore;

use Pimcore\Model\Element;
use Pimcore\Model\Document;

class Cache {

    /**
     * Instance of the used cache-implementation
     * @var \Zend_Cache_Core|\Zend_Cache_Frontend
     */
    public static $instance;

    /**
     * @var bool
     */
    protected  static $enabled = true;

    /**
     * @var null
     */
    public static $defaultLifetime = 2419200; // 28 days

    /**
     * Contains the items which should be written to the cache on shutdown. They are ordered respecting the priority
     * @var array
     */
    public static $saveStack = array();

    /**
     * Contains the Logger, this is necessary because otherwise logging doesn't work in shutdown (missing reference)
     * @var Logger
     */
    public static $logger;

    /**
     * Contains the tags which were already cleared
     * @var array
     */
    public static $clearedTagsStack = array();

    /**
     * Items having tags which are in this array are cleared on shutdown \Pimcore::shutdown(); This is especially for the output-cache
     * @var array
     */
    protected static $_clearTagsOnShutdown = array();

    /**
     * How many items should stored to the cache within one process
     * @var int
     */
    public static $maxWriteToCacheItems = 50;

    /**
     * prefix which will be added to every item-key
     * @var string
     */
    public static $cachePrefix = "pimcore_";

    /**
     * items having one of the tags in this store are not cleared when calling self::clearTags() or self::clearTag()
     * @var array
     */
    public static $ignoredTagsOnClear = array();

    /**
     * if set to truq items are directly written into the cache, and do not get into the queue
     * @var bool
     */
    protected static $forceImmediateWrite = false;

    /**
     * contains the timestamp of the writeLockTime from the current process
     * this is to recheck when removing the write lock (if the value is different -> higher) do not remove the lock
     * because then another process has acquired a lock
     * @var int
     */
    protected static $writeLockTimestamp;

    /**
     * @var \Zend_Cache_Core
     */
    protected static $blackHoleCache = null;
    
    /**
     * Returns a instance of the cache, if the instance isn't available it creates a new one
     *
     * @return \Zend_Cache_Core|\Zend_Cache_Frontend
     */
    public static function getInstance() {

        if (!self::$instance instanceof \Zend_Cache_Core) {
            self::init();
        }

        if (!empty($_REQUEST["nocache"])) {
            self::disable();
        }

        return self::$instance;
    }

    /**
     *
     */
    public static function init() {

        if (!self::$instance instanceof \Zend_Cache_Core) {
            // check for custom cache configuration
            $customCacheFile = PIMCORE_CONFIGURATION_DIRECTORY . "/cache.xml";
            if (is_file($customCacheFile)) {
                $config = self::getDefaultConfig();
                try {
                    $conf = new \Zend_Config_Xml($customCacheFile);

                    if ($conf->frontend) {
                        $config["frontendType"] = (string) $conf->frontend->type;
                        $config["customFrontendNaming"] = (bool) $conf->frontend->custom;
                        if ($conf->frontend->options && method_exists($conf->frontend->options,"toArray")) {
                            $config["frontendConfig"] = $conf->frontend->options->toArray();
                        }
                    }

                    if ($conf->backend) {
                        $config["backendType"] = (string) $conf->backend->type;
                        $config["customBackendNaming"] = (bool) $conf->backend->custom;
                        if ($conf->backend->options && method_exists($conf->backend->options,"toArray")) {
                            $config["backendConfig"] = $conf->backend->options->toArray();
                        }
                    }

                    if(isset($config["frontendConfig"]["lifetime"])) {
                        self::$defaultLifetime = $config["frontendConfig"]["lifetime"];
                    }

                    $config = self::normalizeConfig($config);

                    // here you can use the cache backend you like
                    try {
                        self::$instance = self::initializeCache($config);
                    } catch (\Exception $e) {
                        \Logger::crit("can't initialize cache with the given configuration " . $e->getMessage());
                    }

                } catch (\Exception $e) {
                    \Logger::crit($e);
                    \Logger::crit("Error while reading cache configuration, using the default file backend");
                }
            }
        }

        // return default cache if cache cannot be initialized
        if (!self::$instance instanceof \Zend_Cache_Core) {
            self::$instance = self::getDefaultCache();
        }

        self::$instance->setLifetime(self::$defaultLifetime);
        self::$instance->setOption("automatic_serialization", true);
        self::$instance->setOption("automatic_cleaning_factor", 0);

        // init the write lock once (from other processes etc.)
        if(self::$writeLockTimestamp === null) {
            self::$writeLockTimestamp = 0; // set the write lock to 0, otherwise infinite loop (self::hasWriteLock() calls self::getInstance())
            self::hasWriteLock();
        }

        self::setZendFrameworkCaches(self::$instance);
    }

    /**
     * @param $config
     * @return mixed
     */
    protected static function normalizeConfig($config) {

        foreach ($config as $key => &$value) {
            if($value === "true") {
                $value = true;
            }
            if($value === "false") {
                $value = false;
            }

            if(is_array($value)) {
                $value = self::normalizeConfig($value);
            }
        }

        return $config;
    }

    /**
     * @param $config
     * @return \Zend_Cache_Core|\Zend_Cache_Frontend
     */
    public static function initializeCache ($config) {
        $cache = \Zend_Cache::factory($config["frontendType"], $config["backendType"], $config["frontendConfig"], $config["backendConfig"], $config["customFrontendNaming"], $config["customBackendNaming"], true);
        return $cache;
    }

    /**
     * @param string|null $adapter
     * @return array
     */
    public static function getDefaultConfig($adapter = null) {
        $config =  array(
            "frontendType" => "Core",
            "frontendConfig" => array(
                "lifetime" => self::$defaultLifetime,
                "automatic_serialization" => true,
                "automatic_cleaning_factor" => 0
            ),
            "customFrontendNaming" => true,
            "backendType" => "\\Pimcore\\Cache\\Backend\\MysqlTable",
            "backendConfig" => array(),
            "customBackendNaming" => true
        );

        if($adapter) {
            $config["backendType"] = $adapter;
        }

        return $config;
    }

    /**
     * @return \Zend_Cache_Core|\Zend_Cache_Frontend
     */
    public static function getDefaultCache () {
        if(\Pimcore\Config::getSystemConfig()) {
            // default mysql cache adapter
            $config = self::getDefaultConfig();
            $cache = self::initializeCache($config);
        } else {
            $cache = self::getBlackHoleCache();
        }

        return $cache;
    }

    /**
     * @return \Zend_Cache_Core|\Zend_Cache_Frontend
     */
    public static function getBlackHoleCache() {
        if(!self::$blackHoleCache) {
            $config = self::getDefaultConfig();
            $config["backendType"] = "\\Zend_Cache_Backend_BlackHole";
            self::$blackHoleCache = self::initializeCache($config);
        }
        return self::$blackHoleCache;
    }
    
    /**
     * Returns the content of the requested cache entry
     * @param string $key
     * @return mixed
     */
    public static function load($key, $doNotTestCacheValidity = false) {
        
        if (!self::$enabled) {
            \Logger::debug("Key " . $key . " doesn't exist in cache (deactivated)");
            return;
        }

        if($cache = self::getInstance()) {

            $key = self::$cachePrefix . $key;
            $data = $cache->load($key, $doNotTestCacheValidity);

            if(is_object($data)) {
                $data->____pimcore_cache_item__ = $key;
            }
    
            if ($data !== false) {
                \Logger::debug("Successfully got data for key " . $key . " from cache");
            } else {
                \Logger::debug("Key " . $key . " doesn't exist in cache");
            }
    
            return $data;
        }
        return false;
    }

    /**
     * Get the last modified time for the requested cache entry
     *
     * @param  string $key Cache key
     * @return int|bool Last modified time of cache entry if it is available, false otherwise
     */
    public static function test ($key) {
        if (!self::$enabled) {
            \Logger::debug("Key " . $key . " doesn't exist in cache (deactivated)");
            return;
        }

        $lastModified = false;

        if($cache = self::getInstance()) {
            $key = self::$cachePrefix . $key;
            $data = $cache->test($key);

            if ($data !== false) {
                $lastModified = $data;
            } else {
                \Logger::debug("Key " . $key . " doesn't exist in cache");
            }
        }

        return $lastModified;
    }

    /**
     * Puts content into the cache
     * @param mixed $data
     * @param string $key
     * @return void
     */
    public static function save($data, $key, $tags = array(), $lifetime = null, $priority = 0, $force = false) {
        if(self::getForceImmediateWrite() || $force) {

            if(self::hasWriteLock()) {
                return;
            }

            return self::storeToCache($data, $key, $tags, $lifetime, $priority, $force);
        } else {
            self::addToSaveStack(array($data, $key, $tags, $lifetime, $priority, $force));
        }
    }

    /**
     * Write's an item to the cache // don't use the logger inside here
     * @param $data
     * @param $key
     * @param array $tags
     * @param null $lifetime
     * @param null $priority
     * @param bool $force
     * @return bool|void
     */
    public static function storeToCache ($data, $key, $tags = array(), $lifetime = null, $priority = null, $force = false) {
        if (!self::$enabled) {
            return;
        }

        // don't put anything into the cache, when cache is cleared
        if(in_array("__CLEAR_ALL__",self::$clearedTagsStack) && !$force) {
            return;
        }

        // do not cache hardlink-wrappers
        if($data instanceof Document\Hardlink\Wrapper\WrapperInterface) {
            return;
        }

        // $priority is currently just for sorting the items in self::addToSaveStack()
        // maybe it will be added to prioritize items for backends with volatile memories

        // get cache instance
        if($cache = self::getInstance()) {

            //if ($lifetime !== null) {
            //    $cache->setLifetime($lifetime);
            //}

            if ($data instanceof Element\ElementInterface) {
                // check for currupt data
                if ($data->getId() < 1) {
                    return;
                }

                if(isset($data->_fulldump)) {
                    unset($data->_fulldump);
                }

                // get dependencies for this element
                $tags = $data->getCacheTags($tags);
                $type = get_class($data);

                \Logger::debug("prepared " . $type . " " . $data->getId() . " for data cache with tags: " . implode(",", $tags));
            }

            // check for cleared tags, only item which are not cleared within the same session are stored to the cache
            if(is_array($tags)){
                foreach ($tags as $t) {
                    if(in_array($t,self::$clearedTagsStack)) {
                        \Logger::debug("Aborted caching for key: " . $key . " because it is in the clear stack");
                        return;
                    }
                }
            } else {
                $tags = array();
            }

            // always add the key as tag
            $tags[] = $key;

            // array_values() because the tags from \Element_Interface and some others are associative eg. array("object_123" => "object_123")
            $tags = array_values($tags);

            if(is_object($data) && isset($data->____pimcore_cache_item__)) {
                unset($data->____pimcore_cache_item__);
            }

            $key = self::$cachePrefix . $key;

            if($lifetime === null) {
                $lifetime = false; // set to false otherwise the lifetime stays at null (\Zend_Cache_Backend::getLifetime())
            }

            $success = $cache->save($data, $key, $tags, $lifetime);
            if($success !== true) {
                \Logger::error("Failed to add entry $key to the cache, item-size was " . formatBytes(strlen(serialize($data))));
            }

            \Logger::debug("Added " . $key . " to cache");

            return $success;
        }
    }

    /**
     * Put the cache item info into the stack
     * array_unshift because the output cache has priority so the 1st item added to the stack will be for sure in the cache
     *
     * @param array $config
     * @return void
     */
    public static function addToSaveStack ($config) {
        $priority = $config[4];
        $i=0;

        //saveStack is sorted - just find the correct position for the new item
        foreach(self::$saveStack as $entry) {
            if($entry[4] <= $priority) {
                //we got the position!
                break;
            } else {
                $i++;
            }
        }
        //add new item at the correct position
        array_splice(self::$saveStack, $i, 0, array($config));

        // remove items which are too much, and cannot be added to the cache anymore
        array_splice(self::$saveStack,self::$maxWriteToCacheItems);
    }

    /**
     *
     */
    public function clearSaveStack() {
        self::$saveStack = array();
    }
    
    /**
     * Write the stack to the cache
     *
     * @return void
     */
    public static function write () {

        if(self::hasWriteLock()) {
            return;
        }

        $processedKeys = array();
        $count = 0;
        foreach (self::$saveStack as $conf) {

            if(in_array($conf[1],$processedKeys)) {
                continue;
            }

            try {
                forward_static_call_array(array(__CLASS__, "storeToCache"),$conf);
            } catch (\Exception $e) {
                \Logger::error("Unable to put element " . $conf[1] . " to cache because of the following reason: ");
                \Logger::error($e);
            }

            $processedKeys[] = $conf[1]; // index 1 is the key for the cache item

            // only add $maxWriteToCacheItems items att once to the cache for performance issues
            $count++;
            if($count > self::$maxWriteToCacheItems) {
                break;
            }
        }

        // reset
        self::$saveStack = array();
    }


    /**
     *
     */
    public static function setWriteLock ($force = false) {
        if(!self::$writeLockTimestamp || $force) {
            self::$writeLockTimestamp = time();
            if($cache = self::getInstance()) {
                $cache->save(self::$writeLockTimestamp, "system_cache_write_lock", array(), 30);
            }
        }
    }

    /**
     *
     */
    public static function removeWriteLock () {
        if(self::$writeLockTimestamp) {
            if($cache = self::getInstance()) {
                $lock = $cache->load("system_cache_write_lock");

                // only remove the lock if it was created by this process
                if($lock <= self::$writeLockTimestamp) {
                    $cache->remove("system_cache_write_lock");
                    self::$writeLockTimestamp = null;
                }
            }
        }
    }

    /**
     * @return bool
     */
    public static function hasWriteLock() {

        if(self::$writeLockTimestamp) {
            return true;
        }

        if($cache = self::getInstance()) {
            $lock = $cache->load("system_cache_write_lock");

            // lock is valid for 30 secs
            if($lock && $lock > (time()-30)) {
                self::$writeLockTimestamp = $lock;
                return true;
            } else {
                self::$writeLockTimestamp = 0;
            }
        }

        return false;
    }

    /**
     * @param $key
     */
    public static function remove($key) {

        // do not disable clearing, it's better purging items here than having inconsistent data because of wrong usage
        /*if (!self::$enabled) {
            \Logger::debug("Cache is not cleared because it is disabled");
            return;
        }*/

        self::setWriteLock();

        $key = self::$cachePrefix . $key;
        if($cache = self::getInstance()) {
            $cache->remove($key);
        }
    }

    /**
     * Empty the cache
     *
     * @return void
     */
    public static function clearAll() {

        // do not disable clearing, it's better purging items here than having inconsistent data because of wrong usage
        /*if (!self::$enabled) {
            \Logger::debug("Cache is not cleared because it is disabled");
            return;
        }*/

        self::setWriteLock();

        if($cache = self::getInstance()) {
            $cache->clean(\Zend_Cache::CLEANING_MODE_ALL);
        }
        
        // add tag to clear stack
        self::$clearedTagsStack[] = "__CLEAR_ALL__";

        // immediately acquire the write lock again (force), because the lock is in the cache too
        self::setWriteLock(true);
    }

    /**
     * Removes entries from the cache matching the given tag
     *
     * @param string $tag
     * @return void
     */
    public static function clearTag($tag) {
        self::clearTags(array($tag));
    }
    
    /**
     * Removes entries from the cache matching the given tags
     *
     * @param array $tags
     * @return void
     */
    public static function clearTags($tags = array()) {

        // do not disable clearing, it's better purging items here than having inconsistent data because of wrong usage
        /*if (!self::$enabled) {
            \Logger::debug("Cache is not cleared because it is disabled");
            return;
        }*/

        self::setWriteLock();

        \Logger::info("clear cache tags: " . implode(",",$tags));

        // ensure that every tag is unique
        $tags = array_unique($tags);

        // check for ignored tags
        foreach (self::$ignoredTagsOnClear as $t) {
            $tagPosition = array_search($t, $tags);
            if($tagPosition !== false) {
                array_splice($tags, $tagPosition, 1);
            }
        }

        // check for the tag output, because items with this tags are only cleared after the process is finished
        // the reason is that eg. long running importers will clean the output-cache on every save/update, that's not necessary,
        // only cleaning the output-cache on shutdown should be enough 
        $outputTagPosition = array_search("output", $tags);
        if($outputTagPosition !== false) {
            array_splice($tags, $outputTagPosition, 1);
             self::addClearTagOnShutdown("output");
        }

        // add tag to clear stack
        foreach ($tags as $tag) {
            self::$clearedTagsStack[] = $tag;
        }

        // clean tags, except output
        if($cache = self::getInstance()) {
            $cache->clean(
                \Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG,
                $tags
            );
        }
    }

    /**
     * Adds a tag to the shutdown queue, see clearTagsOnShutdown
     * @static
     * @param $tag
     * @return void
     */
    public static function addClearTagOnShutdown($tag) {
        self::setWriteLock();

        self::$_clearTagsOnShutdown[] = $tag;
    }


    /**
     * Clears all tags stored in self::$_clearTagsOnShutdown, this function is executed in \Pimcore::shutdown()
     * @static
     * @return void
     */
    public static function clearTagsOnShutdown() {

        // do not disable clearing, it's better purging items here than having inconsistent data because of wrong usage
        /*if (!self::$enabled) {
            \Logger::debug("Cache is not cleared because it is disabled");
            return;
        }*/

        if(!empty(self::$_clearTagsOnShutdown)) {
            $tags = array_unique(self::$_clearTagsOnShutdown);
            if($cache = self::getInstance()) {
                $cache->clean(
                    \Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG,
                    $tags
                );
            }
        }
    }

    /**
     * @static
     * @param $tag
     * @return void
     */
    public static function addIgnoredTagOnClear($tag) {
        if(!in_array($tag, self::$ignoredTagsOnClear)) {
            self::$ignoredTagsOnClear[] = $tag;
        }
    }

    /**
     * @static
     * @param $tag
     * @return void
     */
    public static function removeIgnoredTagOnClear($tag) {
        $tagPosition = array_search($tag, self::$ignoredTagsOnClear);
        if($tagPosition !== false) {
            array_splice(self::$ignoredTagsOnClear, $tagPosition, 1);
        }
    }

    /**
     * @param string $tag
     */
    public static function addClearedTag($tag) {
        self::$clearedTagsStack[] = $tag;
    }

    /**
     * Disables the complete pimcore cache
     * @static
     * @return void
     */
    public static function disable() {
        if(self::$enabled) {
            self::setZendFrameworkCaches(self::getBlackHoleCache());
        }
        self::$enabled = false;
    }

    /**
     * @static
     * @return void
     */
    public static function enable() {
        self::$enabled = true;
        self::setZendFrameworkCaches(self::getInstance());
    }


    /**
     * @param null $cache
     */
    public static function setZendFrameworkCaches ($cache = null) {
        \Zend_Locale::setCache($cache);
        \Zend_Locale_Data::setCache($cache);
        \Zend_Db_Table_Abstract::setDefaultMetadataCache($cache);
    }

    /**
     * @param boolean $forceImmediateWrite
     */
    public static function setForceImmediateWrite($forceImmediateWrite)
    {
        self::$forceImmediateWrite = $forceImmediateWrite;
    }

    /**
     * @return boolean
     */
    public static function getForceImmediateWrite()
    {
        return self::$forceImmediateWrite;
    }

    public static function maintenance() {
        $cache = self::getInstance();
        $cache->clean(\Zend_Cache::CLEANING_MODE_OLD);
    }
}
