local skynet = require "skynet"
local redis = require "skynet.db.redis"
local crypt = require "skynet.crypt"

local math_floor = math.floor
local math_ceil = math.ceil
local math_random = math.random

local function hash(script)
    local key = crypt.sha1(script)
    return crypt.hexencode(key)
end

local QUORUM

local SCRIPT = {
    LOCK = [[
        local key = KEYS[1]
        if redis.call("exists", key) == 1 then
            return 0
        end
        redis.call("set", key, ARGV[1], "PX", ARGV[2])
        return 1
    ]],
    UNLOCK = [[
        local key = KEYS[1]
        if redis.call("get", key) == ARGV[1] then
            redis.pcall("del", key)
            return 1
        end
        return 0
    ]],
    EXTEND = [[
        local key = KEYS[1]
        if redis.call("get", key) ~= ARGV[1] then
            return 0
        end
        redis.call("set", key, ARGV[1], "PX", ARGV[2])
        return 1
    ]],
}

local SCRIPT_HASH = {
    LOCK = hash(SCRIPT.LOCK),
    UNLOCK = hash(SCRIPT.UNLOCK),
    EXTEND = hash(SCRIPT.EXTEND),
}

local conf
local dbs = {}
local sessions = {}

local function execute_script(db, type, s)
    local ok, ret = pcall(db["evalsha"], db, SCRIPT_HASH[type], 1, s.lockname, s.uuid, s.timeout)
    if not ok and ret:find("NOSCRIPT") then
        ok, ret = pcall(db["eval"], db, SCRIPT[type], 1, s.lockname, s.uuid, s.timeout)
    end
    if not ok then
        skynet.error("redis execute_script err.", ret, s.lockname, s.uuid, s.timeout)
        return false
    end
    if ret == 1 then
        return true
    end
    return false
end

local function execute_script_timeout(db, type, s)
    local co = coroutine.running()
    local ok, ret = false, "timeout"

    skynet.fork(function()
        ok, ret = execute_script(db, type, s)
        if co then
            skynet.wakeup(co)
            co = nil
        end
    end)

    skynet.sleep(conf.request_timeout / 10)
    if co then
        co = nil
    end
    return ok, ret
end

local function calc_time(s)
    local now = skynet.now() * 10
    local drift = math_floor(conf.drift_factor * s.timeout) + 2
    s.starttime = now
    s.endtime = now + s.timeout - drift
end

local function make_session(lockname, uuid, timeout)
    local s = {
        lockname = lockname,
        uuid = uuid,
        timeout = timeout,
        attempts = 0,
        starttime = 0,
        endtime = 0,
    }
    calc_time(s)
    return s
end

local function unlock(s)
    s.endtime = 0
    for _, db in pairs(dbs) do
        execute_script(db, "UNLOCK", s)
    end
end

local function attempt(s, is_extend)
    s.attempts = s.attempts + 1
    local votes = 0
    for _, db in pairs(dbs) do
        local ok
        if is_extend then
            ok = execute_script_timeout(db, "EXTEND", s)
        else
            ok = execute_script_timeout(db, "LOCK", s)
        end
        if ok then
            votes = votes + 1
        end
    end

    local now = skynet.now() * 10
    if votes >= QUORUM and s.endtime > now then
        local ti = s.timeout / 3 - (now - s.starttime)
        ti = math_floor(ti / 10)
        if ti < 0 then
            ti = 0
        end
        skynet.timeout(ti, function()
            if s.endtime == 0 then
                return
            end
            s.attempts = 0
            calc_time(s)
            attempt(s, true)
        end)
        return true
    else
        unlock(s)
        -- retry
        if conf.retry_count == -1 or s.attempts <= conf.retry_count then
            local t = conf.retry_delay + math_floor((math_random() * 2 - 1) * conf.retry_jitter)
            skynet.sleep(math_ceil(t / 10))
            calc_time(s)
            return attempt(s)
        end
        -- failed
        sessions[s.uuid] = nil
        return false, "timeout"
    end
end

local CMD = {}

function CMD.lock(lockname, uuid, timeout)
    timeout = timeout or conf.timeout
    local s = sessions[uuid]
    if s then
        return false, "session exist"
    end
    s = make_session(lockname, uuid, timeout)
    sessions[uuid] = s

    return attempt(s)
end

function CMD.unlock(_, uuid)
    local s = sessions[uuid]
    if not s then
        return false, "session not exist."
    end
    sessions[uuid] = nil
    return unlock(s)
end

skynet.init(function()
    conf = require "redlock_conf"
    for _, client in ipairs(conf.servers) do
        table.insert(dbs, redis.connect(client))
    end
    QUORUM = math_floor(#conf.servers / 2) + 1
end)

skynet.start(function()
    skynet.dispatch("lua", function(_, _, cmd, ...)
        local f = CMD[cmd]
        assert(f, cmd)
        skynet.retpack(f(...))
    end)
end)

