-- Copyright 2015 Paul Kulchenko, ZeroBrane LLC; All rights reserved
-- Integration with Redis data store (http://redis.io) from Redis Labs (https://redislabs.com).

-- Repository of the redis-lua Redis client: https://github.com/nrk/redis-lua
local redis = (function() -- redis-lua module start ----------------------------
local redis = {
    _VERSION     = 'redis-lua 2.0.5-dev',
    _DESCRIPTION = 'A Lua client library for the redis key value storage system.',
    _COPYRIGHT   = 'Copyright (C) 2009-2012 Daniele Alessandri',
}

local unpack = _G.unpack or table.unpack
local network, request, response = {}, {}, {}

local defaults = {
    host        = '127.0.0.1',
    port        = 6379,
    tcp_nodelay = true,
    path        = nil,
}

local function merge_defaults(parameters)
    if parameters == nil then
        parameters = {}
    end
    for k, v in pairs(defaults) do
        if parameters[k] == nil then
            parameters[k] = defaults[k]
        end
    end
    return parameters
end

local function parse_boolean(v)
    if v == '1' or v == 'true' or v == 'TRUE' then
        return true
    elseif v == '0' or v == 'false' or v == 'FALSE' then
        return false
    else
        return nil
    end
end

local function toboolean(value) return value == 1 end

local function sort_request(client, command, key, params)
    --[[ params = {
        by    = 'weight_*',
        get   = 'object_*',
        limit = { 0, 10 },
        sort  = 'desc',
        alpha = true,
    } ]]
    local query = { key }

    if params then
        if params.by then
            table.insert(query, 'BY')
            table.insert(query, params.by)
        end

        if type(params.limit) == 'table' then
            -- TODO: check for lower and upper limits
            table.insert(query, 'LIMIT')
            table.insert(query, params.limit[1])
            table.insert(query, params.limit[2])
        end

        if params.get then
            if (type(params.get) == 'table') then
                for _, getarg in pairs(params.get) do
                    table.insert(query, 'GET')
                    table.insert(query, getarg)
                end
            else
                table.insert(query, 'GET')
                table.insert(query, params.get)
            end
        end

        if params.sort then
            table.insert(query, params.sort)
        end

        if params.alpha == true then
            table.insert(query, 'ALPHA')
        end

        if params.store then
            table.insert(query, 'STORE')
            table.insert(query, params.store)
        end
    end

    request.multibulk(client, command, query)
end

local function zset_range_request(client, command, ...)
    local args, opts = {...}, { }

    if #args >= 1 and type(args[#args]) == 'table' then
        local options = table.remove(args, #args)
        if options.withscores then
            table.insert(opts, 'WITHSCORES')
        end
    end

    for _, v in pairs(opts) do table.insert(args, v) end
    request.multibulk(client, command, args)
end

local function zset_range_byscore_request(client, command, ...)
    local args, opts = {...}, { }

    if #args >= 1 and type(args[#args]) == 'table' then
        local options = table.remove(args, #args)
        if options.limit then
            table.insert(opts, 'LIMIT')
            table.insert(opts, options.limit.offset or options.limit[1])
            table.insert(opts, options.limit.count or options.limit[2])
        end
        if options.withscores then
            table.insert(opts, 'WITHSCORES')
        end
    end

    for _, v in pairs(opts) do table.insert(args, v) end
    request.multibulk(client, command, args)
end

local function zset_range_reply(reply, command, ...)
    local args = {...}
    local opts = args[4]
    if opts and (opts.withscores or string.lower(tostring(opts)) == 'withscores') then
        local new_reply = { }
        for i = 1, #reply, 2 do
            table.insert(new_reply, { reply[i], reply[i + 1] })
        end
        return new_reply
    else
        return reply
    end
end

local function zset_store_request(client, command, ...)
    local args, opts = {...}, { }

    if #args >= 1 and type(args[#args]) == 'table' then
        local options = table.remove(args, #args)
        if options.weights and type(options.weights) == 'table' then
            table.insert(opts, 'WEIGHTS')
            for _, weight in ipairs(options.weights) do
                table.insert(opts, weight)
            end
        end
        if options.aggregate then
            table.insert(opts, 'AGGREGATE')
            table.insert(opts, options.aggregate)
        end
    end

    for _, v in pairs(opts) do table.insert(args, v) end
    request.multibulk(client, command, args)
end

local function mset_filter_args(client, command, ...)
    local args, arguments = {...}, {}
    if (#args == 1 and type(args[1]) == 'table') then
        for k,v in pairs(args[1]) do
            table.insert(arguments, k)
            table.insert(arguments, v)
        end
    else
        arguments = args
    end
    request.multibulk(client, command, arguments)
end

local function hash_multi_request_builder(builder_callback)
    return function(client, command, ...)
        local args, arguments = {...}, { }
        if #args == 2 then
            table.insert(arguments, args[1])
            for k, v in pairs(args[2]) do
                builder_callback(arguments, k, v)
            end
        else
            arguments = args
        end
        request.multibulk(client, command, arguments)
    end
end

local function parse_info(response)
    local info = {}
    local current = info

    response:gsub('([^\r\n]*)\r\n', function(kv)
        if kv == '' then return end

        local section = kv:match('^# (%w+)$')
        if section then
            current = {}
            info[section:lower()] = current
            return
        end

        local k,v = kv:match(('([^:]*):([^:]*)'):rep(1))
        if k:match('db%d+') then
            current[k] = {}
            v:gsub(',', function(dbkv)
                local dbk,dbv = dbkv:match('([^:]*)=([^:]*)')
                current[k][dbk] = dbv
            end)
        else
            current[k] = v
        end
    end)

    return info
end

local function scan_request(client, command, ...)
    local args, req, params = {...}, { }, nil

    if command == 'SCAN' then
        table.insert(req, args[1])
        params = args[2]
    else
        table.insert(req, args[1])
        table.insert(req, args[2])
        params = args[3]
    end

    if params and params.match then
        table.insert(req, 'MATCH')
        table.insert(req, params.match)
    end

    if params and params.count then
        table.insert(req, 'COUNT')
        table.insert(req, params.count)
    end

    request.multibulk(client, command, req)
end

local zscan_response = function(reply, command, ...)
    local original, new = reply[2], { }
    for i = 1, #original, 2 do
        table.insert(new, { original[i], tonumber(original[i + 1]) })
    end
    reply[2] = new

    return reply
end

local hscan_response = function(reply, command, ...)
    local original, new = reply[2], { }
    for i = 1, #original, 2 do
        new[original[i]] = original[i + 1]
    end
    reply[2] = new

    return reply
end

local function load_methods(proto, commands)
    local client = setmetatable ({}, getmetatable(proto))

    for cmd, fn in pairs(commands) do
        if type(fn) ~= 'function' then
            redis.error('invalid type for command ' .. cmd .. '(must be a function)')
        end
        client[cmd] = fn
    end

    for i, v in pairs(proto) do
        client[i] = v
    end

    return client
end

local function create_client(proto, client_socket, commands)
    local client = load_methods(proto, commands)
    client.error = redis.error
    client.network = {
        socket = client_socket,
        read   = network.read,
        write  = network.write,
    }
    client.requests = {
        multibulk = request.multibulk,
    }
    return client
end

-- ############################################################################

function network.write(client, buffer)
    local _, err = client.network.socket:send(buffer)
    if type(client.onwrite) == 'function' then client:onwrite(buffer, err) end
    if err then return client.error(err) end
end

function network.read(client, len)
    if len == nil then len = '*l' end
    local line, err = client.network.socket:receive(len)
    if type(client.onread) == 'function' then client:onread(line, err) end
    if not err then return line else return client.error(err) end
end

-- ############################################################################

function response.read(client)
    local payload, err = client.network.read(client)
    if not payload then return client.error('reading error: ' .. err) end

    local prefix, data = payload:sub(1, -#payload), payload:sub(2)

    -- status reply
    if prefix == '+' then
        if data == 'OK' then
            return true
        elseif data == 'QUEUED' then
            return { queued = true }
        else
            return data
        end

   -- error reply
    elseif prefix == '-' then
        return client.error('redis error: ' .. data)

   -- integer reply
    elseif prefix == ':' then
        local number = tonumber(data)

        if not number then
            if data == 'nil' then
                return nil
            end
            return client.error('cannot parse ' .. data .. ' as a numeric response.')
        end

        return number

   -- bulk reply
    elseif prefix == '$' then
        local length = tonumber(data)

        if not length then
            return client.error('cannot parse ' .. length .. ' as data length')
        end

        if length == -1 then
            return nil
        end

        local nextchunk = client.network.read(client, length + 2)

        return nextchunk:sub(1, -3)

   -- multibulk reply
    elseif prefix == '*' then
        local count = tonumber(data)

        if count == -1 then
            return nil
        end

        local list = {}
        if count > 0 then
            local reader = response.read
            for i = 1, count do
                list[i] = reader(client)
            end
        end
        return list

   -- unknown type of reply
    else
        return client.error('unknown response prefix: ' .. prefix)
    end
end

-- ############################################################################

function request.raw(client, buffer)
    local bufferType = type(buffer)

    if bufferType == 'table' then
        client.network.write(client, table.concat(buffer))
    elseif bufferType == 'string' then
        client.network.write(client, buffer)
    else
        client.error('argument error: ' .. bufferType)
    end
end

function request.multibulk(client, command, ...)
    local args = {...}
    local argsn = #args
    local buffer = { true, true }

    if argsn == 1 and type(args[1]) == 'table' then
        argsn, args = #args[1], args[1]
    end

    buffer[1] = '*' .. tostring(argsn + 1) .. "\r\n"
    buffer[2] = '$' .. #command .. "\r\n" .. command .. "\r\n"

    local table_insert = table.insert
    for i = 1, argsn do
        local s_argument = tostring(args[i] or '')
        table_insert(buffer, '$' .. #s_argument .. "\r\n" .. s_argument .. "\r\n")
    end

    client.network.write(client, table.concat(buffer))
end

-- ############################################################################

local function custom(command, send, parse)
    command = string.upper(command)
    return function(client, ...)
        send(client, command, ...)
        local reply, err = response.read(client)

        if type(reply) == 'table' and reply.queued then
            reply.parser = parse
            return reply
        elseif reply == nil then
            return reply, err
        else
            if parse then
                return parse(reply, command, ...)
            end
            return reply
        end
    end
end

local function command(command, opts)
    if opts == nil or type(opts) == 'function' then
        return custom(command, request.multibulk, opts)
    else
        return custom(command, opts.request or request.multibulk, opts.response)
    end
end

local define_command_impl = function(target, name, opts)
    local opts = opts or {}
    target[string.lower(name)] = custom(
        opts.command or string.upper(name),
        opts.request or request.multibulk,
        opts.response or nil
    )
end

local undefine_command_impl = function(target, name)
    target[string.lower(name)] = nil
end

-- ############################################################################

local client_prototype = {}

client_prototype.raw_cmd = function(client, buffer)
    request.raw(client, buffer .. "\r\n")
    return response.read(client)
end

-- obsolete
client_prototype.define_command = function(client, name, opts)
    define_command_impl(client, name, opts)
end

-- obsolete
client_prototype.undefine_command = function(client, name)
    undefine_command_impl(client, name)
end

client_prototype.quit = function(client)
    request.multibulk(client, 'QUIT')
    client.network.socket:shutdown()
    return true
end

client_prototype.shutdown = function(client)
    request.multibulk(client, 'SHUTDOWN')
    client.network.socket:shutdown()
end

-- Command pipelining

client_prototype.pipeline = function(client, block)
    local requests, replies, parsers = {}, {}, {}
    local table_insert = table.insert
    local socket_write, socket_read = client.network.write, client.network.read

    client.network.write = function(_, buffer)
        table_insert(requests, buffer)
    end

    -- TODO: this hack is necessary to temporarily reuse the current
    --       request -> response handling implementation of redis-lua
    --       without further changes in the code, but it will surely
    --       disappear when the new command-definition infrastructure
    --       will finally be in place.
    client.network.read = function() return '+QUEUED' end

    local pipeline = setmetatable({}, {
        __index = function(env, name)
            local cmd = client[name]
            if not cmd then
                client.error('unknown redis command: ' .. name, 2)
            end
            return function(self, ...)
                local reply = cmd(client, ...)
                table_insert(parsers, #requests, reply.parser)
                return reply
            end
        end
    })

    local success, retval = pcall(block, pipeline)

    client.network.write, client.network.read = socket_write, socket_read
    if not success then return client.error(retval, 0) end

    client.network.write(client, table.concat(requests, ''))

    for i = 1, #requests do
        local reply, parser = response.read(client), parsers[i]
        if parser then
            reply = parser(reply)
        end
        table_insert(replies, i, reply)
    end

    return replies, #requests
end

-- Publish/Subscribe

do
    local channels = function(channels)
        if type(channels) == 'string' then
            channels = { channels }
        end
        return channels
    end

    local subscribe = function(client, ...)
        request.multibulk(client, 'subscribe', ...)
    end
    local psubscribe = function(client, ...)
        request.multibulk(client, 'psubscribe', ...)
    end
    local unsubscribe = function(client, ...)
        request.multibulk(client, 'unsubscribe')
    end
    local punsubscribe = function(client, ...)
        request.multibulk(client, 'punsubscribe')
    end

    local consumer_loop = function(client)
        local aborting, subscriptions = false, 0

        local abort = function()
            if not aborting then
                unsubscribe(client)
                punsubscribe(client)
                aborting = true
            end
        end

        return coroutine.wrap(function()
            while true do
                local message
                local response = response.read(client)

                if response[1] == 'pmessage' then
                    message = {
                        kind    = response[1],
                        pattern = response[2],
                        channel = response[3],
                        payload = response[4],
                    }
                else
                    message = {
                        kind    = response[1],
                        channel = response[2],
                        payload = response[3],
                    }
                end

                if string.match(message.kind, '^p?subscribe$') then
                    subscriptions = subscriptions + 1
                end
                if string.match(message.kind, '^p?unsubscribe$') then
                    subscriptions = subscriptions - 1
                end

                if aborting and subscriptions == 0 then
                    break
                end
                coroutine.yield(message, abort)
            end
        end)
    end

    client_prototype.pubsub = function(client, subscriptions)
        if type(subscriptions) == 'table' then
            if subscriptions.subscribe then
                subscribe(client, channels(subscriptions.subscribe))
            end
            if subscriptions.psubscribe then
                psubscribe(client, channels(subscriptions.psubscribe))
            end
        end
        return consumer_loop(client)
    end
end

-- Redis transactions (MULTI/EXEC)

do
    local function identity(...) return ... end
    local emptytable = {}

    local function initialize_transaction(client, options, block, queued_parsers)
        local table_insert = table.insert
        local coro = coroutine.create(block)

        if options.watch then
            local watch_keys = {}
            for _, key in pairs(options.watch) do
                table_insert(watch_keys, key)
            end
            if #watch_keys > 0 then
                client:watch(unpack(watch_keys))
            end
        end

        local transaction_client = setmetatable({}, {__index=client})
        transaction_client.exec  = function(...)
            return client.error('cannot use EXEC inside a transaction block')
        end
        transaction_client.multi = function(...)
            coroutine.yield()
        end
        transaction_client.commands_queued = function()
            return #queued_parsers
        end

        assert(coroutine.resume(coro, transaction_client))

        transaction_client.multi = nil
        transaction_client.discard = function(...)
            local reply = client:discard()
            for i, v in pairs(queued_parsers) do
                queued_parsers[i]=nil
            end
            coro = initialize_transaction(client, options, block, queued_parsers)
            return reply
        end
        transaction_client.watch = function(...)
            return client.error('WATCH inside MULTI is not allowed')
        end
        setmetatable(transaction_client, { __index = function(t, k)
                local cmd = client[k]
                if type(cmd) == "function" then
                    local function queuey(self, ...)
                        local reply = cmd(client, ...)
                        assert((reply or emptytable).queued == true, 'a QUEUED reply was expected')
                        table_insert(queued_parsers, reply.parser or identity)
                        return reply
                    end
                    t[k]=queuey
                    return queuey
                else
                    return cmd
                end
            end
        })
        client:multi()
        return coro
    end

    local function transaction(client, options, coroutine_block, attempts)
        local queued_parsers, replies = {}, {}
        local retry = tonumber(attempts) or tonumber(options.retry) or 2
        local coro = initialize_transaction(client, options, coroutine_block, queued_parsers)

        local success, retval
        if coroutine.status(coro) == 'suspended' then
            success, retval = coroutine.resume(coro)
        else
            -- do not fail if the coroutine has not been resumed (missing t:multi() with CAS)
            success, retval = true, 'empty transaction'
        end
        if #queued_parsers == 0 or not success then
            client:discard()
            assert(success, retval)
            return replies, 0
        end

        local raw_replies = client:exec()
        if not raw_replies then
            if (retry or 0) <= 0 then
                return client.error("MULTI/EXEC transaction aborted by the server")
            else
                --we're not quite done yet
                return transaction(client, options, coroutine_block, retry - 1)
            end
        end

        local table_insert = table.insert
        for i, parser in pairs(queued_parsers) do
            table_insert(replies, i, parser(raw_replies[i]))
        end

        return replies, #queued_parsers
    end

    client_prototype.transaction = function(client, arg1, arg2)
        local options, block
        if not arg2 then
            options, block = {}, arg1
        elseif arg1 then --and arg2, implicitly
            options, block = type(arg1)=="table" and arg1 or { arg1 }, arg2
        else
            return client.error("Invalid parameters for redis transaction.")
        end

        if not options.watch then
            local watch_keys = { }
            for i, v in pairs(options) do
                if tonumber(i) then
                    table.insert(watch_keys, v)
                    options[i] = nil
                end
            end
            options.watch = watch_keys
        elseif not (type(options.watch) == 'table') then
            options.watch = { options.watch }
        end

        if not options.cas then
            local tx_block = block
            block = function(client, ...)
                client:multi()
                return tx_block(client, ...) --can't wrap this in pcall because we're in a coroutine.
            end
        end

        return transaction(client, options, block)
    end
end

-- MONITOR context

do
    local monitor_loop = function(client)
        local monitoring = true

        -- Tricky since the payload format changed starting from Redis 2.6.
        local pattern = '^(%d+%.%d+)( ?.- ?) ?"(%a+)" ?(.-)$'

        local abort = function()
            monitoring = false
        end

        return coroutine.wrap(function()
            client:monitor()

            while monitoring do
                local message, matched
                local response = response.read(client)

                local ok = response:gsub(pattern, function(time, info, cmd, args)
                    message = {
                        timestamp = tonumber(time),
                        client    = info:match('%d+.%d+.%d+.%d+:%d+'),
                        database  = tonumber(info:match('%d+')) or 0,
                        command   = cmd,
                        arguments = args:match('.+'),
                    }
                    matched = true
                end)

                if not matched then
                    return client.error('Unable to match MONITOR payload: '..response)
                end

                coroutine.yield(message, abort)
            end
        end)
    end

    client_prototype.monitor_messages = function(client)
        return monitor_loop(client)
    end
end

-- ############################################################################

local function connect_tcp(socket, parameters)
    local host, port = parameters.host, tonumber(parameters.port)
    if parameters.timeout then
        socket:settimeout(parameters.timeout, 'b')
    end

    local ok, err = socket:connect(host, port)
    if not ok then
        redis.error('could not connect to '..host..':'..port..' ['..err..']')
    end
    socket:setoption('tcp-nodelay', parameters.tcp_nodelay)
    return socket
end

local function connect_unix(socket, parameters)
    local ok, err = socket:connect(parameters.path)
    if not ok then
        redis.error('could not connect to '..parameters.path..' ['..err..']')
    end
    return socket
end

local function create_connection(parameters)
    if parameters.socket then
        return parameters.socket
    end

    local perform_connection, socket

    if parameters.scheme == 'unix' then
        perform_connection, socket = connect_unix, require('socket.unix')
        assert(socket, 'your build of LuaSocket does not support UNIX domain sockets')
    else
        if parameters.scheme then
            local scheme = parameters.scheme
            assert(scheme == 'redis' or scheme == 'tcp', 'invalid scheme: '..scheme)
        end
        perform_connection, socket = connect_tcp, require('socket').tcp
    end

    return perform_connection(socket(), parameters)
end

-- ############################################################################

function redis.error(message, level)
    error(message, (level or 1) + 1)
end

function redis.connect(...)
    local args, parameters = {...}, nil

    if #args == 1 then
        if type(args[1]) == 'table' then
            parameters = args[1]
        else
            local uri = require('socket.url')
            parameters = uri.parse(select(1, ...))
            if parameters.scheme then
                if parameters.query then
                    for k, v in parameters.query:gmatch('([-_%w]+)=([-_%w]+)') do
                        if k == 'tcp_nodelay' or k == 'tcp-nodelay' then
                            parameters.tcp_nodelay = parse_boolean(v)
                        elseif k == 'timeout' then
                            parameters.timeout = tonumber(v)
                        end
                    end
                end
            else
                parameters.host = parameters.path
            end
        end
    elseif #args > 1 then
        local host, port, timeout = unpack(args)
        parameters = { host = host, port = port, timeout = tonumber(timeout) }
    end

    local commands = redis.commands or {}
    if type(commands) ~= 'table' then
        redis.error('invalid type for the commands table')
    end

    local socket = create_connection(merge_defaults(parameters))
    local client = create_client(client_prototype, socket, commands)

    return client
end

function redis.command(cmd, opts)
    return command(cmd, opts)
end

-- obsolete
function redis.define_command(name, opts)
    define_command_impl(redis.commands, name, opts)
end

-- obsolete
function redis.undefine_command(name)
    undefine_command_impl(redis.commands, name)
end

-- ############################################################################

-- Commands defined in this table do not take the precedence over
-- methods defined in the client prototype table.

redis.commands = {
    -- commands operating on the key space
    exists           = command('EXISTS', {
        response = toboolean
    }),
    del              = command('DEL'),
    type             = command('TYPE'),
    rename           = command('RENAME'),
    renamenx         = command('RENAMENX', {
        response = toboolean
    }),
    expire           = command('EXPIRE', {
        response = toboolean
    }),
    pexpire          = command('PEXPIRE', {     -- >= 2.6
        response = toboolean
    }),
    expireat         = command('EXPIREAT', {
        response = toboolean
    }),
    pexpireat        = command('PEXPIREAT', {   -- >= 2.6
        response = toboolean
    }),
    ttl              = command('TTL'),
    pttl             = command('PTTL'),         -- >= 2.6
    move             = command('MOVE', {
        response = toboolean
    }),
    dbsize           = command('DBSIZE'),
    persist          = command('PERSIST', {     -- >= 2.2
        response = toboolean
    }),
    keys             = command('KEYS', {
        response = function(response)
            if type(response) == 'string' then
                -- backwards compatibility path for Redis < 2.0
                local keys = {}
                response:gsub('[^%s]+', function(key)
                    table.insert(keys, key)
                end)
                response = keys
            end
            return response
        end
    }),
    randomkey        = command('RANDOMKEY'),
    sort             = command('SORT', {
        request = sort_request,
    }),
    scan             = command('SCAN', {        -- >= 2.8
        request = scan_request,
    }),

    -- commands operating on string values
    set              = command('SET'),
    setnx            = command('SETNX', {
        response = toboolean
    }),
    setex            = command('SETEX'),        -- >= 2.0
    psetex           = command('PSETEX'),       -- >= 2.6
    mset             = command('MSET', {
        request = mset_filter_args
    }),
    msetnx           = command('MSETNX', {
        request  = mset_filter_args,
        response = toboolean
    }),
    get              = command('GET'),
    mget             = command('MGET'),
    getset           = command('GETSET'),
    incr             = command('INCR'),
    incrby           = command('INCRBY'),
    incrbyfloat      = command('INCRBYFLOAT', { -- >= 2.6
        response = function(reply, command, ...)
            return tonumber(reply)
        end,
    }),
    decr             = command('DECR'),
    decrby           = command('DECRBY'),
    append           = command('APPEND'),       -- >= 2.0
    substr           = command('SUBSTR'),       -- >= 2.0
    strlen           = command('STRLEN'),       -- >= 2.2
    setrange         = command('SETRANGE'),     -- >= 2.2
    getrange         = command('GETRANGE'),     -- >= 2.2
    setbit           = command('SETBIT'),       -- >= 2.2
    getbit           = command('GETBIT'),       -- >= 2.2
    bitop            = command('BITOP'),        -- >= 2.6
    bitcount         = command('BITCOUNT'),     -- >= 2.6

    -- commands operating on lists
    rpush            = command('RPUSH'),
    lpush            = command('LPUSH'),
    llen             = command('LLEN'),
    lrange           = command('LRANGE'),
    ltrim            = command('LTRIM'),
    lindex           = command('LINDEX'),
    lset             = command('LSET'),
    lrem             = command('LREM'),
    lpop             = command('LPOP'),
    rpop             = command('RPOP'),
    rpoplpush        = command('RPOPLPUSH'),
    blpop            = command('BLPOP'),        -- >= 2.0
    brpop            = command('BRPOP'),        -- >= 2.0
    rpushx           = command('RPUSHX'),       -- >= 2.2
    lpushx           = command('LPUSHX'),       -- >= 2.2
    linsert          = command('LINSERT'),      -- >= 2.2
    brpoplpush       = command('BRPOPLPUSH'),   -- >= 2.2

    -- commands operating on sets
    sadd             = command('SADD'),
    srem             = command('SREM'),
    spop             = command('SPOP'),
    smove            = command('SMOVE', {
        response = toboolean
    }),
    scard            = command('SCARD'),
    sismember        = command('SISMEMBER', {
        response = toboolean
    }),
    sinter           = command('SINTER'),
    sinterstore      = command('SINTERSTORE'),
    sunion           = command('SUNION'),
    sunionstore      = command('SUNIONSTORE'),
    sdiff            = command('SDIFF'),
    sdiffstore       = command('SDIFFSTORE'),
    smembers         = command('SMEMBERS'),
    srandmember      = command('SRANDMEMBER'),
    sscan            = command('SSCAN', {       -- >= 2.8
        request = scan_request,
    }),

    -- commands operating on sorted sets
    zadd             = command('ZADD'),
    zincrby          = command('ZINCRBY', {
        response = function(reply, command, ...)
            return tonumber(reply)
        end,
    }),
    zrem             = command('ZREM'),
    zrange           = command('ZRANGE', {
        request  = zset_range_request,
        response = zset_range_reply,
    }),
    zrevrange        = command('ZREVRANGE', {
        request  = zset_range_request,
        response = zset_range_reply,
    }),
    zrangebyscore    = command('ZRANGEBYSCORE', {
        request  = zset_range_byscore_request,
        response = zset_range_reply,
    }),
    zrevrangebyscore = command('ZREVRANGEBYSCORE', {    -- >= 2.2
        request  = zset_range_byscore_request,
        response = zset_range_reply,
    }),
    zunionstore      = command('ZUNIONSTORE', {         -- >= 2.0
        request = zset_store_request
    }),
    zinterstore      = command('ZINTERSTORE', {         -- >= 2.0
        request = zset_store_request
    }),
    zcount           = command('ZCOUNT'),
    zcard            = command('ZCARD'),
    zscore           = command('ZSCORE'),
    zremrangebyscore = command('ZREMRANGEBYSCORE'),
    zrank            = command('ZRANK'),                -- >= 2.0
    zrevrank         = command('ZREVRANK'),             -- >= 2.0
    zremrangebyrank  = command('ZREMRANGEBYRANK'),      -- >= 2.0
    zscan            = command('ZSCAN', {               -- >= 2.8
        request  = scan_request,
        response = zscan_response,
    }),

    -- commands operating on hashes
    hset             = command('HSET', {        -- >= 2.0
        response = toboolean
    }),
    hsetnx           = command('HSETNX', {      -- >= 2.0
        response = toboolean
    }),
    hmset            = command('HMSET', {       -- >= 2.0
        request  = hash_multi_request_builder(function(args, k, v)
            table.insert(args, k)
            table.insert(args, v)
        end),
    }),
    hincrby          = command('HINCRBY'),      -- >= 2.0
    hincrbyfloat     = command('HINCRBYFLOAT', {-- >= 2.6
        response = function(reply, command, ...)
            return tonumber(reply)
        end,
    }),
    hget             = command('HGET'),         -- >= 2.0
    hmget            = command('HMGET', {       -- >= 2.0
        request  = hash_multi_request_builder(function(args, k, v)
            table.insert(args, v)
        end),
    }),
    hdel             = command('HDEL'),        -- >= 2.0
    hexists          = command('HEXISTS', {     -- >= 2.0
        response = toboolean
    }),
    hlen             = command('HLEN'),         -- >= 2.0
    hkeys            = command('HKEYS'),        -- >= 2.0
    hvals            = command('HVALS'),        -- >= 2.0
    hgetall          = command('HGETALL', {     -- >= 2.0
        response = function(reply, command, ...)
            local new_reply = { }
            for i = 1, #reply, 2 do new_reply[reply[i]] = reply[i + 1] end
            return new_reply
        end
    }),
    hscan            = command('HSCAN', {       -- >= 2.8
        request  = scan_request,
        response = hscan_response,
    }),

    -- connection related commands
    ping             = command('PING', {
        response = function(response) return response == 'PONG' end
    }),
    echo             = command('ECHO'),
    auth             = command('AUTH'),
    select           = command('SELECT'),

    -- transactions
    multi            = command('MULTI'),        -- >= 2.0
    exec             = command('EXEC'),         -- >= 2.0
    discard          = command('DISCARD'),      -- >= 2.0
    watch            = command('WATCH'),        -- >= 2.2
    unwatch          = command('UNWATCH'),      -- >= 2.2

    -- publish - subscribe
    subscribe        = command('SUBSCRIBE'),    -- >= 2.0
    unsubscribe      = command('UNSUBSCRIBE'),  -- >= 2.0
    psubscribe       = command('PSUBSCRIBE'),   -- >= 2.0
    punsubscribe     = command('PUNSUBSCRIBE'), -- >= 2.0
    publish          = command('PUBLISH'),      -- >= 2.0

    -- redis scripting
    eval             = command('EVAL'),         -- >= 2.6
    evalsha          = command('EVALSHA'),      -- >= 2.6
    script           = command('SCRIPT'),       -- >= 2.6

    -- remote server control commands
    bgrewriteaof     = command('BGREWRITEAOF'),
    config           = command('CONFIG', {     -- >= 2.0
        response = function(reply, command, ...)
            if (type(reply) == 'table') then
                local new_reply = { }
                for i = 1, #reply, 2 do new_reply[reply[i]] = reply[i + 1] end
                return new_reply
            end

            return reply
        end
    }),
    client           = command('CLIENT'),       -- >= 2.4
    slaveof          = command('SLAVEOF'),
    save             = command('SAVE'),
    bgsave           = command('BGSAVE'),
    lastsave         = command('LASTSAVE'),
    flushdb          = command('FLUSHDB'),
    flushall         = command('FLUSHALL'),
    monitor          = command('MONITOR'),
    time             = command('TIME'),         -- >= 2.6
    slowlog          = command('SLOWLOG', {     -- >= 2.2.13
        response = function(reply, command, ...)
            if (type(reply) == 'table') then
                local structured = { }
                for index, entry in ipairs(reply) do
                    structured[index] = {
                        id = tonumber(entry[1]),
                        timestamp = tonumber(entry[2]),
                        duration = tonumber(entry[3]),
                        command = entry[4],
                    }
                end
                return structured
            end

            return reply
        end
    }),
    info             = command('INFO', {
        response = parse_info,
    }),
}

-- ############################################################################

return redis -- redis-lua module end -------------------------------------------
end)()

local api = {
  KEYS = {
    type = "value",
    description = "A table with the key names that were explicitly declared for the script to access.\n\nUse the `Command Line Parameters` to provide the script with parameters in `redis-cli --eval`-like format, i.e.: [key1 [key2 [...]]] , [arg1 [arg2 [...]]]"
  },
  ARGV = {
    type = "value",
    description = "A table with the arguments that were passed to the script.\n\nUse the `Command Line Parameters` to provide the script with parameters in `redis-cli --eval`-like format, i.e.: [key1 [key2 [...]]] , [arg1 [arg2 [...]]]"
  },

  redis = {
    type = "lib",
    description = "A library for inteacting with the Redis server in which the script is EVALed.",
    childs = {

      -- Values
      REPL_ALL = {
        type = "value",
        description = "Target setting for calling redis.set_repl() that replicates effects both to AOF and slaves (the default).",
      },
      REPL_AOF  = {
        type = "value",
        description = "Target setting for calling redis.set_repl() that replicates effects only to AOF.",
      },
      REPL_SLAVE = {
        type = "value",
        description = "Target setting for calling redis.set_repl() that replicates effects only to slaves.",
      },
      REPL_NONE = {
        type = "value",
        description = "Target setting for calling redis.set_repl() that disables replication.",
      },

      LOG_DEBUG = {
        type = "value",
        description = "DEBUG loglevel setting for calling redis.log().",
      },
      LOG_VERBOSE = {
        type = "value",
        description = "VERBOSE loglevel setting for calling redis.log().",
      },
      LOG_NOTICE = {
        type = "value",
        description = "NOTICE loglevel setting for calling redis.log().",
      },
      LOG_WARNING = {
        type = "value",
        description = "WARNING loglevel setting for calling redis.log().",
      },

      -- functions
      error_reply = {
        type = "function",
        description = "Returns a Redis error reply.\nThis helper function returns a single field table with the err field set to the specified string.",
        args = "(error: string)",
        returns = "(table)",
      },

      status_reply = {
        type = "function",
        description = "Returns a Redis status reply.\nThis helper function returns a single field table with the ok field set to the specified string.",
        args = "(status: string)",
        returns = "(table)",
      },

      call = {
        type = "function",
        description = "Calls a Redis command, raising errors if any.\nIf the call to the command returns an error, the error will be raised and returned to the caller. To trap the error consider using pcall instead.\nThe arguments should be a well-formed Redis command. The return value depends on the command called and the dataset.",
        args = "(command: string, [arg: string|number, ...])",
        returns = "(number|string|table|boolean)",
      },

      pcall = {
        type = "function",
        description = "Calls a Redis command, trapping errors if any.\nIf the call to the command returns an error, the error will be trapped and the function will return a table with the error. To raise the error to the caller consider using call instead.\nThe arguments should be a well-formed Redis command. The return value depends on the command called and the dataset.",
        args = "(command: string, [arg: string|number, ...])",
        returns = "(number|string|table|boolean)",
      },

      replicate_commands = {
        type = "function",
        description = "Enables effects replication mode for the script.\nReturns true if successful.\n",
        args = "()",
        returns = "(boolean)",
      },

      set_repl = {
        type = "function",
        description = "Sets the replication target when in effects replication mode.\n\nAccepts one of the following targets:\n* redis.REPL_ALL: Replicate to AOF and slaves (the default).\n* redis.REPL_AOF: Replicate only to AOF.\n* redis.REPL_SLAVE: Replicate only to slaves.\n* redis.REPL_NONE: Don't replicate at all.",
        args = "(target: redis.REPL_(ALL|AOF|SLAVE|NONE))", -- TODO: replace with something else?
        returns = "()",
      },

      sha1hex = {
        type = "function",
        description = "Performs the SHA1 of the input string.",
        args = "(input: string)",
        returns = "(string)",
      },

      breakpoint = {
        type = "function",
        description = "When in SCRIPT DEBUG mode, stops execution as if a breakpoint is encountered at the next line.",
        args = "()",
        returns = "()",
      },

      debug = {
        type = "function",
        description = "When in SCRIPT DEBUG mode, emits a log message to the debug console.",
        args = "(message: string)",
        returns = "()",
      },

      log = {
        type = "function",
        description = "Emits a log message to the Redis log file.\nAccepst one of the standard Redis loglevels and the message to emit.",
        args = "(loglevel: redis.LOG_(DEBUG|VERBOSE|NOTICE|WARNING), message: string)",
        returns = "()",
      },

    },
  },

  struct = {
    type = "lib",
    description = "A library for packing/unpacking structures within Lua.",
    childs = {
      pack = {
        type = "function",
        description = "Returns an encoded representation of the arguments according to the specified format.\n\nValid formats:\n> - big endian\n< - little endian\n![num] - alignment\nx - padding\nb/B - signed/unsigned byte\nh/H - signed/unsigned short\nl/L - signed/unsigned long\nT   - size_t\ni/In - signed/unsigned integer with size `n' (default is size of int)\ncn - sequence of `n' chars (from/to a string); when packing, n==0 means\n     the whole string; when unpacking, n==0 means use the previous\n     read number as the string length\ns - zero-terminated string\nf - float\nd - double\n' ' - ignored",
        args = "(format: string, ...)",
        returns = "(string)",
      },
      unpack = {
        type = "function",
        description = "Returns the values from an encoded representation according to the specified format.\n\nValid formats:\n> - big endian\n< - little endian\n![num] - alignment\nx - padding\nb/B - signed/unsigned byte\nh/H - signed/unsigned short\nl/L - signed/unsigned long\nT   - size_t\ni/In - signed/unsigned integer with size `n' (default is size of int)\ncn - sequence of `n' chars (from/to a string); when packing, n==0 means\n     the whole string; when unpacking, n==0 means use the previous\n     read number as the string length\ns - zero-terminated string\nf - float\nd - double\n' ' - ignored",
        args = "(format: string, struct: string)",
        returns = "(table)", -- TODO: the table also has an additional element???
      },
      size = {
        type = "function",
        description = "Returns the size of the encoded representation according to the specified format.\n\nValid formats:\n> - big endian\n< - little endian\n![num] - alignment\nx - padding\nb/B - signed/unsigned byte\nh/H - signed/unsigned short\nl/L - signed/unsigned long\nT   - size_t\ni/In - signed/unsigned integer with size `n' (default is size of int)\ncn - sequence of `n' chars (from/to a string); when packing, n==0 means\n     the whole string; when unpacking, n==0 means use the previous\n     read number as the string length\ns - zero-terminated string\nf - float\nd - double\n' ' - ignored",
        args = "(format: string)",
        returns = "(number)"
      },
    },
  },

  cjson = {
    type = "lib",
    description = "A library for encoding/decoding JSON.",
    childs = {
      encode = {
        type = "function",
        description = "Encodes a Lua table as a JSON document.",
        args = "(input: table)",
        returns = "(string)",
      },
      decode = {
        type = "function",
        description = "Converts a JSON document to a Lua table.",
        args = "(JSON: string)",
        returns = "(table)",
      },
    },
  },

  cmsgpack = {
    type = "lib",
    description = "A library for encoding/decoding MessagePack.",
    childs = {
      pack = {
        type = "function",
        description = "Encodes a Lua table as a MessagePack.",
        args = "(input: table)",
        returns = "(string)",
      },
      unpack = {
        type = "function",
        description = "Converts a MessagePack to a Lua table.",
        args = "(msgpack: string)",
        returns = "(table)",
      },
    },
  },

  bit = {
    type = "lib",
    description = "A library for bitwise operations on numbers.",
    childs = {
      tobit = {
        type = "function",
        description = "Normalizes a number to the numeric range for bit operations and returns it.",
        args = "(x: number)",
        returns = "(number)",
      },
      tohex = {
        type = "function",
        description = "Converts its first argument to a hex string. The number of hex digits is given by the absolute value of the optional second argument. Positive numbers between 1 and 8 generate lowercase hex digits. Negative numbers generate uppercase hex digits. Only the least-significant 4*|n| bits are used. The default is to generate 8 lowercase hex digits.",
        args = "(x: number [, n: number])",
        returns = "(string)",
      },
      bnot = {
        type = "function",
        description = "Returns the bitwise not of its argument.",
        args = "(x: number)",
        returns = "(number)",
      },
      band = {
        type = "function",
        description = "Returns the bitwise and of all of its arguments.",
        args = "(x1: number [, x2: number, ...])",
        returns = "(number)",
      },
      bor = {
        type = "function",
        description = "Returns the bitwise or of all of its arguments.",
        args = "(x1: number [, x2: number, ...])",
        returns = "(number)",
      },
      bxor = {
        type = "function",
        description = "Returns the bitwise xor of all of its arguments.",
        args = "(x1: number [, x2: number, ...])",
        returns = "(number)",
      },
      lshift = {
        type = "function",
        description = "Returns the bitwise logical left-shift of its first argument by the number of bits given by the second argument.\nTreats the first argument as an unsigned number and shifts in 0-bits.\nOnly the lower 5 bits of the shift count are used (reduces to the range [0..31]).",
        args = "(x: number, n: number)",
        returns = "(number)",
      },
      rshift = {
        type = "function",
        description = "Returns the bitwise logical right-shift of its first argument by the number of bits given by the second argument.\nTreats the first argument as an unsigned number and shifts in 0-bits.\nOnly the lower 5 bits of the shift count are used (reduces to the range [0..31]).",
        args = "(x: number, n: number)",
        returns = "(number)",
      },
      arshift = {
        type = "function",
        description = "Returns the bitwise arithmetic right-shift of its first argument by the number of bits given by the second argument.\nTreats the most-significant bit as a sign bit and replicates it.\nOnly the lower 5 bits of the shift count are used (reduces to the range [0..31]).",
        args = "(x: number, n: number)",
        returns = "(number)",
      },
      rol = {
        type = "function",
        description = "Returns the bitwise left rotation of its first argument by the number of bits given by the second argument.\nBits shifted out on one side are shifted back in on the other side.\nOnly the lower 5 bits of the rotate count are used (reduces to the range [0..31]).",
        args = "(x: number, n: number)",
        returns = "(number)",
      },
      ror = {
        type = "function",
        description = "Returns the bitwise right rotation of its first argument by the number of bits given by the second argument.\nBits shifted out on one side are shifted back in on the other side.\nOnly the lower 5 bits of the rotate count are used (reduces to the range [0..31]).",
        args = "(x: number, n: number)",
        returns = "(number)",
      },
      bswap = {
        type = "function",
        description = "Swaps the bytes of its argument and returns it.",
        args = "(x: number)",
        returns = "(number)",
      },
    },
  },
}

local function isinstance(host, port, password)
  local ok, res = pcall(redis.connect, {host = host, port = port, timeout = 0.5})
  if not ok then return nil, res:match("%[(.+)%]") end

  -- check if the instance is password protected
  local client = res
  ok, res = pcall(client.ping, client)
  if not ok and res:find("NOAUTH")
  and (not password or not pcall(client.auth, client, password)) then
    while true do
      password = password or wx.wxGetPasswordFromUser("Enter Redis password", "Redis authentication")
      if not password or password == "" then return end
      if pcall(client.auth, client, password) then break end
      password = nil
    end
  end

  client:quit()
  return true, password
end

local pkg
local address, password
local interpreter = {
  name = "Redis",
  description = "Redis interpreter",
  api = {"baselib", "redis"},
  frun = function(self,wfilename,rundebug)
    if not pkg then
      ide:Print("Can't get package configuration.")
      return
    end
    local filepath = wfilename:GetFullPath()
    if ide.osname == 'Windows' then
      -- if running on Windows and can't open the file, this may mean that
      -- the file path includes unicode characters that need special handling
      local fh = io.open(filepath, "r")
      if fh then fh:close() end
      if pcall(require, "winapi") and wfilename:FileExists() and not fh then
        winapi.set_encoding(winapi.CP_UTF8)
        filepath = winapi.short_path(filepath)
      end
    end
    local defaddress = pkg:GetSettings().address or "redis://localhost:6379"
    while true do
      defaddress = address or wx.wxGetTextFromUser(
        "Database URI ([redis://][username:password@]hostname:port[/db])", "Redis server", defaddress)
      -- Redis currently ignores the username, should change with RCP1
      if not defaddress or defaddress == "" then return end
      local uri = require('socket.url').parse(defaddress)
      if uri.scheme ~= "redis" then return end -- need to think about support for sockets, future support for SSL/TLS
      if not uri.host or not uri.port then
        ide:Print(("Can't get host name or port number from URI '%s'."):format(defaddress))
      else
        local ok, err = isinstance(uri.host, uri.port, (password or uri.password))
        if ok then
          address, password = uri.scheme .. "://" .. uri.host .. ":" .. uri.port .. (uri.path or ""), err
          pkg:SetSettings({address = address})
          break
        elseif err then
          ide:Print(("Can't connect to Redis instance '%s': %s."):format(defaddress, err))
          address = nil
        else -- cancelled authentication
          return
        end
      end
    end

    if rundebug then ide:GetDebugger():SetOptions() end
    local pkgcfg = pkg:GetConfig()
    local redis = " --instance "..address
    local controller = " --controller "..ide:GetDebugger():GetHostName()..":"..ide:GetDebugger():GetPortNumber()
    local rundebug = " --debug " .. (rundebug and (pkgcfg.debugmode or "yes") or "no")
    local pswd = password and (" --password %q"):format(password) or ""
    local verbose = pkgcfg.verbose and " --verbose" or ""
    local maxlen = pkgcfg.maxlen and (" --maxlen %s"):format(pkgcfg.maxlen) or ""
    local cfg = ide:GetConfig()
    local params = cfg.arg.any or cfg.arg.redis
    local exe = ide:GetInterpreters().luadeb:GetExePath("")
    local cmd = ('"%s" "%s"%s "%s"%s'):format(exe, pkg:GetFilePath(),
      table.concat({redis, controller, rundebug, pswd, maxlen, verbose}, ""),
      filepath, params and " "..params or "")

    -- CommandLineRun(cmd,wdir,tooutput,nohide,stringcallback,uid,endcallback)
    return CommandLineRun(cmd,self:fworkdir(wfilename),true,false)
  end,
  hasdebugger = true,
  fattachdebug = function(self) ide:GetDebugger():SetOptions() end,
  unhideanywindow = false,
  takeparameters = true,
}

local name = "redis"
local package = {
  name = "Redis",
  description = "Integrates with Redis.",
  author = "Paul Kulchenko",
  version = 0.38,
  dependencies = "1.30",

  onRegister = function(self)
    pkg = self
    ide:AddInterpreter(name, interpreter)
    ide:AddAPI("lua", name, api)
  end,
  onUnRegister = function(self)
    ide:RemoveInterpreter(name)
    ide:RemoveAPI("lua", name)
  end,
}

if pcall(debug.getlocal, 4, 1) then return package end

-- main redis script -----------------------------------------------------------

io.stdout:setvbuf('no')

local unpack = unpack or table.unpack
local controller, instance = "localhost:8172", "redis://localhost:6379"
local verbose, maxlen, debugmode, password, params, file = false
while #arg > 0 do
  local a = table.remove(arg, 1)
  if a == "--instance" then
    instance = table.remove(arg, 1)
  elseif a == "--controller" then
    controller = table.remove(arg, 1)
  elseif a == "--debug" then
    debugmode = table.remove(arg, 1)
  elseif a == "--password" then
    password = table.remove(arg, 1)
  elseif a == "--maxlen" then
    maxlen = table.remove(arg, 1)
  elseif a == "--verbose" then
    verbose = true
  else
    file, params, arg = a, arg, {}
  end
end

local function check(cond, msg, ...)  -- uses "file" name to inject into the error message
  if cond or msg == nil then return cond, msg, ... end
  print(type(msg) == 'string' and msg:gsub("(.+): @?user_script:%s*", "%1\n"..file..":") or msg)
  os.exit(1)
end

check(file, "Required file name is not provided")

-- read the script
local fh, err = io.open(file, "rb")
check(fh, ("Can't open file '%s' for reading: %s"):format(file, err))
local code = fh:read("*a")
fh:close()

local function getval(response, keyword, format, keep)
  if type(response) ~= 'table' then return end
  local res = {}
  for _, v in ipairs(response) do
    if type(v) == "string" and v:find("^"..keyword) then
      table.insert(res, (format or "%q"):format(keep and v or v:gsub(keyword.." ","")))
    end
  end
  return res
end
local function getline(response)
  local line = getval(response, "* Stopped at")
  return line and line[1] and line[1]:match("(%d+)")
end
local function getretval(response)
  return "return {"..table.concat(getval(response, "<retval>") or {}, ",").."}"
end
local function getreply(response)
  local msg = table.concat(getval(response, "<reply>", "%s") or {}, ",")
  -- add proper quoting to those messages that may be truncated because of `maxlen` limit
  if msg:find('^"') and not msg:find('"$') then msg = msg..'"' end
  return ("return {%s}"):format(
    -- show returned NULL as `nil`
    msg == "NULL" and "'nil'" or
    -- show array (`[...]`) results from commands like `@hgetall something` as is
    msg:find("^%[.+%]$") and ("%q"):format(msg) or
    msg
  )
end
local function geterror(response)
  local err = getval(type(response) == 'table' and response or {response}, "<error>")
  return err and #err > 0 and err[1] or nil
end
local function isdone(response)
  local endsession = getval(response, "<endsession>")
  return endsession and #endsession > 0
end
local function isfilesame(fullname, fname, basedir)
  local sep = "/"
  fullname = fullname:gsub("[\\/]", sep)
  basedir = basedir:gsub("[\\/]", sep):gsub(sep.."+$", "")..sep
  return fullname == fname or fullname == basedir .. fname
end
local function removebasedir(fullname, basedir)
  local sep = "/"
  fullname = fullname:gsub("[\\/]", sep)
  basedir = basedir:gsub("[\\/]", sep):gsub(sep.."+$", "")..sep
  return (fullname:gsub(basedir, ""))
end
local function getvars(response)
  if type(response) ~= 'table' then return end
  -- convert reported values to local definitions; skip internal variables;
  -- best effort handling of maxlen trimmed values; skip hints
  -- <value> (for index) = 1
  -- <value> (for limit) = 20
  -- <value> i = 1
  -- <value> s = "foobar"
  -- <value> t = {}
  -- <value> trimmed_string = "abc ...
  -- <value> trimmed_table = { "abc"; "ef ...
  -- <hint> some text
  local res = {}
  for _, v in ipairs(response) do
    if not v:find("<value> %(") and not v:find("<hint>") then
      local val = v:gsub("<value> ", "")
      if val:sub(-3) == "..." then -- trimmed string or table
        local msg = "[[contents trimmed, consider using `maxlen 0`]]"
        local var, valtype = val:match("^([%p%w]+)%s+=%s+(.)")
        if valtype == "\"" then
          val = val .. " " .. msg .. "\""
        elseif valtype == "{" then
          val = var .. " = {\"" .. msg .. "\"}"
        end
      end
      if loadstring("local "..val) then table.insert(res, val) end
    end
  end
  return res
end
local function getvarsaslocals(response, max)
  local vars = getvars(response)
  if not vars or #vars == 0 then return "" end

  local s = ""
  for _, v in ipairs(vars) do
    local news = s.."local "..v..";"
    if max and #news > max then break end
    s = news
  end
  return s
end
local function getvarsastable(response)
  local vars = getvars(response)
  for k, v in pairs(vars) do
    local name, val = v:match("^([%p%w]+)%s+=%s+(.+)")
    vars[k] = ("%s={%s,%s}"):format(name, val, val:find("^{") and "'table'" or "nil")
  end
  return "{"..table.concat(vars, "; ").."}"
end
local function reportdebug(response)
  local deb = getval(response, "<debug>", "%s", true)
  if deb and #deb > 0 then print(table.concat(deb, "\n")) end
  local hint = getval(response, "<hint>", "%s", true)
  if hint and #hint > 0 then print(table.concat(hint, "\n")) end
end
local function reportresult(client, msg)
  local function report(msg)
    if type(msg) ~= 'table' then return print(msg) end
    for _, v in ipairs(msg) do report(v) end
  end
  report(msg or check(client:echo('nil'))) -- this will also report any errors
  client:quit()
end

-- get redis instance host:port
local uri = require("socket.url").parse(instance)
local host, port, db = uri.host, uri.port, uri.path
check(host and port, ("Unknown Redis URI format '%s'; expected [redis://][username:password@]hostname:port[/db]"):format(instance))

-- register Redis debugger commands
for key, command in pairs({continue = 'C', step = 'S', breakpoint = 'B', maxlen = 'M',
  abort = 'A', print = 'P', eval = 'E', trace = 'T', redis = 'R'}) do
  redis.commands['ldb'..key] = redis.command(command)
end

-- connect to redis instance
local ok, err = pcall(redis.connect, {host = host, port = port, timeout = nil})
check(ok, ("Can't connect to Redis instance '%s': %s.")
  :format(instance, type(err) == "string" and err:match("%[(.+)%]" or "Unknown error")))
local client = err
client.error = function(error) return nil, (error:gsub(".*ERR ","")) end
if verbose then
  local function formatmsg(msg)
    return (("%q"):format(msg)
      :gsub("\010","n"):gsub("\\0?13","\\r"):gsub("\026","\\026"):gsub('^"',""):gsub('"$',""))
  end
  client.onread  = function(self, line, err) print("<redis rcvd> "..(line and formatmsg(line) or "error: "..err)) end
  client.onwrite = function(self, line, err) print("<redis sent> "..(line and formatmsg(line) or "error: "..err)) end
end

-- authenticate if password is provided
local msg, err = client:ping()
if not msg and err:find("NOAUTH") then
  check(password, "Authentication is required, but no password is provided to authenticate.")
  check(client:auth(password), "Can't autheticate with the provided password.")
end

-- select database, if any
if db then
  msg, err = check(client:select(db:sub(2)))
end

local isdebugging = debugmode ~= "no"

msg, err = check(client:info("SERVER"))
local version = msg and tonumber(((msg.server or {}).redis_version or ""):match("%d+.%d+"))
local needversion = 3.2
if isdebugging and version and version < needversion then
  print(("Detected Redis server v%s, but need v%s+ for the debugging to work.")
    :format(version, needversion))
end

-- start debugging
if isdebugging then
  msg, err = check(client:script("DEBUG", debugmode))
end

-- split passed parameters into KEYS and ARGV (separated by ',')
local keys = #params
for n, v in ipairs(params) do
  if v == ',' then
    keys = n - 1
    table.remove(params, n)
    break
  end
end

-- if @file is requested, insert file contents
for n, v in ipairs(params) do
  if string.sub(v,1,6) == "@file:" then
    local f = assert(io.open(string.sub(v,7), "rb"))
    params[n] = f:read("*all")
    f:close()
  end
end

-- load the script to debug; check for any reported errors
msg, err = check(client:eval(code, keys, unpack(params)))

-- if no debugging is requested, nothing else is needed to be done
if not isdebugging then
  reportresult(client, msg)
  os.exit(0)
end

-- set maxlen if provided
if maxlen then msg, err = check(client:ldbmaxlen(maxlen)) end

-- connect to the debugger
local server, err = require("socket").tcp()
check(server, ("Can't open socket: %s"):format(err))

host, port = controller:match("^%s*(.+):(%d+)%s*$")
check(host and port, ("Unknown debugger address format '%s'; expected host:port."):format(controller))

if server.settimeout then server:settimeout(2) end
local ok, err = server:connect(host, port)
if server.settimeout then server:settimeout() end
check(ok, ("Can't connect to the debugger at '%s': %s"):format(controller, err))

-- main debugger loop
local basedir = ""
while true do
  local line = check(server:receive())
  local command = string.sub(line, string.find(line, "^[A-Z]+"))
  if command == "SETB" or command == "DELB" then
    local _, _, _, lfile, line = string.find(line, "^([A-Z]+)%s+(.-)%s+(%d+)%s*$")
    if lfile and line then
      line = line == "0" and 0 or (command == "SETB" and tonumber(line) or -tonumber(line))
      -- deleting from file '*' deletes all breakpoints
      if lfile == '*' or isfilesame(file, lfile, basedir) then client:ldbbreakpoint(line) end
      server:send("200 OK\n")
    else
      server:send("400 Bad Request\n")
    end
  elseif command == "BASEDIR" then
    local _, _, dir = string.find(line, "^[A-Z]+%s+(.+)%s*$")
    if dir then
      basedir = dir
      server:send("200 OK\n")
    else
      server:send("400 Bad Request\n")
    end
  elseif command == "LOAD" then
    local _, _, size = string.find(line, "^[A-Z]+%s+(%d+)%s+(%S.-)%s*$")
    size = tonumber(size)

    if size > 0 then server:receive(size) end
    server:send("201 Started " .. file .. " " .. (getline(msg) or 1) .. "\n")
  elseif command == "RUN" or command == "STEP" or command == "OVER" or command == "OUT" then
    server:send("200 OK\n")
    local msg, err
    if command == "RUN" then
      msg, err = check(client:ldbcontinue())
    else
      msg, err = check(client:ldbstep())
      -- check if this is the last step stopped at "out of range" position; do one more step
      local outofrange = msg and getval(msg, "->%s+%d+%s+<out of range")
      if outofrange and #outofrange > 0 then
        reportdebug(msg)
        msg, err = check(client:ldbstep())
      end
    end
    reportdebug(msg)
    if isdone(msg) then
      reportresult(client)
      break
    end
    server:send("202 Paused " .. file .. " " .. (getline(msg) or 0) .. "\n")
  elseif command == "DONE" then
    check(client:ldbbreakpoint(0)) -- remove all breakpoints
    local msg = check(client:ldbcontinue()) -- continue with the script
    reportdebug(msg)
    if isdone(msg) then reportresult(client) end
    break
  elseif command == "EXEC" then
    local _, _, chunk = string.find(line, "^[A-Z]+%s+(.+)$")
    if chunk then
      chunk = chunk:gsub("%-%-%s*%b{}","") -- remove eval options if present
      local func, err = loadstring(chunk)
      if not func then
        local chunkr = "return "..chunk
        func = loadstring(chunkr)
        if func then chunk = chunkr end
      end
      if func then
        -- evaluation is done in a different environment, so capture local variables
        -- to use in the chunk evaluation to make their values available
        local vars = getvarsaslocals(check(client:ldbprint()), 1024-#chunk)
        local msg, err = client:ldbeval(vars..chunk)
        -- if there is an error, try without preamble, as it has a better error message
        if msg and geterror(msg) then msg, err = client:ldbeval(chunk) end
        if msg and geterror(msg) then msg, err = nil, geterror(msg) end
        if msg then
          reportdebug(msg)
          -- if the chunk starts from "return" or looks like an expression, then return the result
          -- otherwise don't return any values to avoid showing `nil` after statements.
          msg = (chunk:find("^return ") or loadstring("return "..chunk)) and getretval(msg) or "return {}"
          server:send("200 OK " .. tostring(#msg) .. "\n")
          server:send(msg)
        else
          server:send("401 Error in Expression " .. tostring(#err) .. "\n")
          server:send(err)
        end
      elseif chunk:find("^[A-Z][A-Z]+%s") or chunk:find("^@") then
        chunk = chunk:gsub("^@", "")
        local cmd = {}
        for v in chunk:gmatch("(%S+)") do table.insert(cmd, v) end
        local msg, err = client:ldbredis(unpack(cmd))
        if msg and geterror(msg) then msg, err = nil, geterror(msg) end
        if msg then
          reportdebug(msg)
          msg = getreply(msg)
          server:send("200 OK " .. tostring(#msg) .. "\n")
          server:send(msg)
        else
          server:send("401 Error in Expression " .. tostring(#err) .. "\n")
          server:send(err)
        end
      else
        server:send("401 Error in Expression " .. tostring(#err) .. "\n")
        server:send(err)
      end
    else
      server:send("400 Bad Request\n")
    end
  elseif command == "STACK" then
    -- get stack information and repackage it in the expected format
    local msg = check(client:ldbtrace())
    local fname = removebasedir(file, basedir)
    local stack = {}
    local vars = getvarsastable(check(client:ldbprint()))
    while #msg > 0 do
      local frame = table.remove(msg, 1)
      local line = table.remove(msg, 1)
      local top = frame:match("^In (.-):")
      local funcname = frame:match("^In (.-):") or frame:match("^From (.-):")
      if funcname == "top level" then
        funcname = #msg > 0 and "anonymous function" or "main chunk"
      end
      local _, _, curline = line:find("(%d+)", 4)
      table.insert(stack, ("{{%q, %q, -1, %d, 'main chunk', '', ''}, %s, {}}")
          :format(funcname, fname, curline or 0, top and vars or "{}"))
    end
    server:send("200 OK " .. "return {"..table.concat(stack, ',').."}" .. "\n")
  elseif command == "EXIT" then
    client:ldbabort()
    server:send("200 OK\n")
    break
  elseif command == "SETW" or command == "DELW" or command == "SUSPEND" or command == "OUTPUT" then
    server:send("200 OK\n")
  else
    server:send("400 Bad Request\n")
  end
end

--[[ configuration example:
redis = {debugmode = "sync"} -- set debug mode to "sync" (the default debug mode is "yes")
redis = {verbose = true} -- set verbose output to show all commands sent to or receivd from Redis
redis = {maxlen = 10000} -- set `maxlen` value during debugging
-- several settings can be combined:
redis = {maxlen = 10000, verbose = true}
--]]
