Cache.php

<?php

namespace Lia\Addon;


/**
 *
 * @todo integrate with liaison server to read() from cache & write() to cache before/after request. OR integrate with startup() & shutdown() hooks (which don't currently exist)
 * @todo integrate with liaison server to run deletion of stale cache files
 * @todo write tests for liaison integration
 */
class Cache extends \Lia\Addon {

    public string $fqn = 'lia:server.cache';

    /**
     * The dir to write cache files to
     */
    public $dir;
    public bool $enabled = true;

    /**
     * Number of minutes between stale cache checks
     */
    public int $stale_check_frequency = 60*24;

    /**
     * The main file to write to & read from
     */
    public $main = 'main.php';

    /**
     * Key=>values that get written to disk in a single file (call $cache->write())
     */
    public array $props = [];

    public function __construct($package=null, $name=null){
        parent::__construct($package,$name);
        // $lia = $this->lia;

        // $this->dir = &$this->props['dir'];
        // $this->main = &$this->props['main'];

        // $this->props['enabled'] = $this->props['enabled'] ?? $this->enabled;
        // $this->enabled = &$this->props['enabled'];

    }

    public function init_lia(){
        $lia = $this->lia;
        $lia->methods['cache'] = [$this, 'set'];
        $lia->methods['cache_get'] = [$this, 'get'];
        $lia->methods['cache_write'] = [$this, 'write'];
        $lia->methods['cache_read'] = [$this, 'read'];
        $lia->methods['cache_file'] = [$this, 'cache_file'];
        $lia->methods['cache_get_file'] = [$this, 'get_cache_file_content'];
    }

    /**
     * @return absolute path to the main key=>value file
     */
    public function main_file_path(){
        return $this->dir.'/'.$this->main;
    }

    /**
     * reads main key=>value pairs from disk into memory
     */
    public function read(){
        $this->props = include($this->main_file_path());
    }
    /**
     * Write main key=>value pairs to disk from memory
     */
    public function write(){
        file_put_contents($this->main_file_path(), '<?php return '.var_export($this->props,true).';');
    }


    // public function onPackageReady(){
    //     parent::onPackageReady();
    //     if ($this->lia->hasApi('lia:config.default')){
    //         $this->lia->api('lia:config.default', 'lia:cache.dir', $this->package->dir('cache'));
    //         // delete stuff every 5 days
    //         $this->lia->api('lia:config.default', 'lia:cache.clearAfterXMinutes', 60*24*5);
    //         $this->lia->api('lia:config.default', 'lia:cache.enable', true);
    //     }
    // }
    //
    // public function onEmitResponseSent(){
    //     if ($this->isStaleCacheCheckRequired()){
    //         $this->deleteStaleCacheFiles();
    //     }
    // }

    /**
     * Check if: cache is enabled, cached file exists, cached file is NOT expired
     * @param $key the key identifying the file
     * @return boolean true or false (valid or not valid)
     */
    public function is_cache_file_valid($key){
        if (!$this->enabled)return false;
        $file = $this->cache_file_path($key);
        $meta = $this->cache_meta_file_path($key);
        if (!file_exists($meta)
            ||!file_exists($file))return false;
        $conf = json_decode(file_get_contents($meta),true);
        if (time()>$conf['expiry'])return false;
        return true;
    }
    /**
     * Get content of a cached file, if cache is valid
     * @param $key the key identifying the file
     * @return string content of file or false if file not cached
     *
     * @see is_cache_file_valid()
     */
    public function get_cache_file_content($key){
        $file = $this->get_cache_file_path($key);
        if ($file==false)return false;
        return file_get_contents($file);
    }
    /**
     * Get path to cached file if cache is valid
     * @param $key the key identifying the file
     * @return string|boolean path to cached file (if cache valid) or false
     *
     * @see is_cache_file_valid()
     * @todo auto-delete stale cache files (depending on a config)
     */
    public function get_cache_file_path($key){
        if ($this->is_cache_file_valid($key))return $this->cache_file_path($key);
        return false;
    }


    /**
     * Get the path for a cached file
     *
     * @param $key the key identifying the file
     * @return absolute file path for the key or false if dir is not set. 
     *
     * @note Does not check if cache is valid
     * @see is_cache_file_valid()
     * @see get_cache_file_path() for a safe version that checks cache state
     */
    public function cache_file_path($key){
        if ($this->dir==null){
            // throw new \Exception("Set server.cache.dir in order to use cache. You must do this before creating the server instance ... for some reason ...");
            return false;
        }
        return $this->dir.'/'.$key.'.file';
    }
    /**
     * get the path to a meta file, which contains expiration time of cached files
     * @note Does not check if cache is enabled, existence of file or meta file, or expiration of file
     * @param $key the key identifying the file/meta file
     * @return absolute file path (to the meta file) for the key or false if dir is not set. 
     */
    public function cache_meta_file_path($key){
        if ($this->dir==null)return false;
        return $this->dir.'/'.$key.'.meta';
    }
    /**
     * Store $value to file by identified by $key
     * @param $key the key identifying the file
     * @param $value the value to store
     * @param $maxAge number of seconds before the value expires
     * @return path to the file or false if cache is disabled
     * @note This implementation is likely inefficient (not benchmarked)
     * @note Creates the cache directory if it does not exist. This feature may be removed in the future
     *
     * @usage(Delete a file) `cache_file('some.key', $any_value, -1);`: the -1 expires the cache.
     */
    public function cache_file($key, $value, $maxAge=60*60*24*5){
        $file = $this->cache_file_path($key);
        if ($file==false || !$this->enabled)return;
        $meta_file = $this->cache_meta_file_path($key);

        $dir = dirname($file);
        $this->create_dir($dir);

        file_put_contents($file,$value);
        $expiry = time() + $maxAge;
        $meta = [
            'expiry'=>$expiry,
        ];
        file_put_contents($meta_file,json_encode($meta));
        return $file;
    }

    /**
     * Delete stale cache files IF a stale cache check is required
     * @see is_stale_cache_check_required()
     * @return boolean true if it runs, false otherwise
     */
    public function delete_stale_files(){
    
        if ($this->is_stale_cache_check_required()){
            $this->run_delete_stale_files();
            return true;
        }
        return false;
    }
    
    /**
     * Delete stale cache files (unconditional)
     * @see delete_stale_files() for conditional deletion (bc you don't want to do this every request)
     * @todo implement faster method for extracting expiry from meta files, such as using substring with fixed lengths or using a different format altogether
     */
    public function run_delete_stale_files(){
        $dir = $this->dir;
        
        // using opendir() over scandir() to save RAM, in case cache dir gets a HUGE list of files
        // just safer this way
        $dh = opendir($dir);
        $openLen = strlen('{"expiry":');
        $endLen = -1 * strlen('}');
        $now = time();
        while (($f = readdir($dh)) !== false){
            if ($f=='.'||$f=='..')continue;
            $tail = substr($f,-5);
            if ($tail == '.meta')continue;
            $meta_file = substr($f,0,-5).'.meta'; // remove '.file' & put '.meta'
            $expiry = json_decode(file_get_contents($dir.'/'.$meta_file),true)['expiry']??0;
            if ($expiry>$now)continue;
            unlink($dir.'/'.$meta_file);
            unlink($dir.'/'.$f);
        }
        closedir($dh);
    }

    /** 
     * Deletes ALL cache files, whether expired or not
     */
    public function delete_all_cache_files(){
        $dir = $this->dir;
        
        // using opendir() over scandir() to save RAM, in case cache dir gets a HUGE list of files
        // just safer this way
        if (!is_dir($dir))return;
        $dh = opendir($dir);
        while ($dh!==false && ($f = readdir($dh)) !== false){
            if ($f=='.'||$f=='..')continue;
            unlink($dir.'/'.$f);
        }
        closedir($dh);
    }

    /**
     * check if stale cache check is required
     * @see $stale_check_frequency
     */
    public function is_stale_cache_check_required(){
        $frequency = $this->stale_check_frequency;
        $seconds = $frequency * 60;
        $last_check = $this->get('cache.last_stale_check', 0);
        if ($last_check + $seconds > time())return false;
        return true;
    }
    
    // protected function isStaleCacheCheckRequired(){
    //     // get cache-clearing frequency (lia config)
    //     $frequencyInMinutes = $this->lia->api('lia:config.get', 'lia:cache.clearAfterXMinutes');
    //     $seconds = $frequencyInMinutes*60;
    //     // check when last cache-clear was performed
    //     $path = $this->lia->getCachedFilePath('lia.Cache.LastCleanupCheck');
    //     if ($path==null||!is_file($path)){
    //         $lastClear = 0;
    //     } else if (is_file($path)){
    //         $content = file_get_contents($path);
    //         $content = trim($content);
    //         $lastClear = (int)$content;
    //     } else {
    //         throw new \Lia\Exception("Could not load cache frequency file.");
    //     }
    //     // if enough time has elapsed, clear the cache
    //     if ($lastClear + $seconds > time())return false;
    //     return true;
    // }
    protected function deleteStaleCacheFiles(){
        $this->lia->cacheFile('lia.Cache.LastCleanupCheck', time(), 60*60*24*180);
        // $dir = $this->lia->api('lia:config.get', 'lia:cache.dir');
        $dir = $this->dir;
        if (!is_dir($dir))throw new \Lia\Exception("The cache directory '{$dir}' does not exist, thus cannot be checked for stale files.");
        
        $dh = opendir($dir);
        $openLen = strlen('{"expiry":');
        $endLen = -1 * strlen('}');
        $now = time();
        while (($f = readdir($dh)) !== false){
            if (substr($f,0,5)!='meta-')continue;
            $content = trim(file_get_contents($dir.'/'.$f));
            $expiresAt = substr($content, $openLen, $endLen);
            if ($expiresAt>$now)continue;
            $actualFile = $dir.'/file-'.substr($f, 5);
            if (is_file($actualFile))unlink($actualFile);
            //delete the meta file
            unlink($dir.'/'.$f);
        }
        closedir($dh);
    }

    /**
     * Creates directory if it does not exist. 
     * @param $dir string directory path
     *
     * @error possible recursive while loop if there is no directory in the path given ... maybe with windows paths like is_dir('C:') ... Or if is_dir('/') in linux/mac returns false for some reason
     */
    public function create_dir($dir){
        if (is_dir($dir))return;
        $parent = $dir;

        // get the permissions of the parent directory
        do {
            $parent = dirname($parent);
        } while (!is_dir($parent));
        $perms = fileperms($parent);
        mkdir($dir, $perms, true);
    }



    public function set($key, $value){
        $this->props[$key] = $value;
    }

    /** 
     * @return false if key not found, or the cached value */
    public function get($key){
        return $this->props[$key] ?? false;
    }

    public function __set($key,$value){
        $this->props[$key] = $value;
    }
    public function __get($key){
        return $this->props[$key];
    }
}