<?php

namespace Gregwar\Cache;

/**
 * A cache system based on files
 *
 * @author Gregwar <g.passault@gmail.com>
 */
class Cache implements CacheInterface
{
    /**
     * Cache directory
     * @var string
     */
    protected $cacheDirectory;

    /**
     * Use a different directory as actual cache
     * @var string|null
     */
    protected $actualCacheDirectory;

    /**
     * Prefix directories size
     *
     * For instance, if the file is helloworld.txt and the prefix size is
     * 5, the cache file will be: h/e/l/l/o/helloworld.txt
     *
     * This is useful to avoid reaching a too large number of files into the
     * cache system directories
     * @var int
     */
    protected $prefixSize = 5;

    /**
     * Directory mode
     *
     * Allows setting of the access mode for the directories created.
     * @var int
     */
    protected $directoryMode = 0755;

    /**
     * Constructs the cache system
     *
     * @param string $cacheDirectory the cache directory
     */
    public function __construct($cacheDirectory = 'cache')
    {
        $this->cacheDirectory = $cacheDirectory;
    }

    /**
     * Sets the cache directory
     *
     * @param string $cacheDirectory the cache directory
     * @return self
     */
    public function setCacheDirectory($cacheDirectory)
    {
        $this->cacheDirectory = $cacheDirectory;

        return $this;
    }

    /**
     * Gets the cache directory
     *
     * @return string the cache directory
     */
    public function getCacheDirectory()
    {
        return $this->cacheDirectory;
    }

    /**
     * Sets the actual cache directory
     *
     * @param string|null $actualCacheDirectory the actual cache directory
     * @return self
     */
    public function setActualCacheDirectory($actualCacheDirectory = null)
    {
        $this->actualCacheDirectory = $actualCacheDirectory;

        return $this;
    }

    /**
     * Returns the actual cache directory
     */
    public function getActualCacheDirectory()
    {
        return $this->actualCacheDirectory ?: $this->cacheDirectory;
    }

    /**
     * Change the prefix size
     *
     * @param int $prefixSize the size of the prefix directories
     * @return self
     */
    public function setPrefixSize($prefixSize)
    {
        $this->prefixSize = $prefixSize;

        return $this;
    }

    /**
     * Change the directory mode
     *
     * @param int $directoryMode the directory mode to use
     * @return self
     */
    public function setDirectoryMode($directoryMode)
    {
        if (!$directoryMode) {
            $directoryMode = 0755;
        }
        $this->directoryMode = $directoryMode;

        return $this;
    }

    /**
     * Creates a directory
     *
     * @param string $directory the target directory
     */
    protected function mkdir($directory)
    {
        if (!is_dir($directory)) {
            @mkdir($directory, $this->directoryMode, true);
        }
    }

    /**
     * Gets the cache file name
     *
     * @param string $filename the name of the cache file
     * @param bool $actual get the actual file or the public file
     * @param bool $mkdir a boolean to enable/disable the construction of the
     *        cache file directory
     * @return string
     */
    public function getCacheFile($filename, $actual = false, $mkdir = false)
    {
        $path = array();

        // Getting the length of the filename before the extension
        $parts = explode('.', $filename);
        $len = strlen($parts[0]);

        for ($i=0; $i<min($len, $this->prefixSize); $i++) {
            $path[] = $filename[$i];

        }
        $path = implode('/', $path);

        if ($mkdir) {
            $actualDir = $this->getActualCacheDirectory() . '/' . $path;
            $this->mkdir($actualDir);
        }

        $path .= '/' . $filename;

        if ($actual) {
            return $this->getActualCacheDirectory() . '/' . $path;
        } else {
            return $this->getCacheDirectory() . '/' . $path;
        }
    }

    /**
     * Checks that the cache conditions are respected
     *
     * @param string $cacheFile the cache file
     * @param array $conditions an array of conditions to check
     * @return bool
     * @throws \Exception
     */
    protected function checkConditions($cacheFile, array $conditions = array())
    {
        // Implicit condition: the cache file should exist
        if (!file_exists($cacheFile)) {
            return false;
        }

        foreach ($conditions as $type => $value) {
            switch ($type) {
            case 'maxage':
            case 'max-age':
                // Return false if the file is older than $value
                $age = time() - filemtime($cacheFile);
                if ($age > $value) {
                    return false;
                }
                break;
            case 'younger-than':
            case 'youngerthan':
                // Return false if the file is older than the file $value, or the files $value
                $check = function($filename) use ($cacheFile) {
                    return !file_exists($filename) || filemtime($cacheFile) < filemtime($filename);
                };

                if (!is_array($value)) {
                    if (!$this->isRemote($value) && $check($value)) {
                        return false;
                    }
                } else {
                    foreach ($value as $file) {
                        if (!$this->isRemote($file) && $check($file)) {
                            return false;
                        }
                    }
                }
                break;
            default:
                throw new \Exception('Cache condition '.$type.' not supported');
            }
        }

        return true;
    }

    /**
     * Checks if the target filename exists in the cache and if the conditions
     * are respected
     *
     * @param string $filename the filename
     * @param array $conditions the conditions to respect
     * @return bool
     */
    public function exists($filename, array $conditions = array())
    {
        $cacheFile = $this->getCacheFile($filename, true);

        return $this->checkConditions($cacheFile, $conditions);
    }

    /**
     * Alias for exists
     *
     * @param string $filename the filename
     * @param array $conditions the conditions to respect
     * @return bool
     */
    public function check($filename, array $conditions = array())
    {
        return $this->exists($filename, $conditions);
    }

    /**
     * Write data in the cache
     *
     * @param string $filename the name of the cache file
     * @param string $contents the contents to store
     * @return self
     */
    public function set($filename, $contents = '')
    {
        $cacheFile = $this->getCacheFile($filename, true, true);

        file_put_contents($cacheFile, $contents, \LOCK_EX);

        return $this;
    }

    /**
     * Alias for set()
     *
     * @param string $filename the name of the cache file
     * @param string $contents the contents to store
     * @return self
     */
    public function write($filename, $contents = '')
    {
        return $this->set($filename, $contents);
    }

    /**
     * Get data from the cache
     *
     * @param string $filename the cache file name
     * @param array $conditions
     * @return null|string
     */
    public function get($filename, array $conditions = array())
    {
        if ($this->exists($filename, $conditions)) {
            return file_get_contents($this->getCacheFile($filename, true));
        } else {
            return null;
        }
    }

    /**
     * Is this URL remote?
     *
     * @param string $file
     * @return bool
     */
    protected function isRemote($file)
    {
        if (preg_match('/^([a-z]+):\/\//', $file, $match)) {
            return ($match[1] != 'file');
        }

        return false;
    }

    /**
     * Get or create the cache entry
     *
     * @param string $filename the cache file name
     * @param array $conditions an array of conditions about expiration
     * @param \Closure $function the closure to call if the file does not exist
     * @param bool $file returns the cache file or the file contents
     * @param bool $actual returns the actual cache file
     * @return string
     * @throws \InvalidArgumentException
     */
    public function getOrCreate($filename, array $conditions, $function, $file = false, $actual = false)
    {
        if (!is_callable($function)) {
            throw new \InvalidArgumentException('The argument $function should be callable');
        }

        $cacheFile = $this->getCacheFile($filename, true, true);
        $data = null;

        if (!$this->check($filename, $conditions)) {
            if(file_exists($cacheFile)) {
                unlink($cacheFile);
            }

            $data = call_user_func($function, $cacheFile);

            // Test if the closure wrote the file or if it returned the data
            if (!file_exists($cacheFile)) {
                $this->set($filename, $data);
            } else {
                $data = file_get_contents($cacheFile);
            }
        }

        return $file ? $this->getCacheFile($filename, $actual) : file_get_contents($cacheFile);
    }

    /**
     * Alias to getOrCreate with $file = true
     *
     * @param string $filename the cache file name
     * @param array $conditions an array of conditions about expiration
     * @param \Closure $function the closure to call if the file does not exist
     * @param bool $actual returns the actual cache file
     * @return string
     * @throws \InvalidArgumentException
     */
    public function getOrCreateFile($filename, array $conditions, $function, $actual = false)
    {
        return $this->getOrCreate($filename, $conditions, $function, true, $actual);
    }
}
