<?php

declare(strict_types=1);

use Psr\Log\LoggerInterface;

/**
 * @psalm-type RedisConfig array{
 *   host:string,
 *   port?:int,
 *   connectTimeout?:float,
 *   retryInterval?:int,
 *   readTimeout?:float,
 *   persistent?:string,
 *   auth:mixed,
 *   ssl?:array,
 *   prefix:string,
 *   lifetime:int,
 * }
 */
class Awardit_Magento_Cache_Redis implements Zend_Cache_Backend_Interface {
    const CONFIG_TYPES = [
        "port" => "int",
        "connectTimeout" => "float",
        "retryInterval" => "int",
        "readTimeout" => "float",
        "lifetime" => "int",
    ];
    const LOG_CHANNEL = "redis_cache";
    /**
     * Script used to load data from the cache.
     *
     * It is run atomically, so it can refresh the expiration of cache entries
     * while reading from them.
     *
     * Arguments:
     *  * Key to read
     *  * Key containing expiration time for the key (does not have to exist)
     *  * Default expiration time in seconds
     *
     * Usage:
     *
     * ```
     * $redis->eval(self::LUA_LOAD, [$id, ttlKey($id), 600], 2);
     * ```
     */
    const LUA_LOAD = <<<'LUA'
local val = redis.call("get", KEYS[1])
local ttl = redis.call("get", KEYS[2])

redis.call("expire", KEYS[1], ttl or ARGV[1])
redis.call("expire", KEYS[2], ttl or ARGV[1])

return val
LUA;

    private LoggerInterface $log;
    private ?string $luaLoadSha = null;
    private array $redisConfig;
    private string $prefix;
    private int $lifetime; // s
    private ?Redis $redis = null;

    /**
     * @param RedisConfig $config
     */
    public function __construct(
        array $config
    ) {
        $config = Awardit_Magento_Redis::castConfigOptions($config, self::CONFIG_TYPES);

        $this->log = new Awardit_Magento_Logger(self::LOG_CHANNEL);
        $this->redisConfig = array_diff_key($config, [
            "prefix" => true,
            "lifetime" => true,
        ]);

        $this->prefix = $config["prefix"];
        $this->lifetime = $config["lifetime"] ?? 600;
    }

    private function getRedis(): Redis {
        if( ! $this->redis) {
            $this->redis = Awardit_Magento_Redis::connect($this->redisConfig, $this->prefix);
        }

        return $this->redis;
    }

    /**
     * Set collection containing all keys having the given tag.
     */
    private function getTagSetKey(string $tag): string {
        return "tag:".$tag;
    }

    private function getTtlKey(string $key): string {
        return "ttl:".$key;
    }

    private function luaCompileLoad(Redis $redis): string {
        /** @var string|false Always string if no error */
        $sha = $redis->script("load", self::LUA_LOAD);
        $err = $redis->getLastError();

        if($err) {
            $redis->clearLastError();

            throw new RuntimeException(sprintf(
                "%s: Failed to load Redis Lua load script: %s",
                __METHOD__,
                $err
            ));
        }

        assert($sha !== false);

        $this->luaLoadSha = $sha;

        return $sha;
    }

    /**
     * Test if a cache is available for the given id and (if yes) return it (false else)
     *
     * Note : return value is always "string" (unserialization is done by the core not by the backend)
     *
     * @param string $id Cache id
     * @param boolean $doNotTestCacheValidity If set to true, the cache validity won't be tested
     * @return string|false cached datas
     */
    public function load($id, $doNotTestCacheValidity = false) {
        $redis = $this->getRedis();
        // These actually get automatically prefixed
        $args = [$id, $this->getTtlKey($id), $this->lifetime];
        $sha = $this->luaLoadSha ?: $this->luaCompileLoad($redis);
        /**
         * @var false|string
         */
        $res = $redis->evalSha($sha, $args, 2);
        $err = $redis->getLastError();

        if($err) {
            $redis->clearLastError();

            if( ! str_contains($err, "NOSCRIPT")) {
                $this->log->error($err);

                return false;
            }

            // Script might have been evicted
            $sha = $this->luaCompileLoad($redis);
            /**
             * @var false|string
             */
            $res = $redis->evalSha($sha, $args, 2);
            $err = $redis->getLastError();

            if($err) {
                $redis->clearLastError();

                $this->log->error("Failed to execute Redis Lua load script after recompilation: ".$err);
            }
        }


        return $res ? Awardit_Magento_Redis::uncompress($res) : false;
    }

    /**
     * Test if a cache is available or not (for the given id)
     *
     * @param string $id cache id
     * @return mixed|false (a cache is not available) or "last modified" timestamp (int) of the available cache record
     */
    public function test($id) {
        // Emulate last modified by calculating from TTL
        $redis = $this->getRedis();

        $redis->pipeline();
        $redis->multi();
        $redis->ttl($id);
        $redis->get($this->getTtlKey($id));

        // Exec the multi inside the pipeline, then run the pipeline, which
        // means we get a nested result since the first exec is queued inside
        // the pipeline:
        $redis->exec();
        /** @var false|array{0:false|array{0:-1|positive-int, 1:false|int}} */
        $res = $redis->exec();

        if( ! $res || ! $res[0]) {
            $this->log->error("Failed to test cache key '$id'");

            return false;
        }

        $ttl = $res[0][0] ?? 0;
        $lifetime = $res[0][1] ?: $this->lifetime;

        return $ttl > 0 ? time() + $lifetime - $ttl : false;
    }

    /**
     * Save some string datas into a cache record
     *
     * Note : $data is always "string" (serialization is done by the
     * core not by the backend)
     *
     * @param string $data Datas to cache
     * @param string $id Cache id
     * @param array $tags Array of strings, the cache record will be tagged by each string entry
     * @param int|false $specificLifetime If != false, set a specific lifetime for this cache record (null => infinite lifetime)
     * @return boolean true if no problem
     */
    public function save($data, $id, $tags = array(), $specificLifetime = false) {
        /** @var list<string> $tags */
        $redis = $this->getRedis();
        // We do not allow infinite lifetimes
        $lifetime = $specificLifetime ?: $this->lifetime;
        $ttlKey = $specificLifetime ? $this->getTtlKey($id) : null;

        assert($lifetime > 0);

        $redis->pipeline();
        $redis->multi();
        $redis->set($id, Awardit_Magento_Redis::compress($data), ["ex" => $lifetime]);

        if($ttlKey) {
            // Save the lifetime so we can refresh it when reading
            $redis->set($ttlKey, $lifetime, ["ex" => $lifetime]);
        }

        foreach($tags as $tag) {
            $key = $this->getTagSetKey($tag);

            $redis->sAdd($key, $id);

            if($ttlKey) {
                $redis->sAdd($key, $ttlKey);
            }
        }

        // Exec the multi inside the pipeline, then run the pipeline, which
        // means we get a nested result since the first exec is queued inside
        // the pipeline:
        $redis->exec();
        /** @var array{0:false|array{0:bool}}|false */
        $res = $redis->exec();

        return $res && $res[0] && $res[0][0];
    }

    /**
     * Remove a cache record
     *
     * @param  string $id Cache id
     * @return boolean True if no problem
     */
    public function remove($id) {
        $redis = $this->getRedis();

        $redis->del($id);

        return true;
    }

    /**
     * Clean some cache records
     *
     * Available modes are :
     * Zend_Cache::CLEANING_MODE_ALL (default)    => remove all cache entries ($tags is not used)
     * Zend_Cache::CLEANING_MODE_OLD              => remove too old cache entries ($tags is not used)
     * Zend_Cache::CLEANING_MODE_MATCHING_TAG     => remove cache entries matching all given tags
     *                                               ($tags can be an array of strings or a single string)
     * Zend_Cache::CLEANING_MODE_NOT_MATCHING_TAG => remove cache entries not {matching one of the given tags}
     *                                               ($tags can be an array of strings or a single string)
     * Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG => remove cache entries matching any given tags
     *                                               ($tags can be an array of strings or a single string)
     *
     * @param string $mode Clean mode
     * @param array|string $tags Array of tags
     * @return boolean true if no problem
     */
    public function clean($mode = Zend_Cache::CLEANING_MODE_ALL, $tags = array()) {
        // TODO: Do we need to remove the keys from sets?
        // Note: We do not pipeline operations here since we do not want to
        // block the redis write-thread
        $redis = $this->getRedis();
        /** @var list<string> */
        $tags = is_array($tags) ? $tags : [$tags];

        switch($mode) {
        case Zend_Cache::CLEANING_MODE_ALL:
            $it = null;

            // All our keys are prefixed with the "prefix:" and the scan setting
            // SCAN_PREFIX will ensure we do not get other keys:
            while($keys = $redis->scan($it, "*")) {
                $redis->del(...$keys);
            }

            break;
        case Zend_Cache::CLEANING_MODE_OLD:
            // Nothing to do, our memory is automatically evicted

            break;
        case Zend_Cache::CLEANING_MODE_MATCHING_TAG:
        case Zend_Cache::CLEANING_MODE_NOT_MATCHING_TAG:
            $tagSets = array_map([$this, "getTagSetKey"], $tags);
            /** @var list<string> */
            $matching = $redis->sInter(...$tagSets);

            if($mode === Zend_Cache::CLEANING_MODE_MATCHING_TAG) {
                // We have all the tags already
                if(count($matching) > 0) {
                    $redis->del(...$matching);
                }
            }
            else {
                $it = null;

                // All our keys are prefixed with the "prefix:" and the scan
                // setting SCAN_PREFIX will ensure we do not get other keys:
                while($keys = $redis->scan($it, "*")) {
                    $notMatching = array_diff($keys, $matching);

                    if(count($notMatching) > 0) {
                        $redis->del(...$notMatching);
                    }
                }
            }

            break;
        case Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG:
            $tagSets = array_map([$this, "getTagSetKey"], $tags);
            $keys = $redis->sUnion(...$tagSets);

            if(count($keys) > 0) {
                $redis->del(...$keys);
            }
        }

        return true;
    }

    /**
     * @param array $directives
     * @return void
     */
    public function setDirectives($directives) {

    }
}
