<?php

namespace Codeception\Module;

use Codeception\Lib\Interfaces\RequiresPackage;
use Codeception\Module as CodeceptionModule;
use Codeception\Exception\ModuleException;
use Codeception\TestInterface;
use Predis\Client as RedisDriver;

/**
 * This module uses the [Predis](https://github.com/nrk/predis) library
 * to interact with a Redis server.
 *
 * ## Status
 *
 * * Stability: **beta**
 *
 * ## Configuration
 *
 * * **`host`** (`string`, default `'127.0.0.1'`) - The Redis host
 * * **`port`** (`int`, default `6379`) - The Redis port
 * * **`database`** (`int`, no default) - The Redis database. Needs to be explicitly specified.
 * * **`cleanupBefore`**: (`string`, default `'suite'`) - Whether/when to flush the database:
 *     * `suite`: at the beginning of every suite
 *     * `test`: at the beginning of every test
 *     * Any other value: never
 *
 * ### Example (`unit.suite.yml`)
 *
 * ```yaml
 *    modules:
 *        - Redis:
 *            host: '127.0.0.1'
 *            port: 6379
 *            database: 0
 *            cleanupBefore: 'test'
 * ```
 *
 * ## Public Properties
 *
 * * **driver** - Contains the Predis client/driver
 *
 * @author Marc Verney <marc@marcverney.net>
 */
class Redis extends CodeceptionModule implements RequiresPackage
{
    /**
     * {@inheritdoc}
     *
     * No default value is set for the database, as this module will delete
     * every data in it. The user is required to explicitly set this parameter.
     */
    protected $config = [
        'host'          => '127.0.0.1',
        'port'          => 6379,
        'cleanupBefore' => 'test'
    ];

    /**
     * {@inheritdoc}
     */
    protected $requiredFields = [
        'database'
    ];

    /**
     * The Redis driver
     *
     * @var RedisDriver
     */
    public $driver;

    public function _requires()
    {
        return ['Predis\Client' => '"predis/predis": "^1.0"'];
    }

    /**
     * Instructions to run after configuration is loaded
     *
     * @throws ModuleException
     */
    public function _initialize()
    {
        try {
            $this->driver = new RedisDriver([
                'host'     => $this->config['host'],
                'port'     => $this->config['port'],
                'database' => $this->config['database']
            ]);
        } catch (\Exception $e) {
            throw new ModuleException(
                __CLASS__,
                $e->getMessage()
            );
        }
    }

    /**
     * Code to run before each suite
     *
     * @param array $settings
     */
    public function _beforeSuite($settings = [])
    {
        if ($this->config['cleanupBefore'] === 'suite') {
            $this->cleanup();
        }
    }

    /**
     * Code to run before each test
     *
     * @param TestInterface $test
     */
    public function _before(TestInterface $test)
    {
        if ($this->config['cleanupBefore'] === 'test') {
            $this->cleanup();
        }
    }

    /**
     * Delete all the keys in the Redis database
     *
     * @throws ModuleException
     */
    public function cleanup()
    {
        try {
            $this->driver->flushdb();
        } catch (\Exception $e) {
            throw new ModuleException(
                __CLASS__,
                $e->getMessage()
            );
        }
    }

    /**
     * Returns the value of a given key
     *
     * Examples:
     *
     * ``` php
     * <?php
     * // Strings
     * $I->grabFromRedis('string');
     *
     * // Lists: get all members
     * $I->grabFromRedis('example:list');
     *
     * // Lists: get a specific member
     * $I->grabFromRedis('example:list', 2);
     *
     * // Lists: get a range of elements
     * $I->grabFromRedis('example:list', 2, 4);
     *
     * // Sets: get all members
     * $I->grabFromRedis('example:set');
     *
     * // ZSets: get all members
     * $I->grabFromRedis('example:zset');
     *
     * // ZSets: get a range of members
     * $I->grabFromRedis('example:zset', 3, 12);
     *
     * // Hashes: get all fields of a key
     * $I->grabFromRedis('example:hash');
     *
     * // Hashes: get a specific field of a key
     * $I->grabFromRedis('example:hash', 'foo');
     * ```
     *
     * @param string $key The key name
     *
     * @return mixed
     *
     * @throws ModuleException if the key does not exist
     */
    public function grabFromRedis($key)
    {
        $args = func_get_args();

        switch ($this->driver->type($key)) {
            case 'none':
                throw new ModuleException(
                    $this,
                    "Cannot grab key \"$key\" as it does not exist"
                );
                break;

            case 'string':
                $reply = $this->driver->get($key);
                break;

            case 'list':
                if (count($args) === 2) {
                    $reply = $this->driver->lindex($key, $args[1]);
                } else {
                    $reply = $this->driver->lrange(
                        $key,
                        isset($args[1]) ? $args[1] : 0,
                        isset($args[2]) ? $args[2] : -1
                    );
                }
                break;

            case 'set':
                $reply = $this->driver->smembers($key);
                break;

            case 'zset':
                if (count($args) === 2) {
                    throw new ModuleException(
                        $this,
                        "The method grabFromRedis(), when used with sorted "
                        . "sets, expects either one argument or three"
                    );
                }
                $reply = $this->driver->zrange(
                    $key,
                    isset($args[2]) ? $args[1] : 0,
                    isset($args[2]) ? $args[2] : -1,
                    'WITHSCORES'
                );
                break;

            case 'hash':
                $reply = isset($args[1])
                    ? $this->driver->hget($key, $args[1])
                    : $this->driver->hgetall($key);
                break;

            default:
                $reply = null;
        }

        return $reply;
    }

    /**
     * Creates or modifies keys
     *
     * If $key already exists:
     *
     * - Strings: its value will be overwritten with $value
     * - Other types: $value items will be appended to its value
     *
     * Examples:
     *
     * ``` php
     * <?php
     * // Strings: $value must be a scalar
     * $I->haveInRedis('string', 'Obladi Oblada');
     *
     * // Lists: $value can be a scalar or an array
     * $I->haveInRedis('list', ['riri', 'fifi', 'loulou']);
     *
     * // Sets: $value can be a scalar or an array
     * $I->haveInRedis('set', ['riri', 'fifi', 'loulou']);
     *
     * // ZSets: $value must be an associative array with scores
     * $I->haveInRedis('zset', ['riri' => 1, 'fifi' => 2, 'loulou' => 3]);
     *
     * // Hashes: $value must be an associative array
     * $I->haveInRedis('hash', ['obladi' => 'oblada']);
     * ```
     *
     * @param string $type  The type of the key
     * @param string $key   The key name
     * @param mixed  $value The value
     *
     * @throws ModuleException
     */
    public function haveInRedis($type, $key, $value)
    {
        switch (strtolower($type)) {
            case 'string':
                if (!is_scalar($value)) {
                    throw new ModuleException(
                        $this,
                        'If second argument of haveInRedis() method is "string", '
                        . 'third argument must be a scalar'
                    );
                }
                $this->driver->set($key, $value);
                break;

            case 'list':
                $this->driver->rpush($key, $value);
                break;

            case 'set':
                $this->driver->sadd($key, $value);
                break;

            case 'zset':
                if (!is_array($value)) {
                    throw new ModuleException(
                        $this,
                        'If second argument of haveInRedis() method is "zset", '
                        . 'third argument must be an (associative) array'
                    );
                }
                $this->driver->zadd($key, $value);
                break;

            case 'hash':
                if (!is_array($value)) {
                    throw new ModuleException(
                        $this,
                        'If second argument of haveInRedis() method is "hash", '
                        . 'third argument must be an array'
                    );
                }
                $this->driver->hmset($key, $value);
                break;

            default:
                throw new ModuleException(
                    $this,
                    "Unknown type \"$type\" for key \"$key\". Allowed types are "
                    . '"string", "list", "set", "zset", "hash"'
                );
        }
    }

    /**
     * Asserts that a key does not exist or, optionaly, that it doesn't have the
     * provided $value
     *
     * Examples:
     *
     * ``` php
     * <?php
     * // With only one argument, only checks the key does not exist
     * $I->dontSeeInRedis('example:string');
     *
     * // Checks a String does not exist or its value is not the one provided
     * $I->dontSeeInRedis('example:string', 'life');
     *
     * // Checks a List does not exist or its value is not the one provided (order of elements is compared).
     * $I->dontSeeInRedis('example:list', ['riri', 'fifi', 'loulou']);
     *
     * // Checks a Set does not exist or its value is not the one provided (order of members is ignored).
     * $I->dontSeeInRedis('example:set', ['riri', 'fifi', 'loulou']);
     *
     * // Checks a ZSet does not exist or its value is not the one provided (scores are required, order of members is compared)
     * $I->dontSeeInRedis('example:zset', ['riri' => 1, 'fifi' => 2, 'loulou' => 3]);
     *
     * // Checks a Hash does not exist or its value is not the one provided (order of members is ignored).
     * $I->dontSeeInRedis('example:hash', ['riri' => true, 'fifi' => 'Dewey', 'loulou' => 2]);
     * ```
     *
     * @param string $key   The key name
     * @param mixed  $value Optional. If specified, also checks the key has this
     * value. Booleans will be converted to 1 and 0 (even inside arrays)
     */
    public function dontSeeInRedis($key, $value = null)
    {
        $this->assertFalse(
            (bool) $this->checkKeyExists($key, $value),
            "The key \"$key\" exists" . ($value ? ' and its value matches the one provided' : '')
        );
    }

    /**
     * Asserts that a given key does not contain a given item
     *
     * Examples:
     *
     * ``` php
     * <?php
     * // Strings: performs a substring search
     * $I->dontSeeRedisKeyContains('string', 'bar');
     *
     * // Lists
     * $I->dontSeeRedisKeyContains('example:list', 'poney');
     *
     * // Sets
     * $I->dontSeeRedisKeyContains('example:set', 'cat');
     *
     * // ZSets: check whether the zset has this member
     * $I->dontSeeRedisKeyContains('example:zset', 'jordan');
     *
     * // ZSets: check whether the zset has this member with this score
     * $I->dontSeeRedisKeyContains('example:zset', 'jordan', 23);
     *
     * // Hashes: check whether the hash has this field
     * $I->dontSeeRedisKeyContains('example:hash', 'magic');
     *
     * // Hashes: check whether the hash has this field with this value
     * $I->dontSeeRedisKeyContains('example:hash', 'magic', 32);
     * ```
     *
     * @param string $key       The key
     * @param mixed  $item      The item
     * @param null   $itemValue Optional and only used for zsets and hashes. If
     * specified, the method will also check that the $item has this value/score
     *
     * @return bool
     */
    public function dontSeeRedisKeyContains($key, $item, $itemValue = null)
    {
        $this->assertFalse(
            (bool) $this->checkKeyContains($key, $item, $itemValue),
            "The key \"$key\" contains " . (
                is_null($itemValue)
                ? "\"$item\""
                : "[\"$item\" => \"$itemValue\"]"
            )
        );
    }

    /**
     * Asserts that a key exists, and optionally that it has the provided $value
     *
     * Examples:
     *
     * ``` php
     * <?php
     * // With only one argument, only checks the key exists
     * $I->seeInRedis('example:string');
     *
     * // Checks a String exists and has the value "life"
     * $I->seeInRedis('example:string', 'life');
     *
     * // Checks the value of a List. Order of elements is compared.
     * $I->seeInRedis('example:list', ['riri', 'fifi', 'loulou']);
     *
     * // Checks the value of a Set. Order of members is ignored.
     * $I->seeInRedis('example:set', ['riri', 'fifi', 'loulou']);
     *
     * // Checks the value of a ZSet. Scores are required. Order of members is compared.
     * $I->seeInRedis('example:zset', ['riri' => 1, 'fifi' => 2, 'loulou' => 3]);
     *
     * // Checks the value of a Hash. Order of members is ignored.
     * $I->seeInRedis('example:hash', ['riri' => true, 'fifi' => 'Dewey', 'loulou' => 2]);
     * ```
     *
     * @param string $key   The key name
     * @param mixed  $value Optional. If specified, also checks the key has this
     * value. Booleans will be converted to 1 and 0 (even inside arrays)
     */
    public function seeInRedis($key, $value = null)
    {
        $this->assertTrue(
            (bool) $this->checkKeyExists($key, $value),
            "Cannot find key \"$key\"" . ($value ? ' with the provided value' : '')
        );
    }

    /**
     * Sends a command directly to the Redis driver. See documentation at
     * https://github.com/nrk/predis
     * Every argument that follows the $command name will be passed to it.
     *
     * Examples:
     *
     * ``` php
     * <?php
     * $I->sendCommandToRedis('incr', 'example:string');
     * $I->sendCommandToRedis('strLen', 'example:string');
     * $I->sendCommandToRedis('lPop', 'example:list');
     * $I->sendCommandToRedis('zRangeByScore', 'example:set', '-inf', '+inf', ['withscores' => true, 'limit' => [1, 2]]);
     * $I->sendCommandToRedis('flushdb');
     * ```
     *
     * @param string $command The command name
     *
     * @return mixed
     */
    public function sendCommandToRedis($command)
    {
        return call_user_func_array(
            [$this->driver, $command],
            array_slice(func_get_args(), 1)
        );
    }

    /**
     * Asserts that a given key contains a given item
     *
     * Examples:
     *
     * ``` php
     * <?php
     * // Strings: performs a substring search
     * $I->seeRedisKeyContains('example:string', 'bar');
     *
     * // Lists
     * $I->seeRedisKeyContains('example:list', 'poney');
     *
     * // Sets
     * $I->seeRedisKeyContains('example:set', 'cat');
     *
     * // ZSets: check whether the zset has this member
     * $I->seeRedisKeyContains('example:zset', 'jordan');
     *
     * // ZSets: check whether the zset has this member with this score
     * $I->seeRedisKeyContains('example:zset', 'jordan', 23);
     *
     * // Hashes: check whether the hash has this field
     * $I->seeRedisKeyContains('example:hash', 'magic');
     *
     * // Hashes: check whether the hash has this field with this value
     * $I->seeRedisKeyContains('example:hash', 'magic', 32);
     * ```
     *
     * @param string $key       The key
     * @param mixed  $item      The item
     * @param null   $itemValue Optional and only used for zsets and hashes. If
     * specified, the method will also check that the $item has this value/score
     *
     * @return bool
     */
    public function seeRedisKeyContains($key, $item, $itemValue = null)
    {
        $this->assertTrue(
            (bool) $this->checkKeyContains($key, $item, $itemValue),
            "The key \"$key\" does not contain " . (
            is_null($itemValue)
                ? "\"$item\""
                : "[\"$item\" => \"$itemValue\"]"
            )
        );
    }

    /**
     * Converts boolean values to "0" and "1"
     *
     * @param mixed $var The variable
     *
     * @return mixed
     */
    private function boolToString($var)
    {
        $copy = is_array($var) ? $var : [$var];

        foreach ($copy as $key => $value) {
            if (is_bool($value)) {
                $copy[$key] = $value ? '1' : '0';
            }
        }

        return is_array($var) ? $copy : $copy[0];
    }

    /**
     * Checks whether a key contains a given item
     *
     * @param string $key       The key
     * @param mixed  $item      The item
     * @param null   $itemValue Optional and only used for zsets and hashes. If
     * specified, the method will also check that the $item has this value/score
     *
     * @return bool
     *
     * @throws ModuleException
     */
    private function checkKeyContains($key, $item, $itemValue = null)
    {
        $result = null;

        if (!is_scalar($item)) {
            throw new ModuleException(
                $this,
                "All arguments of [dont]seeRedisKeyContains() must be scalars"
            );
        }

        switch ($this->driver->type($key)) {
            case 'string':
                $reply = $this->driver->get($key);
                $result = strpos($reply, $item) !== false;
                break;

            case 'list':
                $reply = $this->driver->lrange($key, 0, -1);
                $result = in_array($item, $reply);
                break;

            case 'set':
                $result = $this->driver->sismember($key, $item);
                break;

            case 'zset':
                $reply = $this->driver->zscore($key, $item);

                if (is_null($reply)) {
                    $result = false;
                } elseif (!is_null($itemValue)) {
                    $result = (float) $reply === (float) $itemValue;
                } else {
                    $result = true;
                }
                break;

            case 'hash':
                $reply = $this->driver->hget($key, $item);

                $result = is_null($itemValue)
                    ? !is_null($reply)
                    : (string) $reply === (string) $itemValue;
                break;

            case 'none':
                throw new ModuleException(
                    $this,
                    "Key \"$key\" does not exist"
                );
                break;
        }

        return $result;
    }

    /**
     * Checks whether a key exists and, optionally, whether it has a given $value
     *
     * @param string $key   The key name
     * @param mixed  $value Optional. If specified, also checks the key has this
     * value. Booleans will be converted to 1 and 0 (even inside arrays)
     *
     * @return bool
     */
    private function checkKeyExists($key, $value = null)
    {
        $type = $this->driver->type($key);

        if (is_null($value)) {
            return $type != 'none';
        }

        $value = $this->boolToString($value);

        switch ($type) {
            case 'string':
                $reply = $this->driver->get($key);
                // Allow non strict equality (2 equals '2')
                $result = $reply == $value;
                break;

            case 'list':
                $reply = $this->driver->lrange($key, 0, -1);
                // Check both arrays have the same key/value pairs + same order
                $result = $reply === $value;
                break;

            case 'set':
                $reply = $this->driver->smembers($key);
                // Only check both arrays have the same values
                sort($reply);
                sort($value);
                $result = $reply === $value;
                break;

            case 'zset':
                $reply = $this->driver->zrange($key, 0, -1, 'WITHSCORES');
                // Check both arrays have the same key/value pairs + same order
                $reply = $this->scoresToFloat($reply);
                $value = $this->scoresToFloat($value);
                $result = $reply === $value;
                break;

            case 'hash':
                $reply = $this->driver->hgetall($key);
                // Only check both arrays have the same key/value pairs (==)
                $result = $reply == $value;
                break;

            default:
                $result = false;
        }

        return $result;
    }

    /**
     * Explicitly cast the scores of a Zset associative array as float/double
     *
     * @param array $arr The ZSet associative array
     *
     * @return array
     */
    private function scoresToFloat(array $arr)
    {
        foreach ($arr as $member => $score) {
            $arr[$member] = (float) $score;
        }

        return $arr;
    }
}
