<?php
/**
 * Perforce Swarm
 *
 * @copyright   2013 Perforce Software. All rights reserved.
 * @license     Please see LICENSE.txt in top-level folder of this distribution.
 * @version     <release>/<patch>
 */
namespace Record\Cache;

use P4\Model\Connected\ConnectedAbstract as Connected;

/**
 * A basic filesystem cache with multi-server invalidation.
 * The interface is inspired by Zend\Cache, but way simpler.
 *
 * One unique aspect of this cache is that it uses Perforce counters to provide
 * cache validation. Every cache entry can have a corresponding counter. Anytime
 * we read or write a cache entry to a file, we include the counter value for
 * the entry in the filename. Anytime we need to invalidate the cache for a
 * given entry, we increment the corresponding counter value. This ensures that
 * the next read will produce a cache-miss.
 *
 * The cache counters are preferable to local clearing as they will invalidate
 * caches across web-servers in a multi-server environment. To minimize their
 * expense, we only fetch the cache counters once per-instance. The counters can
 * be forcibly refreshed by calling reset().
 *
 * When an item is read from the cache, we hang onto it in memory. This costs
 * memory, but saves time when the same item is read repeatedly. Repeated reads
 * are a common case at the time of this writing. The in-memory cache can be
 * cleared by calling reset().
 */
class Cache extends Connected
{
    const   COUNTER_PREFIX  = 'swarm-cache-';

    protected $items        = array();
    protected $counters     = null;
    protected $cacheDir     = null;

    /**
     * Get an item from the cache.
     * Note: when getting cached objects, clone them before you modify them.
     * We hang onto an in-memory copy of cached items for performance.
     *
     * @param   string      $key        the item identifier
     * @param   bool        $success    true if successful, false otherwise
     * @return  mixed       data on success, null on failure
     * @throws  Exception   if cache directory is not set
     */
    public function getItem($key, &$success = null)
    {
        $file    = $this->getFile($key);
        $index   = $this->encodeKey($key);
        $success = null;

        // check for in-memory (already fetched) item
        if (array_key_exists($index, $this->items)) {
            $success = true;
            return $this->items[$index];
        }

        if (!is_readable($file)) {
            $success = false;
            return null;
        }

        // unserialize and hang onto item for next time
        $item = unserialize(file_get_contents($file));
        $this->items[$index] = $item;

        if ($item === false) {
            $success = false;
            return null;
        }

        $success = true;
        return $item;
    }

    /**
     * Store an item in the cache.
     *
     * @param   string      $key    the item identifier
     * @param   mixed       $value  the item value to store
     * @return  Cache       provides fluent interface
     * @throws  Exception   if unable to write to cache
     */
    public function setItem($key, $value)
    {
        $dir   = $this->getCacheDir();
        $file  = $this->getFile($key);
        $index = $this->encodeKey($key);

        // ensure cache dir exists and is writable
        static::ensureWritable($dir);

        if (is_file($file) && !is_writable($file)) {
            @chmod($file, 0700);
            if (!is_writable($file)) {
                throw new Exception(
                    "Cannot write to cache file ('" . $file . "'). Check permissions."
                );
            }
        }

        file_put_contents($file, serialize($value));

        // update in-memory copy
        $this->items[$index] = $value;

        return $this;
    }

    /**
     * Invalidate cache of the given key (across web-servers).
     * This is accomplished by incrementing the validation number.
     *
     * @param   string  $key    the cache key to invalidate
     * @return  Cache   provides fluent interface
     */
    public function invalidateItem($key)
    {
        $index   = $this->encodeKey($key);
        $counter = static::COUNTER_PREFIX . $index;
        $result  = $this->getConnection()->run(
            'counter',
            array('-u', '-i', $counter)
        );

        // clear in-memory copy of item
        unset($this->items[$index]);

        // if we have cached counters, update the affected one
        if (is_array($this->counters)) {
            $this->counters[$counter] = $result->getData(0, 'value');
        }

        return $this;
    }

    /**
     * Invalidate all items in the cache.
     * This is accomplished by incrementing all of the validation numbers.
     *
     * @return  Cache   provides fluent interface
     */
    public function invalidateAll()
    {
        $counters = $this->getCounters();
        foreach ($counters as $key => $value) {
            $result = $this->getConnection()->run(
                'counter',
                array('-u', '-i', $key)
            );
        }

        // clear in-memory items and counters
        $this->items    = array();
        $this->counters = null;

        return $this;
    }

    /**
     * Set the filesystem path to write cache entries to.
     *
     * @param   string  $dir    the file-system path to write to (will be created)
     * @return  Cache   provides fluent interface
     */
    public function setCacheDir($dir)
    {
        $this->cacheDir = rtrim($dir, '/');

        return $this;
    }

    /**
     * Get the filesystem path to write cache entries to.
     *
     * @return  string      the file-system path to write to
     * @throws  Exception   if cache directory is not set
     */
    public function getCacheDir()
    {
        if (!$this->cacheDir) {
            throw new Exception("Cannot get cache directory. Directory is unset.");
        }

        return $this->cacheDir;
    }

    /**
     * Clear the in-memory items and validation numbers.
     * Useful for long-running processes and testing purposes.
     *
     * @return  Cache   provides fluent interface
     */
    public function reset()
    {
        $this->items    = array();
        $this->counters = null;

        return $this;
    }

    /**
     * Clear the in-memory copy of a specific item.
     *
     * @param   string  $key    the cache key to reset
     * @return  Cache   provides fluent interface
     */
    public function resetItem($key)
    {
        unset($this->items[$this->encodeKey($key)]);

        return $this;
    }

    /**
     * Deletes invalidated cache files from the cache directory.
     *
     * @return  Cache       provides fluent interface
     */
    public function removeInvalidatedFiles()
    {
        $prefix = strtoupper(md5($this->getConnection()->getPort()));
        $regex  = '/^' . $prefix . '\-(?P<key>.+)\-(?P<counter>[0-9]+)(\..*)?$/';
        $dir    = $this->getCacheDir();

        static::ensureWritable($dir);
        $handle = opendir($dir);
        while (($file = readdir($handle)) !== false) {
            if (preg_match($regex, $file, $matches)) {
                $count = $this->getCounter($this->decodeKey($matches['key']));
                if (!$count || (int)$matches['counter'] < $count) {
                    // either no counter found, or it is lower than the current one, so remove the file
                    unlink($dir . '/' . $file);
                }
            }
        }
        closedir($handle);

        return $this;
    }

    /**
     * Get the name of the file to read/write a given cache entry.
     * The file is under the cache dir and uses the encoded key and
     * the corresponding cache counter (for invalidation).
     *
     * @param   string  $key    the cache key to get the filename for
     * @return  string  the file to read/write
     */
    public function getFile($key)
    {
        return $this->getCacheDir()
            . '/' . strtoupper(md5($this->getConnection()->getPort()))
            . '-' . $this->encodeKey($key)
            . '-' . $this->getCounter($key);
    }

    /**
     * Throws an exception if $dir is not a directory or is not writable.
     *
     * @param $dir          string  the name of the directory to check
     * @throws Exception    thrown if $dir is not a directory or is not writable
     */
    public static function ensureWritable($dir)
    {
        // ensure cache dir exists and is writable
        if (!is_dir($dir)) {
            @mkdir($dir, 0700, true);
        }
        if (!is_writable($dir)) {
            @chmod($dir, 0700);
        }
        if (!is_dir($dir) || !is_writable($dir)) {
            throw new Exception(
                "Cannot write to cache directory ('" . $dir . "'). Check permissions."
            );
        }
    }

    /**
     * Get the validation counter for the given cache key.
     * If no counter exists, 0 will be returned.
     *
     * @param   string  $key    the cache key to get the counter for
     * @return  int     the cache validation number for the given key
     */
    protected function getCounter($key)
    {
        $counters = $this->getCounters();
        $counter  = static::COUNTER_PREFIX . $this->encodeKey($key);

        return isset($counters[$counter]) ? (int) $counters[$counter] : 0;
    }

    /**
     * Get the cache validation counters. Used to compose cache filenames.
     * The counters are stored in Perforce and read once per-instance or more
     * often if their in-memory copy is cleared.
     *
     * @return  array   the cache validation counters
     */
    protected function getCounters()
    {
        if ($this->counters === null) {
            $result = $this->getConnection()->run(
                'counters',
                array('-u', '-e', static::COUNTER_PREFIX . '*')
            );

            $counters = array();
            foreach ($result->getData() as $counter) {
                $counters[$counter['counter']] = $counter['value'];
            }

            $this->counters = $counters;
        }

        return $this->counters;
    }

    /**
     * Decodes and returns the key from the encoded version.
     *
     * @param   string  $key    encoded cache key to decode
     * @return  string  the decoded cache key
     */
    protected function decodeKey($key)
    {
        return pack('H*', $key);
    }

    /**
     * Make the given key safe for use in filenames and counters
     *
     * @param   string  $key    the cache key to encode
     * @return  string  the encoded cache key
     */
    protected function encodeKey($key)
    {
        return strtoupper(bin2hex($key));
    }
}