<?php

namespace Laminas\Cache\Storage\Adapter;

use Laminas\Cache\Exception;
use Laminas\Stdlib\ArrayUtils;
use Redis as RedisResource;
use ReflectionClass;
use Traversable;

use function array_merge;
use function constant;
use function defined;
use function is_array;
use function is_string;
use function method_exists;
use function parse_url;
use function str_replace;
use function strpos;
use function strtoupper;
use function trim;

/**
 * This is a resource manager for redis
 */
class RedisResourceManager
{
    /**
     * Registered resources
     *
     * @var array
     */
    protected $resources = [];

    /**
     * Check if a resource exists
     *
     * @param string $id
     * @return bool
     */
    public function hasResource($id)
    {
        return isset($this->resources[$id]);
    }

    /**
     * Get redis server version
     *
     * @param string $resourceId
     * @return string
     * @throws Exception\RuntimeException
     */
    public function getVersion($resourceId)
    {
        // check resource id and initialize the resource
        $this->getResource($resourceId);

        return $this->resources[$resourceId]['version'];
    }

    /**
     * Get redis major server version
     *
     * @param string $resourceId
     * @return int
     * @throws Exception\RuntimeException
     */
    public function getMajorVersion($resourceId)
    {
        // check resource id and initialize the resource
        $this->getResource($resourceId);

        return (int) $this->resources[$resourceId]['version'];
    }

    /**
     * Get redis server version
     *
     * @deprecated 2.2.2 Use getMajorVersion instead
     *
     * @param string $id
     * @return int
     * @throws Exception\RuntimeException
     */
    public function getMayorVersion($id)
    {
        return $this->getMajorVersion($id);
    }

    /**
     * Get redis resource database
     *
     * @param string $id
     * @return string
     */
    public function getDatabase($id)
    {
        if (! $this->hasResource($id)) {
            throw new Exception\RuntimeException("No resource with id '{$id}'");
        }

        $resource = &$this->resources[$id];
        return $resource['database'];
    }

    /**
     * Get redis resource password
     *
     * @param string $id
     * @return string
     */
    public function getPassword($id)
    {
        if (! $this->hasResource($id)) {
            throw new Exception\RuntimeException("No resource with id '{$id}'");
        }

        $resource = &$this->resources[$id];
        return $resource['password'];
    }

    /**
     * Gets a redis resource
     *
     * @param string $id
     * @return RedisResource
     * @throws Exception\RuntimeException
     */
    public function getResource($id)
    {
        if (! $this->hasResource($id)) {
            throw new Exception\RuntimeException("No resource with id '{$id}'");
        }

        $resource = &$this->resources[$id];
        if ($resource['resource'] instanceof RedisResource) {
            //in case new server was set then connect
            if (! $resource['initialized']) {
                $this->connect($resource);
            }

            if (! $resource['version']) {
                $info                = $resource['resource']->info();
                $resource['version'] = $info['redis_version'];
            }

            return $resource['resource'];
        }

        $redis = new RedisResource();

        $resource['resource'] = $redis;
        $this->connect($resource);

        $this->normalizeLibOptions($resource['lib_options']);

        foreach ($resource['lib_options'] as $k => $v) {
            $redis->setOption($k, $v);
        }

        $info                             = $redis->info();
        $resource['version']              = $info['redis_version'];
        $this->resources[$id]['resource'] = $redis;
        return $redis;
    }

    /**
     * Get server
     *
     * @param string $id
     * @throws Exception\RuntimeException
     * @return array array('host' => <host>[, 'port' => <port>[, 'timeout' => <timeout>]])
     */
    public function getServer($id)
    {
        if (! $this->hasResource($id)) {
            throw new Exception\RuntimeException("No resource with id '{$id}'");
        }

        $resource = &$this->resources[$id];
        return $resource['server'];
    }

    /**
     * Normalize one server into the following format:
     * array('host' => <host>[, 'port' => <port>[, 'timeout' => <timeout>]])
     *
     * @param string|array $server
     * @throws Exception\InvalidArgumentException
     */
    protected function normalizeServer(&$server)
    {
        $host    = null;
        $port    = null;
        $timeout = 0;

        // convert a single server into an array
        if ($server instanceof Traversable) {
            $server = ArrayUtils::iteratorToArray($server);
        }

        if (is_array($server)) {
            // array(<host>[, <port>[, <timeout>]])
            if (isset($server[0])) {
                $host    = (string) $server[0];
                $port    = isset($server[1]) ? (int) $server[1] : $port;
                $timeout = isset($server[2]) ? (int) $server[2] : $timeout;
            }

            // array('host' => <host>[, 'port' => <port>, ['timeout' => <timeout>]])
            if (! isset($server[0]) && isset($server['host'])) {
                $host    = (string) $server['host'];
                $port    = isset($server['port']) ? (int) $server['port'] : $port;
                $timeout = isset($server['timeout']) ? (int) $server['timeout'] : $timeout;
            }
        } else {
            // parse server from URI host{:?port}
            $server = trim($server);
            if (strpos($server, '/') !== 0) {
                //non unix domain socket connection
                $server = parse_url($server);
            } else {
                $server = ['host' => $server];
            }
            if (! $server) {
                throw new Exception\InvalidArgumentException("Invalid server given");
            }

            $host    = $server['host'];
            $port    = isset($server['port']) ? (int) $server['port'] : $port;
            $timeout = isset($server['timeout']) ? (int) $server['timeout'] : $timeout;
        }

        if (! $host) {
            throw new Exception\InvalidArgumentException('Missing required server host');
        }

        $server = [
            'host'    => $host,
            'port'    => $port,
            'timeout' => $timeout,
        ];
    }

    /**
     * Extract password to be used on connection
     *
     * @param mixed $resource
     * @param mixed $serverUri
     * @return string|null
     */
    protected function extractPassword($resource, $serverUri)
    {
        if (! empty($resource['password'])) {
            return $resource['password'];
        }

        if (! is_string($serverUri)) {
            return;
        }

        // parse server from URI host{:?port}
        $server = trim($serverUri);

        if (strpos($server, '/') === 0) {
            return;
        }

        //non unix domain socket connection
        $server = parse_url($server);

        return $server['pass'] ?? null;
    }

    /**
     * Connects to redis server
     *
     * @param array $resource
     * @return void
     * @throws Exception\RuntimeException
     */
    protected function connect(array &$resource)
    {
        $server = $resource['server'];
        $redis  = $resource['resource'];
        if ($resource['persistent_id'] !== '') {
            //connect or reuse persistent connection
            $success = $redis->pconnect(
                $server['host'],
                $server['port'],
                $server['timeout'],
                $resource['persistent_id']
            );
        } elseif ($server['port']) {
            $success = $redis->connect($server['host'], $server['port'], $server['timeout']);
        } elseif ($server['timeout']) {
            //connect through unix domain socket
            $success = $redis->connect($server['host'], $server['timeout']);
        } else {
            $success = $redis->connect($server['host']);
        }

        if (! $success) {
            throw new Exception\RuntimeException('Could not establish connection with Redis instance');
        }

        $resource['initialized'] = true;
        if ($resource['password']) {
            $redis->auth($resource['password']);
        }
        $redis->select($resource['database']);
    }

    /**
     * Set a resource
     *
     * @param string $id
     * @param array|Traversable|RedisResource $resource
     * @return RedisResourceManager Fluent interface
     */
    public function setResource($id, $resource)
    {
        $id = (string) $id;
        //TODO: how to get back redis connection info from resource?
        $defaults = [
            'persistent_id' => '',
            'lib_options'   => [],
            'server'        => [],
            'password'      => '',
            'database'      => 0,
            'resource'      => null,
            'initialized'   => false,
            'version'       => 0,
        ];
        if (! $resource instanceof RedisResource) {
            if ($resource instanceof Traversable) {
                $resource = ArrayUtils::iteratorToArray($resource);
            } elseif (! is_array($resource)) {
                throw new Exception\InvalidArgumentException(
                    'Resource must be an instance of an array or Traversable'
                );
            }

            $resource = array_merge($defaults, $resource);
            // normalize and validate params
            $this->normalizePersistentId($resource['persistent_id']);

            // #6495 note: order is important here, as `normalizeServer` applies destructive
            // transformations on $resource['server']
            $resource['password'] = $this->extractPassword($resource, $resource['server']);

            $this->normalizeServer($resource['server']);
        } else {
            //there are two ways of determining if redis is already initialized
            //with connect function:
            //1) pinging server
            //2) checking undocumented property socket which is available only
            //after successful connect
            $resource = array_merge(
                $defaults,
                [
                    'resource'    => $resource,
                    'initialized' => isset($resource->socket),
                ]
            );
        }
        $this->resources[$id] = $resource;
        return $this;
    }

    /**
     * Remove a resource
     *
     * @param string $id
     * @return RedisResourceManager Fluent interface
     */
    public function removeResource($id)
    {
        unset($this->resources[$id]);
        return $this;
    }

    /**
     * Set the persistent id
     *
     * @param string $id
     * @param string $persistentId
     * @return RedisResourceManager Fluent interface
     * @throws Exception\RuntimeException
     */
    public function setPersistentId($id, $persistentId)
    {
        if (! $this->hasResource($id)) {
            return $this->setResource($id, [
                'persistent_id' => $persistentId,
            ]);
        }

        $resource = &$this->resources[$id];
        if ($resource['resource'] instanceof RedisResource && $resource['initialized']) {
            throw new Exception\RuntimeException(
                "Can't change persistent id of resource {$id} after initialization"
            );
        }

        $this->normalizePersistentId($persistentId);
        $resource['persistent_id'] = $persistentId;

        return $this;
    }

    /**
     * Get the persistent id
     *
     * @param string $id
     * @return string
     * @throws Exception\RuntimeException
     */
    public function getPersistentId($id)
    {
        if (! $this->hasResource($id)) {
            throw new Exception\RuntimeException("No resource with id '{$id}'");
        }

        $resource = &$this->resources[$id];

        return $resource['persistent_id'];
    }

    /**
     * Normalize the persistent id
     *
     * @param string $persistentId
     */
    protected function normalizePersistentId(&$persistentId)
    {
        $persistentId = (string) $persistentId;
    }

    /**
     * Set Redis options
     *
     * @param string $id
     * @param array  $libOptions
     * @return RedisResourceManager Fluent interface
     */
    public function setLibOptions($id, array $libOptions)
    {
        if (! $this->hasResource($id)) {
            return $this->setResource($id, [
                'lib_options' => $libOptions,
            ]);
        }

        $resource = &$this->resources[$id];

        $resource['lib_options'] = $libOptions;

        if (! $resource['resource'] instanceof RedisResource) {
            return $this;
        }

        $this->normalizeLibOptions($libOptions);
        $redis = &$resource['resource'];

        if (method_exists($redis, 'setOptions')) {
            $redis->setOptions($libOptions);
        } else {
            foreach ($libOptions as $key => $value) {
                $redis->setOption($key, $value);
            }
        }

        return $this;
    }

    /**
     * Get Redis options
     *
     * @param string $id
     * @return array
     * @throws Exception\RuntimeException
     */
    public function getLibOptions($id)
    {
        if (! $this->hasResource($id)) {
            throw new Exception\RuntimeException("No resource with id '{$id}'");
        }

        $resource = &$this->resources[$id];

        if ($resource['resource'] instanceof RedisResource) {
            $libOptions = [];
            $reflection = new ReflectionClass('Redis');
            $constants  = $reflection->getConstants();
            foreach ($constants as $constName => $constValue) {
                if (strpos($constName, 'OPT_') === 0) {
                    $libOptions[$constValue] = $resource['resource']->getOption($constValue);
                }
            }
            return $libOptions;
        }
        return $resource['lib_options'];
    }

    /**
     * Set one Redis option
     *
     * @param string     $id
     * @param string|int $key
     * @param mixed      $value
     * @return RedisResourceManager Fluent interface
     */
    public function setLibOption($id, $key, $value)
    {
        return $this->setLibOptions($id, [$key => $value]);
    }

    /**
     * Get one Redis option
     *
     * @param string     $id
     * @param string|int $key
     * @return mixed
     * @throws Exception\RuntimeException
     */
    public function getLibOption($id, $key)
    {
        if (! $this->hasResource($id)) {
            throw new Exception\RuntimeException("No resource with id '{$id}'");
        }

        $this->normalizeLibOptionKey($key);
        $resource = &$this->resources[$id];

        if ($resource['resource'] instanceof RedisResource) {
            return $resource['resource']->getOption($key);
        }

        return $resource['lib_options'][$key] ?? null;
    }

    /**
     * Normalize Redis options
     *
     * @param array|Traversable $libOptions
     * @throws Exception\InvalidArgumentException
     */
    protected function normalizeLibOptions(&$libOptions)
    {
        if (! is_array($libOptions) && ! $libOptions instanceof Traversable) {
            throw new Exception\InvalidArgumentException(
                "Lib-Options must be an array or an instance of Traversable"
            );
        }

        $result = [];
        foreach ($libOptions as $key => $value) {
            $this->normalizeLibOptionKey($key);
            $result[$key] = $value;
        }

        $libOptions = $result;
    }

    /**
     * Convert option name into it's constant value
     *
     * @param string|int $key
     * @throws Exception\InvalidArgumentException
     */
    protected function normalizeLibOptionKey(&$key)
    {
        // convert option name into it's constant value
        if (is_string($key)) {
            $const = 'Redis::OPT_' . str_replace([' ', '-'], '_', strtoupper($key));
            if (! defined($const)) {
                throw new Exception\InvalidArgumentException("Unknown redis option '{$key}' ({$const})");
            }
            $key = constant($const);
        } else {
            $key = (int) $key;
        }
    }

    /**
     * Set server
     *
     * Server can be described as follows:
     * - URI:   /path/to/sock.sock
     * - Assoc: array('host' => <host>[, 'port' => <port>[, 'timeout' => <timeout>]])
     * - List:  array(<host>[, <port>, [, <timeout>]])
     *
     * @param string       $id
     * @param string|array $server
     * @return RedisResourceManager
     */
    public function setServer($id, $server)
    {
        if (! $this->hasResource($id)) {
            return $this->setResource($id, [
                'server' => $server,
            ]);
        }

        $this->normalizeServer($server);

        $resource             = &$this->resources[$id];
        $resource['password'] = $this->extractPassword($resource, $server);

        if ($resource['resource'] instanceof RedisResource) {
            $resourceParams = ['server' => $server];

            if (! empty($resource['password'])) {
                $resourceParams['password'] = $resource['password'];
            }

            $this->setResource($id, $resourceParams);
        } else {
            $resource['server'] = $server;
        }

        return $this;
    }

    /**
     * Set redis password
     *
     * @param string $id
     * @param string $password
     * @return RedisResource
     */
    public function setPassword($id, $password)
    {
        if (! $this->hasResource($id)) {
            return $this->setResource($id, [
                'password' => $password,
            ]);
        }

        $resource                = &$this->resources[$id];
        $resource['password']    = $password;
        $resource['initialized'] = false;
        return $this;
    }

    /**
     * Set redis database number
     *
     * @param string $id
     * @param int $database
     * @return RedisResourceManager
     */
    public function setDatabase($id, $database)
    {
        $database = (int) $database;

        if (! $this->hasResource($id)) {
            return $this->setResource($id, [
                'database' => $database,
            ]);
        }

        $resource = &$this->resources[$id];
        if ($resource['resource'] instanceof RedisResource && $resource['initialized']) {
            $resource['resource']->select($database);
        }

        $resource['database'] = $database;

        return $this;
    }
}