<?php

declare(strict_types=1);

use Redis;

/**
 * @psalm-type RedisConfig array{
 *   host:string,
 *   port?:int,
 *   connectTimeout?:float,
 *   retryInterval?:int,
 *   readTimeout?:float,
 *   persistent?:string,
 *   auth:mixed,
 *   ssl?:array,
 *   prefix:string,
 *   lifetime:int,
 *   lockWait?:int,
 *   lockRetries?:int,
 *   lockTimeout?:int,
 * }
 */
class Awardit_Magento_Session_Redis implements SessionHandlerInterface, SessionUpdateTimestampHandlerInterface, SessionIdInterface {
    const LOG_CHANNEL = "redis_session";
    const LOCK_RETRIES = 5;
    // 128 bits
    const ID_LENGTH = 16;

    private array $redisConfig;
    private int $lockTimeout; // ms
    private int $lockRetries;
    /**
     * @var positive-int
     */
    private int $lockWait; // us
    private int $lifetime; // s
    private string $prefix;
    private ?Redis $redis = null;
    /**
     * @var Array<string, string>
     */
    private array $prefetch = [];
    private ?string $sessionName = null;
    /**
     * Aquired locks and their secrets for active sessions
     *
     * @var Array<string, string>
     */
    private array $locks = [];

    /**
     * @param RedisConfig $config
     */
    public function __construct(
        array $config
    ) {
        $this->redisConfig = array_diff_key($config, [
            "prefix" => true,
            "lifetime" => true,
            "lockWait" => true,
            "lockRetries" => true,
            "lockTimeout" => true,
        ]);

        $this->prefix = $config["prefix"];
        $this->lockTimeout = $config["lockTimeout"] ?? 10000; // ms
        $this->lockRetries = $config["lockRetries"] ?? 5; // ms
        $this->lifetime = $config["lifetime"] ?? 600;
        $this->lockWait = array_key_exists("lockWait", $config) && $config["lockWait"] > 0 ? $config["lockWait"] : 10000; // us

        // Ensure we release all locks
        register_shutdown_function([$this, "releaseLocks"]);
    }

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

        return $this->redis;
    }

    // TODO: Move to general function
    // Replacement since we want cryptographically secure and not dependent on client (eg. IP, time)
    private function generateId(): string {
        return base64_encode(random_bytes(self::ID_LENGTH));
    }

    private function createLockSecret(): string {
        return base64_encode(random_bytes(self::ID_LENGTH));
    }

    private function getLockKey(string $name): string {
        return "lock:".$name;
    }

    // TODO: Do we really need this complex lock release mechanism? (it ensures
    // nobody fucks with our lock)
    private function releaseLock(string $name): void {
        if( ! array_key_exists($name, $this->locks)) {
            Mage::log("Attempted to release lock we do not have", Zend_Log::WARN, self::LOG_CHANNEL);

            return;
        }

        $redis = $this->getRedis();
        $lockname = $this->getLockKey($name);

        // Make sure nobody else modifies the lock while we try to delete it
        $redis->watch($lockname);

        if($redis->get($lockname) === $this->locks[$lockname]) {
            // Our lock
            $redis->multi();
            $redis->del($lockname);
            // If this is not false nobody modified it
            if( ! $redis->exec()) {
                Mage::log("Failed to release lock, lock modified during release", Zend_Log::WARN, self::LOG_CHANNEL);
            }
        }
        else {
            // Someone else did something with it during the time we had the lock
            Mage::log("Failed to release lock, lock secret modified", Zend_Log::WARN, self::LOG_CHANNEL);
        }

        // In any case, we release and delete the locks secret since we no
        // longer have it
        $redis->unwatch();
        unset($this->locks[$name]);
    }

    public function open(string $path, string $name): bool {
        Mage::log(__METHOD__, Zend_Log::DEBUG, self::LOG_CHANNEL);

        // This should lock if we do not have one already? What is the PHP documentation on about?
        $this->sessionName = $name;

        return true;
    }

    public function create_sid(): string {
        Mage::log(__METHOD__, Zend_Log::DEBUG, self::LOG_CHANNEL);

        $redis = $this->getRedis();
        $retries = $this->lockRetries;
        $secret = $this->createLockSecret();

        while($retries-- > 0) {
            $id = $this->generateId();
            $lockName = $this->getLockKey($id);

            // Transaction to create the key if it does not already exist
            /**
             * Psalm has bad typing for Redis
             * @psalm-suppress InvalidArgument
             */
            $redis->watch([$id, $lockName]);
            $redis->multi();
            // Create lock key if it does not exist
            $redis->set($lockName, $secret, ["nx", "px" => $this->lockTimeout]);
            // Create session key if it does not exist, will return false if it exists
            $redis->set($id, "", ["nx", "ex" => $this->lifetime]);

            // Commit
            /**
             * @var false|array{0:bool, 1:bool}
             */
            $res = $redis->exec();
            $redis->unwatch();

            if( ! $res) {
                // We failed to obtain the lock, or we failed to create the key,
                // due to someone else modifying one of them first
                // The lock should not have been created in this case, so just retry
                continue;
            }

            if( ! $res[0]) {
                // We failed to create the lock, retry even if we managed to create the key
                continue;
            }

            // We at least have a lock
            $this->locks[$id] = $secret;

            if($res[1] !== false) {
                // We managed to create the key since it did not exist,
                // keep lock and return
                return $id;
            }

            // Key exists, release lock and retry
            $this->releaseLock($id);
        }

        throw new Exception(sprintf(
            "%s: Failed to obtain unique session id",
            __METHOD__
        ));
    }

    public function validateId(string $id): bool {
        Mage::log(__METHOD__, Zend_Log::DEBUG, self::LOG_CHANNEL);

        $lockName = $this->getLockKey($id);
        $redis = $this->getRedis();
        $retries = $this->lockRetries;
        $secret = $this->createLockSecret();

        while($retries-- > 0) {
            // Transaction for key and lock
            /**
             * Psalm has bad typing for Redis
             * @psalm-suppress InvalidArgument
             */
            $redis->watch([$id, $lockName]);
            $redis->multi();
            // Create lock key if it does not exist
            $redis->set($lockName, $secret, ["nx", "px" => $this->lockTimeout]);
            // Check if we already have a key
            $redis->get($id);

            // Commit
            /**
             * @var false|array{0:bool, 1:false|string}
             */
            $res = $redis->exec();
            $redis->unwatch();

            if($res) {
                $this->locks[$id] = $secret;

                if($res[1] !== false) {
                    $this->prefetch[$id] = $res[1];

                    // Keep the lock
                    return true;
                }
                else {
                    $this->releaseLock($id);

                    return false;
                }
            }

            // Transaction failed due to modifications, retry
            usleep($this->lockWait);
        }

        throw new Exception(sprintf(
            "%s: Failed to obtain lock on session",
            __METHOD__
        ));
    }

    public function read(string $id): string|false {
        Mage::log(__METHOD__, Zend_Log::DEBUG, self::LOG_CHANNEL);

        assert(array_key_exists($id, $this->locks));

        if(array_key_exists($id, $this->prefetch)) {
            $data = $this->prefetch[$id];

            unset($this->prefetch[$id]);

            return $data;
        }

        $redis = $this->getRedis();

        return Awardit_Magento_Redis::uncompress($redis->get($id) ?: "");
    }

    public function write(string $id, string $data): bool {
        Mage::log(__METHOD__, Zend_Log::DEBUG, self::LOG_CHANNEL);

        assert(array_key_exists($id, $this->locks));

        return $this->getRedis()->set($id, Awardit_Magento_Redis::compress($data), ["ex" => $this->lifetime]);
    }

    public function updateTimestamp(string $id, string $data): bool {
        Mage::log(__METHOD__, Zend_Log::DEBUG, self::LOG_CHANNEL);

        assert(array_key_exists($id, $this->locks));

        return $this->getRedis()->expire($id, $this->lifetime);
    }

    public function close(): bool {
        Mage::log(__METHOD__, Zend_Log::DEBUG, self::LOG_CHANNEL);

        // Should we really release ALL locks here?
        $this->releaseLocks();

        return true;
    }

    public function destroy(string $id): bool {
        Mage::log(__METHOD__, Zend_Log::DEBUG, self::LOG_CHANNEL);

        assert(array_key_exists($id, $this->locks));

        $redis = $this->getRedis();

        // TODO: Maybe pipeline?
        $redis->del($id);
        $this->releaseLock($id);

        return true;
    }

    public function gc(int $max_lifetime): int|false {
        // Redis clears its own
        return false;
    }

    public function releaseLocks(): void {
        Mage::log(__METHOD__, Zend_Log::DEBUG, self::LOG_CHANNEL);

        foreach(array_keys($this->locks) as $id) {
            $this->releaseLock($id);
        }
    }
}
