-- InputEvent
-- https://github.com/natural-harmonia-gropius/input-event

local utils = require("mp.utils")
local options = require("mp.options")

local o = {
    configs = "input.conf",
    prefix = "event,@",
}

local bind_map = {}

local event_pattern = {
    { to = "penta_click",  from = "down,up,down,up,down,up,down,up,down,up", length = 10 },
    { to = "quatra_click", from = "down,up,down,up,down,up,down,up",         length = 8 },
    { to = "triple_click", from = "down,up,down,up,down,up",                 length = 6 },
    { to = "double_click", from = "down,up,down,up",                         length = 4 },
    { to = "click",        from = "down,up",                                 length = 2 },
    { to = "press",        from = "down",                                    length = 1 },
    { to = "release",      from = "up",                                      length = 1 },
}

local supported_events = {
    ["repeat"] = true
}
for _, value in ipairs(event_pattern) do
    supported_events[value.to] = true
end

-- https://mpv.io/manual/master/#input-command-prefixes
local prefixes = { "osd-auto", "no-osd", "osd-bar", "osd-msg", "osd-msg-bar", "raw", "expand-properties",
    "repeatable", "nonrepeatable", "async", "sync" }

-- https://mpv.io/manual/master/#list-of-input-commands
local commands = { "set", "cycle", "add", "multiply" }

function table:push(element)
    self[#self + 1] = element
    return self
end

function table:assign(source)
    for key, value in pairs(source) do
        self[key] = value
    end
    return self
end

function table:has(element)
    for _, value in ipairs(self) do
        if value == element then
            return true
        end
    end
    return false
end

function table:filter(filter)
    local nt = {}
    for index, value in ipairs(self) do
        if (filter(index, value)) then
            nt = table.push(nt, value)
        end
    end
    return nt
end

function table:join(separator)
    local result = ""
    for i, v in ipairs(self) do
        local value = type(v) == "string" and v or tostring(v)
        local semi = i == #self and "" or separator
        result = result .. value .. semi
    end
    return result
end

function string:trim()
    return (self:gsub("^%s*(.-)%s*$", "%1"))
end

function string:replace(pattern, replacement)
    local result, n = self:gsub(pattern, replacement)
    return result
end

function string:split(separator)
    local fields = {}
    local separator = separator or ":"
    local pattern = string.format("([^%s]+)", separator)
    local copy = self:gsub(pattern, function(c) fields[#fields + 1] = c end)
    return fields
end

function debounce(func, wait)
    func = type(func) == "function" and func or function() end
    wait = type(wait) == "number" and wait / 1000 or 0

    local timer = nil
    local timer_end = function()
        if timer then
            timer:kill()
            timer = nil
        end
        func()
    end

    return function()
        if timer then
            timer:kill()
        end
        timer = mp.add_timeout(wait, timer_end)
    end
end

function now()
    return mp.get_time() * 1000
end

function command(command)
    if not command or command == "" then return true end
    return mp.command(command)
end

function command_split(command)
    local separator = { ";" }
    local escape = { "\\" }
    local quotation = { '"', "'" }
    local quotation_stack = {}
    local result = {}
    local temp = ""

    for i = 1, #command do
        local char = command:sub(i, i)

        if table.has(separator, char) and #quotation_stack == 0 then
            result = table.push(result, temp)
            temp = ""
        elseif table.has(quotation, char) and not table.has(escape, temp:sub(#temp, #temp)) then
            temp = temp .. char
            if quotation_stack[#quotation_stack] == char then
                quotation_stack = table.filter(quotation_stack, function(i, v) return i ~= #quotation_stack end)
            else
                quotation_stack = table.push(quotation_stack, char)
            end
        else
            temp = temp .. char
        end
    end

    if #temp then
        result = table.push(result, temp)
    end

    return result
end

function command_invert(command)
    local invert = ""
    local command_list = command_split(command)
    for i, v in ipairs(command_list) do
        local trimed = v:trim()
        local subs = trimed:split("%s*")
        local prefix = ""
        local command = ""
        local property = ""

        for index, value in ipairs(subs) do
            if command == "" and table.has(prefixes, value) then
                prefix = prefix .. value .. " "
            elseif command == "" then
                command = value
            elseif property == "" then
                property = value
            end
        end

        if table.has(commands, command) then
            local restore = true
            local use_del = false
            local value = mp.get_property_native(property)
            local semi = i == #command_list and "" or "; "

            if type(value) == 'number' then
                value = tostring(value)
            elseif type(value) == 'boolean' then
                value = value and 'yes' or 'no'
            elseif type(value) == 'string' then
                value = '"' .. value:replace('"', '\\"') .. '"'
            elseif type(value) == 'nil' then
                use_del = true
            else
                mp.msg.warn("the value type of \"" .. property .. "\" is " .. type(value) .. ", can't auto restore.")
                restore = false
            end

            if restore then
                if not use_del then
                    invert = invert .. prefix .. "set" .. " " .. property .. " " .. value .. semi
                else
                    invert = invert .. prefix .. "del" .. " " .. property .. semi
                end
            end
        else
            mp.msg.warn("\"" .. trimed .. "\" doesn't support auto restore.")
        end
    end
    return invert
end

local InputEvent = {}

function InputEvent:new(key, on)
    local Instance = {}
    setmetatable(Instance, self);
    self.__index = self;

    Instance.key = key
    Instance.on = table.assign({ click = "" }, on)
    Instance.queue = {}
    Instance.queue_max = { length = 0 }
    Instance.duration = mp.get_property_number("input-doubleclick-time", 300)
    Instance.ignored = {}

    for _, event in ipairs(event_pattern) do
        if Instance.on[event.to] and event.length > 1 then
            Instance.queue_max = { event = event.to, length = event.length }
            break
        end
    end

    for event, cmd in pairs(Instance.on) do
        if type(cmd) == "table" then
            for index, cmd_part in ipairs(cmd) do
                if type(cmd_part) == "table" then
                    Instance.on[event][index] = table.concat(cmd_part, " ")
                end
            end
            Instance.on[event] = table.concat(Instance.on[event], ";")
        end
    end

    return Instance
end

function InputEvent:emit(event)
    if self.ignored[event] then
        if now() - self.ignored[event] < self.duration then
            return
        end

        self.ignored[event] = nil
    end

    if event == "press" and self.on["release"] == "ignore" then
        self.on["release-auto"] = command_invert(self.on["press"])
    end

    if event == "release" and self.on[event] == "ignore" then
        event = "release-auto"
    end

    if event == "repeat" and self.on[event] == "ignore" then
        event = "click"
    end

    local cmd = self.on[event]
    if not cmd or cmd == "" then
        return
    end

    local expand = mp.command_native({ "expand-text", cmd })
    if #command_split(cmd) == #command_split(expand) then
        cmd = mp.command_native({ "expand-text", cmd })
    else
        mp.msg.warn("Unsafe property-expansion: " .. cmd)
    end

    command(cmd)
end

function InputEvent:handler(event)
    if event == "press" then
        self:handler("down")
        self:handler("up")
        return
    end

    if event == "down" then
        self:ignore("repeat")
    end

    if event == "repeat" then
        self:emit(event)
        return
    end

    if event == "up" then
        if #self.queue == 0 then
            self:emit("release")
            return
        end

        if #self.queue + 1 == self.queue_max.length then
            self.queue = {}
            self:emit(self.queue_max.event)
            return
        end
    end

    if event == "cancel" then
        if #self.queue == 0 then
            self:emit("release")
            return
        end

        table.remove(self.queue)
        return
    end

    self.queue = table.push(self.queue, event)
    self.exec_debounced()
end

function InputEvent:exec()
    if #self.queue == 0 then
        return
    end

    local separator = ","

    local queue_string = table.join(self.queue, separator)
    for _, v in ipairs(event_pattern) do
        if self.on[v.to] then
            queue_string = queue_string:replace(v.from, v.to)
        end
    end

    self.queue = queue_string:split(separator)
    for _, event in ipairs(self.queue) do
        self:emit(event)
    end

    self.queue = {}
end

function InputEvent:ignore(event, timeout)
    timeout = timeout or 0

    self.ignored[event] = now() + timeout
end

function InputEvent:bind()
    self.exec_debounced = debounce(function() self:exec() end, self.duration)
    mp.add_forced_key_binding(self.key, self.key, function(e)
        local event = e.canceled and "cancel" or e.event
        self:handler(event)
    end, { complex = true })
end

function InputEvent:unbind()
    mp.remove_key_binding(self.key)
end

function InputEvent:rebind(diff)
    if type(diff) == "table" then
        self = table.assign(self, diff)
    end

    self:unbind()
    self:bind()
end

function bind(key, on)
    key = #key == 1 and key or key:upper()

    if type(on) == "string" then
        on = utils.parse_json(on)
    end

    if bind_map[key] then
        on = table.assign(bind_map[key].on, on)
        bind_map[key]:unbind()
    end

    bind_map[key] = InputEvent:new(key, on)
    bind_map[key]:bind()
end

function unbind(key)
    local binding = bind_map[key]
    if binding then
        binding:unbind()
        bind_map[key] = nil
    end
end

function bind_from_conf(conf)
    local kv = {}
    for _, line in pairs(conf:split("\n")) do
        line = line:trim()
        if line ~= "" and line:sub(1, 1) ~= "#" then
            local key, cmd, comment = line:trim():match("^([%S]+)%s+(.-)%s+#%s*(.-)$")
            if comment then
                local comments = {}
                for _, item in ipairs(comment:split("#")) do
                    item = item:trim()
                    local prefix, value = item:match("^(.-)%s*:%s*(.-)$")
                    if not prefix then
                        prefix, value = item:match("^(%p)%s*(.-)$")
                    end
                    if prefix then
                        comments[prefix] = value
                    end
                end

                for _, prefix in ipairs(o.prefix:split(",")) do
                    local event = comments[prefix]
                    if event and event ~= "" and supported_events[event] then
                        if not kv[key] then
                            kv[key] = {}
                        end
                        kv[key][event] = cmd
                    end
                end
            end
        end
    end

    local parsed = {}
    for key, on in pairs(kv) do
        table.insert(parsed, { key = key, on = on })
    end

    return parsed
end

function bind_from_json(json)
    local parsed = utils.parse_json(json)
    return parsed
end

function bind_from_options_configs()
    for key, value in pairs(bind_map) do
        unbind(key)
    end

    for index, value in ipairs(o.configs:split(",")) do
        local path = value:trim()
        local content = ""
        local extension = ""
        if path == "input.conf" then
            local input_conf = mp.get_property_native("input-conf")
            path = input_conf == "" and "~~/input.conf" or input_conf
        end
        if (path:match("^memory://")) then
            content = path:replace("^memory://", "")
            extension = "conf"
        else
            path = mp.command_native({ "expand-path", path })
            local meta, meta_error = utils.file_info(path)
            if meta and meta.is_file then
                local file = io.open(path, "r")
                if file then
                    content = file:read("*all")
                    file:close()
                    extension = path:match("^.+%.(.+)$")
                end
            end
        end

        local parsed = {}
        if extension == "conf" then
            parsed = bind_from_conf(content)
        elseif extension == "json" then
            parsed = bind_from_json(content)
        end
        if #parsed ~= 0 then
            for _, v in ipairs(parsed) do
                if v.key and v.on then
                    unbind(v.key)
                    bind(v.key, v.on)
                else
                    mp.msg.error("Invalidated config: " .. path)
                end
            end
        end
    end
end

function on_options_update(list)
    if (list.configs) then
        bind_from_options_configs()
    end
end

function on_input_doubleclick_time_update(_, duration)
    for _, binding in pairs(bind_map) do
        binding:rebind({ duration = duration })
    end
end

function on_focused_update(_, focused)
    if not focused then
        return
    end

    local binding = bind_map["MBTN_LEFT"]
    if not binding then
        return
    end

    binding:ignore("click", binding.duration)
end

mp.register_script_message("bind", bind)
mp.register_script_message("unbind", unbind)
mp.observe_property("input-doubleclick-time", "native", on_input_doubleclick_time_update)
mp.observe_property("focused", "native", on_focused_update)
options.read_options(o, _, on_options_update)

bind_from_options_configs()
