<?php
/**
 * Jingga
 *
 * PHP Version 8.2
 *
 * @package   phpOMS\DataStorage\Cache\Connection
 * @copyright Dennis Eichhorn
 * @license   OMS License 2.2
 * @version   1.0.0
 * @link      https://jingga.app
 */
declare(strict_types=1);

namespace phpOMS\DataStorage\Cache\Connection;

use phpOMS\DataStorage\Cache\CacheStatus;
use phpOMS\DataStorage\Cache\CacheType;
use phpOMS\DataStorage\Cache\Exception\InvalidConnectionConfigException;
use phpOMS\Stdlib\Base\Exception\InvalidEnumValue;

/**
 * RedisCache class.
 *
 * @package phpOMS\DataStorage\Cache\Connection
 * @license OMS License 2.2
 * @link    https://jingga.app
 * @since   1.0.0
 */
final class RedisCache extends ConnectionAbstract
{
    /**
     * {@inheritdoc}
     */
    protected string $type = CacheType::REDIS;

    /**
     * Delimiter for cache meta data
     *
     * @var string
     * @since 1.0.0
     */
    private const DELIM = '$';

    /**
     * Constructor
     *
     * @param array{db:int, host:string, port:int} $data Cache data
     *
     * @since 1.0.0
     */
    public function __construct(array $data)
    {
        $this->con = new \Redis();
        $this->connect($data);
    }

    /**
     * Connect to cache
     *
     * @param null|array{db:int, host:string, port:int} $data Cache data
     *
     * @return void
     *
     * @throws InvalidConnectionConfigException
     *
     * @since 1.0.0
     */
    public function connect(?array $data = null) : void
    {
        $this->dbdata = isset($data) ? $data : $this->dbdata;

        if (!isset($this->dbdata['host'], $this->dbdata['port'], $this->dbdata['db'])) {
            $this->status = CacheStatus::FAILURE;
            throw new InvalidConnectionConfigException((string) \json_encode($this->dbdata));
        }

        $this->con->connect($this->dbdata['host'], $this->dbdata['port']);

        try {
            $this->con->ping();
        } catch (\Throwable $_) {
            $this->status = CacheStatus::FAILURE;
            return;
        }

        $this->con->setOption(\Redis::OPT_SERIALIZER, (string) \Redis::SERIALIZER_NONE);
        $this->con->setOption(\Redis::OPT_SCAN, (string) \Redis::SCAN_NORETRY);
        $this->con->select($this->dbdata['db']);

        $this->status = CacheStatus::OK;
    }

    /**
     * {@inheritdoc}
     */
    public function close() : void
    {
        if ($this->con !== null) {
            $this->con->close();
        }

        parent::close();
    }

    /**
     * {@inheritdoc}
     */
    public function set(int | string $key, mixed $value, int $expire = -1) : void
    {
        if ($this->status !== CacheStatus::OK) {
            return;
        }

        if ($expire > 0) {
            $this->con->set((string) $key, $this->build($value), $expire);

            return;
        }

        $this->con->set((string) $key, $this->build($value));
    }

    /**
     * {@inheritdoc}
     */
    public function add(int | string $key, mixed $value, int $expire = -1) : bool
    {
        if ($this->status !== CacheStatus::OK) {
            return false;
        }

        if ($expire > 0) {
            return $this->con->set((string) $key, $this->build($value), ['nx', 'ex' => $expire]);
        }

        return $this->con->set((string) $key, $this->build($value), ['nx']);
    }

    /**
     * {@inheritdoc}
     */
    public function get(int | string $key, int $expire = -1) : mixed
    {
        if ($this->status !== CacheStatus::OK || $this->con->exists((string) $key) < 1) {
            return null;
        }

        $result = $this->con->get((string) $key);

        if (\is_string($result) && ($result[0] ?? null) === self::DELIM) {
            $result = \substr($result, 1);
            $type   = (int) $result[0];
            $start  = (int) \strpos($result, self::DELIM);
            $result = $this->reverseValue($type, $result, $start);
        }

        return $result;
    }

    /**
     * {@inheritdoc}
     */
    public function delete(int | string $key, int $expire = -1) : bool
    {
        if ($this->status !== CacheStatus::OK) {
            return false;
        }

        $this->con->del((string) $key);

        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function exists(int | string $key, int $expire = -1) : bool
    {
        if ($this->status !== CacheStatus::OK) {
            return false;
        }

        return $this->con->exists((string) $key) > 0;
    }

    /**
     * {@inheritdoc}
     */
    public function increment(int | string $key, int $value = 1) : bool
    {
        if ($this->status !== CacheStatus::OK
            || !$this->exists((string) $key)
        ) {
            return false;
        }

        $this->con->incrBy((string) $key, $value);

        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function decrement(int | string $key, int $value = 1) : bool
    {
        if ($this->status !== CacheStatus::OK
            || !$this->exists((string) $key)
        ) {
            return false;
        }

        $this->con->decrBy((string) $key, $value);

        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function rename(int | string $old, int | string $new, int $expire = -1) : bool
    {
        if ($this->status !== CacheStatus::OK) {
            return false;
        }

        $this->con->rename((string) $old, (string) $new);

        if ($expire > 0) {
            $this->con->expire((string) $new, $expire);
        }

        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function getLike(string $pattern, int $expire = -1) : array
    {
        if ($this->status !== CacheStatus::OK) {
            return [];
        }

        $keys   = $this->con->keys('*');
        $values = [];

        foreach ($keys as $key) {
            if (\preg_match('/' . $pattern . '/', $key) !== 1) {
                continue;
            }

            $result = $this->con->get((string) $key);
            if (\is_string($result) && ($result[0] ?? null) === self::DELIM) {
                $result = \substr($result, 1);
                $type   = (int) $result[0];
                $start  = (int) \strpos($result, self::DELIM);
                $result = $this->reverseValue($type, $result, $start);
            }

            $values[] = $result;
        }

        return $values;
    }

    /**
     * {@inheritdoc}
     */
    public function deleteLike(string $pattern, int $expire = -1) : bool
    {
        if ($this->status !== CacheStatus::OK) {
            return false;
        }

        $keys = $this->con->keys('*');
        foreach ($keys as $key) {
            if (\preg_match('/' . $pattern . '/', $key) === 1) {
                $this->con->del($key);
            }
        }

        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function updateExpire(int | string $key, int $expire = -1) : bool
    {
        if ($this->status !== CacheStatus::OK) {
            return false;
        }

        if ($expire > 0) {
            $this->con->expire((string) $key, $expire);
        }

        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function flush(int $expire = 0) : bool
    {
        return $this->status === CacheStatus::OK;
    }

    /**
     * {@inheritdoc}
     */
    public function flushAll() : bool
    {
        if ($this->status !== CacheStatus::OK) {
            return false;
        }

        $this->con->flushDb();

        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function replace(int | string $key, mixed $value, int $expire = -1) : bool
    {
        if ($this->status !== CacheStatus::OK) {
            return false;
        }

        if ($this->con->exists((string) $key) > 0) {
            $this->set((string) $key, $value, $expire);

            return true;
        }

        return false;
    }

    /**
     * {@inheritdoc}
     */
    public function stats() : array
    {
        if ($this->status !== CacheStatus::OK) {
            return [];
        }

        $info = $this->con->info();

        $stats           = [];
        $stats['status'] = $this->status;
        $stats['count']  = $this->con->dbSize();
        $stats['size']   = $info['used_memory'];

        return $stats;
    }

    /**
     * {@inheritdoc}
     */
    public function getThreshold() : int
    {
        return 0;
    }

    /**
     * Destructor.
     *
     * @since 1.0.0
     */
    public function __destruct()
    {
        $this->close();
    }

    /**
     * Build value
     *
     * @param mixed $value Data to cache
     *
     * @return mixed
     *
     * @since 1.0.0
     */
    private function build(mixed $value) : mixed
    {
        $type = $this->dataType($value);
        $raw  = $this->cachify($value, $type);

        return \is_string($raw)
            && ($type !== CacheValueType::_INT && $type !== CacheValueType::_FLOAT)
            ? self::DELIM . $type . self::DELIM . $raw
            : $raw;
    }

    /**
     * Create string representation of data for storage
     *
     * @param mixed $value Value of the data
     * @param int   $type  Type of the cache data
     *
     * @return mixed
     *
     * @throws InvalidEnumValue This exception is thrown if an unsupported cache value type is used
     *
     * @since 1.0.0
     */
    private function cachify(mixed $value, int $type) : mixed
    {
        if ($type === CacheValueType::_INT || $type === CacheValueType::_STRING || $type === CacheValueType::_BOOL) {
            return (string) $value;
        } elseif ($type === CacheValueType::_FLOAT) {
            return \rtrim(\rtrim(\number_format($value, 5, '.', ''), '0'), '.');
        } elseif ($type === CacheValueType::_ARRAY) {
            return (string) \json_encode($value);
        } elseif ($type === CacheValueType::_SERIALIZABLE) {
            return \get_class($value) . self::DELIM . $value->serialize();
        } elseif ($type === CacheValueType::_JSONSERIALIZABLE) {
            return \get_class($value) . self::DELIM . ((string) \json_encode($value->jsonSerialize()));
        } elseif ($type === CacheValueType::_NULL) {
            return '';
        }

        throw new InvalidEnumValue($type);
    }

    /**
     * Parse cached value
     *
     * @param int   $type  Cached value type
     * @param mixed $raw   Cached value
     * @param int   $start Value start position
     *
     * @return mixed
     *
     * @since 1.0.0
     */
    private function reverseValue(int $type, mixed $raw, int $start) : mixed
    {
        switch ($type) {
            case CacheValueType::_INT:
                return (int) \substr($raw, $start + 1);
            case CacheValueType::_FLOAT:
                return (float) \substr($raw, $start + 1);
            case CacheValueType::_BOOL:
                return (bool) \substr($raw, $start + 1);
            case CacheValueType::_STRING:
                return \substr($raw, $start + 1);
            case CacheValueType::_ARRAY:
                $array = \substr($raw, $start + 1);
                return \json_decode($array === false ? '[]' : $array, true);
            case CacheValueType::_NULL:
                return null;
            case CacheValueType::_JSONSERIALIZABLE:
                $namespaceStart = (int) \strpos($raw, self::DELIM, $start);
                $namespaceEnd   = (int) \strpos($raw, self::DELIM, $namespaceStart + 1);
                $namespace      = \substr($raw, $namespaceStart + 1, $namespaceEnd - $namespaceStart - 1);

                if ($namespace === false) {
                    return null; // @codeCoverageIgnore
                }

                return new $namespace();
            case CacheValueType::_SERIALIZABLE:
                $namespaceStart = (int) \strpos($raw, self::DELIM, $start);
                $namespaceEnd   = (int) \strpos($raw, self::DELIM, $namespaceStart + 1);
                $namespace      = \substr($raw, $namespaceStart + 1, $namespaceEnd - $namespaceStart - 1);

                if ($namespace === false) {
                    return null; // @codeCoverageIgnore
                }

                $obj = new $namespace();
                $obj->unserialize(\substr($raw, $namespaceEnd + 1));

                return $obj;
            default:
                return null; // @codeCoverageIgnore
        }
    }
}
