-- created by Bertrand Legruley
-- lestrucsetastucesdetux@free.fr
-- lestrucsetastucesdetux.pages-perso.free.fr

local mp = require 'mp'

local state = {
    active = false,
    blink_show = true,
    blink_timer = nil,
    bound_keys = {},
    click_time = 0,
    click_x = 0,
    click_y = 0,
    hide_selection = false,
    hover_idx = nil,
    initial_pos = 0,
    input_buffer = "",
    input_timer = nil,
    last_mouse_x = -1,
    last_mouse_y = -1,
    lock_timer = nil,
    max_display = 30,
    mouse_is_idle = false,
    mouse_long_timer = nil,
    mouse_right_long_timer = nil,
    mouse_timer = nil,
    old_osd_level = 1,
    pip_armed = false,
    pos = 0,
    resize_lock = false,
    single_click_timer = nil,
    wheel_lock = false
}

local function title_from_url(url)
    if not url then return "Untitled" end
    local clean = url:gsub("%?.*$", ""):gsub("/$", "")
    clean = clean:match("([^/]+)$") or clean
    clean = clean:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
    clean = clean:gsub("%-", " "):gsub("_", " "):gsub("%s+", " ")
    if #clean > 1 then clean = clean:sub(1, 1):upper() .. clean:sub(2) end
    return (clean ~= "") and clean or "Untitled"
end

local function get_display_name(item)
    local fallback = "Untitled"
    if not item then return fallback end
    local name = ""
    if item.title and item.title ~= "" then
        local t = item.title
        if t ~= "Untitled" and t ~= "Subtitles" and t ~= "Sous-titres" and t ~= fallback then
            name = t
        end
    end
    if name == "" and item.filename then
        if item.filename:find("://") then
            name = title_from_url(item.filename)
        else
            local file = item.filename:gsub("\\", "/")
            name = file:match("([^/]+)$") or file
            name = name:gsub("%.%w+$", "")
        end
    end
    return (name ~= "") and name or fallback
end

local function get_index_at_mouse()
    if not state.active or state.resize_lock then return nil end
    local wx, wy = mp.get_mouse_pos()
    local ww, wh = mp.get_osd_size()
    local playlist = mp.get_property_native("playlist")
    local count = #playlist
    if count == 0 then return nil end
    local current_page = math.floor(state.pos / state.max_display)
    local start_idx = current_page * state.max_display
    local items_on_page = math.min(state.max_display, count - start_idx)
    local total_pages = math.ceil(count / state.max_display)
    local has_header = (total_pages > 1) or state.pip_armed
    local y_start = has_header and wh * 0.062 or wh * 0.024
    local y_end = wh * 0.76
    local relative_y = wy - y_start
    local idx_in_page = math.floor(relative_y / ((y_end - y_start) / 25))
    if idx_in_page >= 0 and idx_in_page < items_on_page then
        return start_idx + idx_in_page
    end
    return nil
end

local draw_playlist

local function trigger_wheel_mode()
    state.hover_idx = nil
    state.mouse_is_idle = true
    state.wheel_lock = true
    if state.lock_timer then state.lock_timer:kill() end
    state.lock_timer = mp.add_timeout(5.0, function() state.wheel_lock = false end)
    draw_playlist()
end

local function handle_blink()
    if not state.active or not state.pip_armed then return end
    state.blink_show = not state.blink_show
    draw_playlist()
    state.blink_timer = mp.add_timeout(state.blink_show and 1.0 or 0.5, handle_blink)
end

local function arm_pip_mode()
    state.blink_show = true
    state.pip_armed = not state.pip_armed
    if state.blink_timer then state.blink_timer:kill() end
    if state.pip_armed then handle_blink() end
    draw_playlist()
end

local function launch_preview(filename)
    local duration = mp.get_property_number("duration", 0)
    local is_partially_seekable = mp.get_property_native("partially-seekable")
    local is_seekable = mp.get_property_native("seekable")
    local is_live = (not is_seekable) or is_partially_seekable or (duration <= 0)
    if not is_live and duration > 0 then
        mp.osd_message("PiP mode for live streams only, if compatible", 5)
        return
    end
    local keyconf = mp.command_native({"expand-path", "~~/script-opts/pip.conf"})
    local video_index = state.pos + 1
    local display_label = "PiP " .. video_index
    if package.config:sub(1, 1) == "\\" then
        mp.commandv("run", "mpv.exe", "--autofit=x20%", "--border=no", "--dscale=mitchell", "--geometry=100%:0%", "--hwdec=auto", "--input-conf=" .. keyconf, "--input-default-bindings=no", "--load-scripts=no", "--mute=yes", "--no-osc", "--no-osd-bar", "--ontop", "--osd-align-x=center", "--osd-align-y=center", "--osd-font-size=74", "--osd-level=1", "--osd-margin-x=0", "--osd-margin-y=0", "--osd-msg1=" .. display_label, "--scale=bilinear", "--title=" .. display_label, "--vo=gpu-next", "--ytdl=no", filename)
    else
        local safe_fn = filename:gsub("%$", "\\$")
        local args = string.format('--autofit=x20%% --border=no --dscale=mitchell --geometry=100%%:100%% --hwdec=auto --input-conf="%s" --input-default-bindings=no --load-scripts=no --mute=yes --no-osc --no-osd-bar --ontop --osd-align-x=center --osd-align-y=center --osd-font-size=74 --osd-level=1 --osd-margin-x=0 --osd-margin-y=0 --osd-msg1="PiP %d" --scale=bilinear --title="PiP %d" --vo=gpu-next --ytdl=no', keyconf, video_index, video_index)
        os.execute(string.format('mpv %s "%s" &', args, safe_fn))
    end
end

draw_playlist = function()
    if not state.active then return end
    local playlist = mp.get_property_native("playlist")
    local count = #playlist
    if count == 0 then return end
    local ww, wh = mp.get_osd_size()
    local current_page = math.floor(state.pos / state.max_display)
    local p_len = tostring(count):len()
    local start_idx = current_page * state.max_display
    local end_idx = math.min(start_idx + state.max_display - 1, count - 1)
    local total_pages = math.ceil(count / state.max_display)
    local has_header = (total_pages > 1) or state.pip_armed
    local header_text = ""
    if total_pages > 1 then header_text = "Page " .. (current_page + 1) .. "/" .. total_pages end
    if state.pip_armed then
        local pip_style = state.blink_show and "{\\1c&H0000FF&}" or "{\\alpha&HFF&}"
        header_text = (total_pages > 1 and header_text .. " " or "") .. pip_style .. "[PiP mode]{\\r}"
    end
    if has_header then header_text = header_text .. "\\N\\N" end
    local list_content = ""
    for i = start_idx + 1, end_idx + 1 do
        local idx = i - 1
        local name = get_display_name(playlist[i])
        local line = (count > 1) and string.format("%0" .. p_len .. "d. %s", idx + 1, name) or name
        if idx == state.pos and not state.hide_selection then
            list_content = list_content .. "{\\1c&H0098FF&}" .. line .. "{\\r}\\N"
        elseif idx == state.hover_idx and not state.wheel_lock then
            list_content = list_content .. "{\\1c&HE8731A&}" .. line .. "{\\r}\\N"
        else
            list_content = list_content .. line .. "\\N"
        end
    end
    if mp.get_property_number("osd-level") ~= 0 then mp.set_property_number("osd-level", 0) end
    local background = string.format("{\\an7\\pos(0,0)\\p1\\1c&H000000&\\1a&H20&\\bord0\\shad0}m 0 0 l %d 0 %d %d 0 %d{\\p0}", ww, ww, wh, wh)
    mp.set_osd_ass(ww, wh, background .. "\n{\\an7\\q2\\V8}" .. header_text .. list_content)
end

local function update_mouse_state()
    if not state.active or state.resize_lock then return end
    local mx, my = mp.get_mouse_pos()
    if math.abs(mx - state.last_mouse_x) > 1 or math.abs(my - state.last_mouse_y) > 1 then
        state.last_mouse_x, state.last_mouse_y = mx, my
        state.mouse_is_idle = false
        if not state.wheel_lock then
            state.hover_idx = get_index_at_mouse()
            draw_playlist()
        end
    end
end

local function update_pos(delta, is_page, hide_selection)
    local count = #mp.get_property_native("playlist")
    if count == 0 then return end
    if is_page then
        local total_pages = math.ceil(count / state.max_display)
        state.pos = ((math.floor(state.pos / state.max_display) + delta + total_pages) % total_pages) * state.max_display
        if state.pos >= count then state.pos = count - 1 end
    else
        state.pos = (state.pos + delta + count) % count
    end
    state.hide_selection = (count > state.max_display) and (hide_selection ~= nil and hide_selection or state.hide_selection) or false
    draw_playlist()
end

local function exit_playlist(apply_selection)
    if state.blink_timer then state.blink_timer:kill() end
    if state.input_timer then state.input_timer:kill() end
    if state.lock_timer then state.lock_timer:kill() end
    if state.mouse_timer then state.mouse_timer:kill() end
    if state.single_click_timer then state.single_click_timer:kill(); state.single_click_timer = nil end
    for _, key in ipairs(state.bound_keys) do mp.remove_key_binding(key) end
    if apply_selection and state.pos ~= mp.get_property_number("playlist-pos", 0) then
        mp.set_property_number("playlist-pos", state.pos)
    end
    state.active, state.hide_selection, state.pip_armed, state.wheel_lock = false, false, false, false
    state.bound_keys, state.hover_idx = {}, nil
    mp.commandv("script-message", "playlist-visible", "no")
    mp.commandv("script-message", "osc-visibility", "auto", "no_osd")
    mp.set_osd_ass(0, 0, "")
    mp.set_property_number("osd-level", state.old_osd_level)
end

local function validate_selection()
    local playlist = mp.get_property_native("playlist")
    local selected = playlist[state.pos + 1]
    if not selected then return end
    if state.pip_armed then
        launch_preview(selected.filename)
        exit_playlist(false)
    else
        exit_playlist(true)
    end
end

local function handle_num(num)
    if state.input_timer then state.input_timer:kill() end
    state.hide_selection = false
    state.input_buffer = state.input_buffer .. num
    draw_playlist()
    state.input_timer = mp.add_timeout(1.0, function()
        local count = #mp.get_property_native("playlist")
        local target = tonumber(state.input_buffer) - 1
        state.input_buffer = ""
        if target and target >= 0 and target < count then
            state.pos = target
            validate_selection()
        else
            draw_playlist()
        end
    end)
end

local function handle_resize()
    if not state.active then return end
    state.resize_lock = true
    mp.add_timeout(0.1, function() state.resize_lock = false; draw_playlist() end)
end

local function toggle_playlist()
    if state.active then exit_playlist(false) return end
    state.active, state.blink_show, state.pip_armed, state.wheel_lock = true, true, false, false
    state.bound_keys, state.click_x, state.click_y = {}, 0, 0
    state.hide_selection, state.mouse_is_idle = false, false
    state.initial_pos = mp.get_property_number("playlist-pos", 0)
    state.old_osd_level = mp.get_property_number("osd-level", 1)
    state.pos = state.initial_pos
    local function bind(key, name, fn, flags)
        local id = "pl_key_" .. name
        mp.add_forced_key_binding(key, id, fn, flags)
        table.insert(state.bound_keys, id)
    end
    local function bind_fast_seek(key, name, delta, hide_selection)
        local seek_timer = nil
        bind(key, name, function(e)
            if e.event == "down" then
                seek_timer = mp.add_timeout(0.5, function() update_pos(delta * 10, true, hide_selection); seek_timer = nil end)
            elseif e.event == "up" and seek_timer then
                seek_timer:kill(); seek_timer = nil; update_pos(delta, true, hide_selection)
            end
        end, {complex = true})
    end
    local down_timer, up_timer = nil, nil
    bind("DOWN", "down_logic", function(e)
        if e.event == "down" then
            down_timer = mp.add_timeout(0.5, function()
                local count = #mp.get_property_native("playlist")
                if count <= 1 then return end
                for i = 1, count do
                    local target = (state.pos + i) % count
                    if (target + 1) % 10 == 0 then state.pos = target break end
                end
                state.hide_selection = false; draw_playlist(); down_timer = nil
            end)
        elseif e.event == "up" and down_timer then
            down_timer:kill(); down_timer = nil; update_pos(1, false, false)
        end
    end, {complex = true})
    bind("UP", "up_logic", function(e)
        if e.event == "down" then
            up_timer = mp.add_timeout(0.5, function()
                local count = #mp.get_property_native("playlist")
                if count <= 1 then return end
                for i = 1, count do
                    local target = (state.pos - i + count) % count
                    if (target + 1) % 10 == 0 or target == 0 then state.pos = target break end
                end
                state.hide_selection = false; draw_playlist(); up_timer = nil
            end)
        elseif e.event == "up" and up_timer then
            up_timer:kill(); up_timer = nil; update_pos(-1, false, false)
        end
    end, {complex = true})
    bind("ENTER", "ent", function() if not state.hide_selection then validate_selection() end end)
    bind("ESC", "esc", function() exit_playlist(false) end)
    bind("i", "pip", arm_pip_mode)
    bind_fast_seek("LEFT", "left", -1, false)
    bind_fast_seek("MBTN_BACK", "mback", -1, true)
    bind_fast_seek("MBTN_FORWARD", "mfrw", 1, true)
    bind_fast_seek("RIGHT", "right", 1, false)
    bind_fast_seek("SPACE", "space", 1, false)
    bind("MBTN_MID", "mclk", function() exit_playlist(false) end)
    bind("MBTN_LEFT", "mouse_logic", function(e)
        local cur_x, cur_y = mp.get_mouse_pos()
        if e.event == "down" then
            state.click_time, state.click_x, state.click_y = mp.get_time(), cur_x, cur_y
            state.mouse_long_timer = mp.add_timeout(0.5, function() state.mouse_long_timer = nil; arm_pip_mode() end)
        elseif e.event == "up" then
            if state.mouse_long_timer then
                state.mouse_long_timer:kill(); state.mouse_long_timer = nil
                local time_held = mp.get_time() - state.click_time
                if time_held > 0.05 and time_held < 0.25 and math.abs(cur_x - state.click_x) <= 1 and math.abs(cur_y - state.click_y) <= 1 then
                    if not state.mouse_is_idle then
                        local m_idx = get_index_at_mouse()
                        if m_idx then state.hide_selection, state.pos = false, m_idx end
                    end
                    if state.single_click_timer then
                        state.single_click_timer:kill(); state.single_click_timer = nil
                    else
                        state.single_click_timer = mp.add_timeout(0.5, function()
                            local nx, ny = mp.get_mouse_pos()
                            if math.abs(nx - state.click_x) <= 1 and math.abs(ny - state.click_y) <= 1 then validate_selection() end
                            state.single_click_timer = nil
                        end)
                    end
                end
            end
        end
    end, {complex = true})
    bind("MBTN_RIGHT", "rclk_logic", function(e)
        if e.event == "down" then
            state.mouse_right_long_timer = mp.add_timeout(0.5, function()
                local count = #mp.get_property_native("playlist")
                if count <= 1 then return end
                for i = 1, count do
                    local target = (state.pos + i) % count
                    if (target + 1) % 10 == 0 then state.pos = target break end
                end
                state.hide_selection, state.mouse_right_long_timer = false, nil; draw_playlist()
            end)
        elseif e.event == "up" and state.mouse_right_long_timer then
            state.mouse_right_long_timer:kill(); state.mouse_right_long_timer = nil; update_pos(1, true, true)
        end
    end, {complex = true})
    bind("WHEEL_DOWN", "wdown", function() trigger_wheel_mode(); if state.hide_selection then state.hide_selection = false; draw_playlist() else update_pos(1, false, false) end end)
    bind("WHEEL_UP", "wup", function() trigger_wheel_mode(); if state.hide_selection then state.hide_selection = false; draw_playlist() else update_pos(-1, false, false) end end)
    for i = 0, 9 do
        bind("KP" .. i, "kp" .. i, function() handle_num(i) end)
        bind(tostring(i), "n" .. i, function() handle_num(i) end)
    end
    state.mouse_timer = mp.add_periodic_timer(0.05, update_mouse_state)
    mp.commandv("script-message", "playlist-visible", "yes")
    mp.commandv("script-message", "osc-visibility", "never", "no_osd")
    mp.set_property_number("osd-level", 0)
    draw_playlist()
end

local function publish_current_title()
    local playlist = mp.get_property_native("playlist")
    if not playlist then return end
    local item = playlist[mp.get_property_number("playlist-pos", 0) + 1]
    mp.commandv("script-message", "update-tux-title", item and get_display_name(item) or "Untitled")
end

local global_mouse_timer = nil
mp.add_forced_key_binding("MBTN_LEFT", "global_long_press_open", function(e)
    if state.active then return end
    if e.event == "down" then
        global_mouse_timer = mp.add_timeout(0.5, function() toggle_playlist(); global_mouse_timer = nil end)
    elseif e.event == "up" and global_mouse_timer then
        global_mouse_timer:kill(); global_mouse_timer = nil
    end
end, {complex = true})

mp.observe_property("osd-level", "number", function(_, val) if state.active and val ~= 0 then mp.set_property_number("osd-level", 0) end end)
mp.observe_property("osd-height", "native", handle_resize)
mp.observe_property("osd-width", "native", handle_resize)
mp.observe_property("playlist", "native", publish_current_title)
mp.observe_property("playlist-pos", "number", publish_current_title)
mp.register_script_message("open-real-playlist", toggle_playlist)
mp.register_script_message("request-tux-title", publish_current_title)
