local mp = require 'mp'
local utils = require 'mp.utils'
local msg = require 'mp.msg'

local M = {}

local MEDEIA_LUA_VERSION = '2025-12-24'

-- Expose a tiny breadcrumb for debugging which script version is loaded.
pcall(mp.set_property, 'user-data/medeia-lua-version', MEDEIA_LUA_VERSION)

-- Track whether uosc is available so menu calls don't fail with
-- "Can't find script 'uosc' to send message to."
local _uosc_loaded = false

mp.register_script_message('uosc-version', function(_ver)
    _uosc_loaded = true
end)

local function _is_script_loaded(name)
    local ok, list = pcall(mp.get_property_native, 'script-list')
    if not ok or type(list) ~= 'table' then
        return false
    end
    for _, s in ipairs(list) do
        if type(s) == 'table' then
            local n = s.name or ''
            if n == name or tostring(n):match('^' .. name .. '%d*$') then
                return true
            end
        elseif type(s) == 'string' then
            local n = s
            if n == name or tostring(n):match('^' .. name .. '%d*$') then
                return true
            end
        end
    end
    return false
end

local LOAD_URL_MENU_TYPE = 'medios_load_url'

local DOWNLOAD_FORMAT_MENU_TYPE = 'medios_download_pick_format'
local DOWNLOAD_STORE_MENU_TYPE = 'medios_download_pick_store'

-- Menu types for the command submenu and trim prompt
local CMD_MENU_TYPE = 'medios_cmd_menu'
local TRIM_PROMPT_MENU_TYPE = 'medios_trim_prompt'

local PIPELINE_REQ_PROP = 'user-data/medeia-pipeline-request'
local PIPELINE_RESP_PROP = 'user-data/medeia-pipeline-response'
local PIPELINE_READY_PROP = 'user-data/medeia-pipeline-ready'

-- Dedicated Lua log (next to mpv log-file) because mp.msg output is not always
-- included in --log-file depending on msg-level and build.
local function _lua_log(text)
    local payload = (text and tostring(text) or '')
    if payload == '' then
        return
    end
    local dir = ''

    -- Prefer a stable repo-root Log/ folder based on the script directory.
    do
        local function _dirname(p)
            p = tostring(p or '')
            p = p:gsub('[/\\]+$', '')
            return p:match('(.*)[/\\]') or ''
        end

        local base = mp.get_script_directory() or ''
        if base ~= '' then
            -- base is expected to be <repo>/MPV/LUA
            local root = _dirname(_dirname(base))
            if root ~= '' then
                dir = utils.join_path(root, 'Log')
            end
        end
    end

    -- Prefer repo-root Log/ for consistency with Python helper logs.
    do
        local function find_up(start_dir, relative_path, max_levels)
            local d = start_dir
            local levels = max_levels or 6
            for _ = 0, levels do
                if d and d ~= '' then
                    local candidate = d .. '/' .. relative_path
                    if utils.file_info(candidate) then
                        return candidate
                    end
                end
                local parent = d and d:match('(.*)[/\\]') or nil
                if not parent or parent == d or parent == '' then
                    break
                end
                d = parent
            end
            return nil
        end

        local base = mp.get_script_directory() or utils.getcwd() or ''
        if base ~= '' then
            local cli = find_up(base, 'CLI.py', 8)
            if cli and cli ~= '' then
                local root = cli:match('(.*)[/\\]') or ''
                if root ~= '' then
                    dir = utils.join_path(root, 'Log')
                end
            end
        end
    end

    -- Fallback: next to mpv --log-file.
    if dir == '' then
        local log_file = mp.get_property('options/log-file') or ''
        dir = log_file:match('(.*)[/\\]') or ''
    end
    if dir == '' then
        dir = mp.get_script_directory() or utils.getcwd() or ''
    end
    if dir == '' then
        return
    end
    local path = utils.join_path(dir, 'medeia-mpv-lua.log')
    local fh = io.open(path, 'a')
    if not fh then
        return
    end
    local line = '[' .. os.date('%Y-%m-%d %H:%M:%S') .. '] ' .. payload
    fh:write(line .. '\n')
    fh:close()

    -- Also mirror Lua-side debug into the Python helper log file so there's one
    -- place to look when diagnosing mpv↔python IPC issues.
    do
        local helper_path = utils.join_path(dir, 'medeia-mpv-helper.log')
        local fh2 = io.open(helper_path, 'a')
        if fh2 then
            fh2:write('[lua] ' .. line .. '\n')
            fh2:close()
        end
    end
end

_lua_log('medeia lua loaded version=' .. tostring(MEDEIA_LUA_VERSION) .. ' script=' .. tostring(mp.get_script_name()))

local function ensure_uosc_loaded()
    if _uosc_loaded or _is_script_loaded('uosc') then
        _uosc_loaded = true
        return true
    end

    local entry = nil
    pcall(function()
        entry = mp.find_config_file('scripts/uosc.lua')
    end)
    if not entry or entry == '' then
        _lua_log('uosc entry not found at scripts/uosc.lua under config-dir')
        return false
    end

    local ok = pcall(mp.commandv, 'load-script', entry)
    if ok then
        _lua_log('Loaded uosc from: ' .. tostring(entry))
    else
        _lua_log('Failed to load uosc from: ' .. tostring(entry))
    end

    -- uosc will broadcast uosc-version on load; also re-check script-list if available.
    if _is_script_loaded('uosc') then
        _uosc_loaded = true
        return true
    end
    return _uosc_loaded
end

local function write_temp_log(prefix, text)
    if not text or text == '' then
        return nil
    end

    local dir = ''
    -- Prefer repo-root Log/ for easier discovery.
    -- NOTE: Avoid spawning cmd.exe/sh just to mkdir on Windows/Linux; console flashes are
    -- highly undesirable. If the directory doesn't exist, we fall back to TEMP.
    do
        local function find_up(start_dir, relative_path, max_levels)
            local d = start_dir
            local levels = max_levels or 6
            for _ = 0, levels do
                if d and d ~= '' then
                    local candidate = d .. '/' .. relative_path
                    if utils.file_info(candidate) then
                        return candidate
                    end
                end
                local parent = d and d:match('(.*)[/\\]') or nil
                if not parent or parent == d or parent == '' then
                    break
                end
                d = parent
            end
            return nil
        end

        local base = mp.get_script_directory() or utils.getcwd() or ''
        if base ~= '' then
            local cli = find_up(base, 'CLI.py', 6)
            if cli and cli ~= '' then
                local parent = cli:match('(.*)[/\\]') or ''
                if parent ~= '' then
                    dir = utils.join_path(parent, 'Log')
                end
            end
        end
    end
    if dir == '' then
        dir = os.getenv('TEMP') or os.getenv('TMP') or utils.getcwd() or ''
    end
    if dir == '' then
        return nil
    end
    local name = (prefix or 'medeia-mpv') .. '-' .. tostring(math.floor(mp.get_time() * 1000)) .. '.log'
    local path = utils.join_path(dir, name)
    local fh = io.open(path, 'w')
    if not fh then
        -- If Log/ wasn't created (or is not writable), fall back to TEMP.
        local tmp = os.getenv('TEMP') or os.getenv('TMP') or ''
        if tmp ~= '' and tmp ~= dir then
            path = utils.join_path(tmp, name)
            fh = io.open(path, 'w')
        end
        if not fh then
            return nil
        end
    end
    fh:write(text)
    fh:close()
    return path
end

local function trim(s)
    return (s:gsub('^%s+', ''):gsub('%s+$', ''))
end

-- Lyrics overlay toggle
-- The Python helper (python -m MPV.lyric) will read this property via IPC.
local LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible"

local function lyric_get_visible()
    local ok, v = pcall(mp.get_property_native, LYRIC_VISIBLE_PROP)
    if not ok or v == nil then
        return true
    end
    return v and true or false
end

local function lyric_set_visible(v)
    pcall(mp.set_property_native, LYRIC_VISIBLE_PROP, v and true or false)
end

local function lyric_toggle()
    local now = not lyric_get_visible()
    lyric_set_visible(now)
    mp.osd_message("Lyrics: " .. (now and "on" or "off"), 1)
end

-- Default to visible unless user overrides.
lyric_set_visible(true)

-- Configuration
local opts = {
    python_path = "python",
    cli_path = nil -- Will be auto-detected if nil
}

local function find_file_upwards(start_dir, relative_path, max_levels)
    local dir = start_dir
    local levels = max_levels or 6
    for _ = 0, levels do
        if dir and dir ~= "" then
            local candidate = dir .. "/" .. relative_path
            if utils.file_info(candidate) then
                return candidate
            end
        end
        local parent = dir and dir:match("(.*)[/\\]") or nil
        if not parent or parent == dir or parent == "" then
            break
        end
        dir = parent
    end
    return nil
end

local _cached_store_names = {}
local _store_cache_loaded = false

-- Optional index into _cached_store_names (used by some older menu code paths).
-- If unset, callers should fall back to reading SELECTED_STORE_PROP.
local _selected_store_index = nil

local SELECTED_STORE_PROP = 'user-data/medeia-selected-store'
local STORE_PICKER_MENU_TYPE = 'medeia_store_picker'
local _selected_store_loaded = false

local function _get_script_opts_dir()
    local dir = nil
    pcall(function()
        dir = mp.command_native({ 'expand-path', '~~/script-opts' })
    end)
    if type(dir) ~= 'string' or dir == '' then
        return nil
    end
    return dir
end

local function _get_selected_store_conf_path()
    local dir = _get_script_opts_dir()
    if not dir then
        return nil
    end
    return utils.join_path(dir, 'medeia.conf')
end

local function _load_selected_store_from_disk()
    local path = _get_selected_store_conf_path()
    if not path then
        return nil
    end
    local fh = io.open(path, 'r')
    if not fh then
        return nil
    end
    for line in fh:lines() do
        local s = trim(tostring(line or ''))
        if s ~= '' and s:sub(1, 1) ~= '#' and s:sub(1, 1) ~= ';' then
            local k, v = s:match('^([%w_%-]+)%s*=%s*(.*)$')
            if k and v and k:lower() == 'store' then
                fh:close()
                v = trim(tostring(v or ''))
                return v ~= '' and v or nil
            end
        end
    end
    fh:close()
    return nil
end

local function _save_selected_store_to_disk(store)
    local path = _get_selected_store_conf_path()
    if not path then
        return false
    end
    local fh = io.open(path, 'w')
    if not fh then
        return false
    end
    fh:write('# Medeia MPV script options\n')
    fh:write('store=' .. tostring(store or '') .. '\n')
    fh:close()
    return true
end

local function _get_selected_store()
    local v = ''
    pcall(function()
        v = tostring(mp.get_property(SELECTED_STORE_PROP) or '')
    end)
    return trim(tostring(v or ''))
end

local function _set_selected_store(store)
    store = trim(tostring(store or ''))
    pcall(mp.set_property, SELECTED_STORE_PROP, store)
    pcall(_save_selected_store_to_disk, store)
end

local function _ensure_selected_store_loaded()
    if _selected_store_loaded then
        return
    end
    _selected_store_loaded = true
    local disk = nil
    pcall(function()
        disk = _load_selected_store_from_disk()
    end)
    disk = trim(tostring(disk or ''))
    if disk ~= '' then
        pcall(mp.set_property, SELECTED_STORE_PROP, disk)
    end
end

local _pipeline_helper_started = false
local _last_ipc_error = ''
local _last_ipc_last_req_json = ''
local _last_ipc_last_resp_json = ''

local function _is_pipeline_helper_ready()
    local ready = mp.get_property(PIPELINE_READY_PROP)
    if ready == nil or ready == '' then
        ready = mp.get_property_native(PIPELINE_READY_PROP)
    end
    if not ready then
        return false
    end
    local s = tostring(ready)
    if s == '' or s == '0' then
        return false
    end

    -- Back-compat: older helpers may set "1". New helpers set unix timestamps.
    local n = tonumber(s)
    if n and n > 1000000000 then
        local now = (os and os.time) and os.time() or nil
        if not now then
            return true
        end
        local age = now - n
        if age < 0 then
            age = 0
        end
        return age <= 10
    end

    -- If it's some other non-empty value, treat as ready.
    return true
end

local function get_mpv_ipc_path()
    local ipc = mp.get_property('input-ipc-server')
    if ipc and ipc ~= '' then
        return ipc
    end
    -- Fallback: fixed pipe/socket name used by MPV/mpv_ipc.py
    local sep = package and package.config and package.config:sub(1, 1) or '/'
    if sep == '\\' then
        return '\\\\.\\pipe\\mpv-medeia-macina'
    end
    return '/tmp/mpv-medeia-macina.sock'
end

local function ensure_mpv_ipc_server()
    -- `.mpv -play` (Python) controls MPV via JSON IPC. If mpv was started
    -- without --input-ipc-server, make sure we set one so the running instance
    -- can be controlled (instead of Python spawning a separate mpv).
    local ipc = mp.get_property('input-ipc-server')
    if ipc and ipc ~= '' then
        return true
    end

    local desired = get_mpv_ipc_path()
    if not desired or desired == '' then
        return false
    end

    local ok = pcall(mp.set_property, 'input-ipc-server', desired)
    if not ok then
        return false
    end
    local now = mp.get_property('input-ipc-server')
    return (now and now ~= '') and true or false
end

local function ensure_pipeline_helper_running()
    -- IMPORTANT: do NOT spawn Python from inside mpv.
    -- The Python side (MPV.mpv_ipc) starts pipeline_helper.py using Windows
    -- no-console flags; spawning here can flash a console window.
    return _is_pipeline_helper_ready() and true or false
end

local _ipc_async_busy = false
local _ipc_async_queue = {}

local function _run_helper_request_async(req, timeout_seconds, cb)
    cb = cb or function() end

    if _ipc_async_busy then
        _ipc_async_queue[#_ipc_async_queue + 1] = { req = req, timeout = timeout_seconds, cb = cb }
        return
    end
    _ipc_async_busy = true

    local function done(resp, err)
        _ipc_async_busy = false
        cb(resp, err)

        if #_ipc_async_queue > 0 then
            local next_job = table.remove(_ipc_async_queue, 1)
            -- Schedule next job slightly later to let mpv deliver any pending events.
            mp.add_timeout(0.01, function()
                _run_helper_request_async(next_job.req, next_job.timeout, next_job.cb)
            end)
        end
    end

    if type(req) ~= 'table' then
        done(nil, 'invalid request')
        return
    end

    ensure_mpv_ipc_server()
    if not ensure_pipeline_helper_running() then
        done(nil, 'helper not running')
        return
    end

    -- Assign id.
    local id = tostring(req.id or '')
    if id == '' then
        id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999))
        req.id = id
    end

    local label = ''
    if req.op then
        label = 'op=' .. tostring(req.op)
    elseif req.pipeline then
        label = 'cmd=' .. tostring(req.pipeline)
    else
        label = '(unknown)'
    end

    -- Wait for helper READY without blocking the UI.
    local ready_deadline = mp.get_time() + 3.0
    local ready_timer
    ready_timer = mp.add_periodic_timer(0.05, function()
        if _is_pipeline_helper_ready() then
            ready_timer:kill()

            _lua_log('ipc-async: send request id=' .. tostring(id) .. ' ' .. label)
            local req_json = utils.format_json(req)
            _last_ipc_last_req_json = req_json

            mp.set_property(PIPELINE_RESP_PROP, '')
            mp.set_property(PIPELINE_REQ_PROP, req_json)

            local deadline = mp.get_time() + (timeout_seconds or 5)
            local poll_timer
            poll_timer = mp.add_periodic_timer(0.05, function()
                if mp.get_time() >= deadline then
                    poll_timer:kill()
                    done(nil, 'timeout waiting response (' .. label .. ')')
                    return
                end

                local resp_json = mp.get_property(PIPELINE_RESP_PROP)
                if resp_json and resp_json ~= '' then
                    _last_ipc_last_resp_json = resp_json
                    local ok, resp = pcall(utils.parse_json, resp_json)
                    if ok and resp and resp.id == id then
                        poll_timer:kill()
                        _lua_log('ipc-async: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success))
                        done(resp, nil)
                    end
                end
            end)

            return
        end

        if mp.get_time() >= ready_deadline then
            ready_timer:kill()
            done(nil, 'helper not ready')
            return
        end
    end)
end

local function _run_helper_request_response(req, timeout_seconds)
    _last_ipc_error = ''
    if not ensure_pipeline_helper_running() then
        local rv = tostring(mp.get_property(PIPELINE_READY_PROP) or mp.get_property_native(PIPELINE_READY_PROP) or '')
        _lua_log('ipc: helper not ready (ready=' .. rv .. '); attempting request anyway')
        _last_ipc_error = 'helper not ready'
    end

    do
        -- Best-effort wait for heartbeat, but do not hard-fail the request.
        local deadline = mp.get_time() + 1.5
        while mp.get_time() < deadline do
            if _is_pipeline_helper_ready() then
                break
            end
            mp.wait_event(0.05)
        end
        if not _is_pipeline_helper_ready() then
            local rv = tostring(mp.get_property(PIPELINE_READY_PROP) or mp.get_property_native(PIPELINE_READY_PROP) or '')
            _lua_log('ipc: proceeding without helper heartbeat; ready=' .. rv)
            _last_ipc_error = 'helper heartbeat missing (ready=' .. rv .. ')'
        end
    end

    if type(req) ~= 'table' then
        return nil
    end

    local id = tostring(req.id or '')
    if id == '' then
        id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999))
        req.id = id
    end

    local label = ''
    if req.op then
        label = 'op=' .. tostring(req.op)
    elseif req.pipeline then
        label = 'cmd=' .. tostring(req.pipeline)
    else
        label = '(unknown)'
    end
    _lua_log('ipc: send request id=' .. tostring(id) .. ' ' .. label)

    local req_json = utils.format_json(req)
    _last_ipc_last_req_json = req_json
    mp.set_property(PIPELINE_RESP_PROP, '')
    mp.set_property(PIPELINE_REQ_PROP, req_json)
    -- Read-back for debugging: confirms MPV accepted the property write.
    local echoed = mp.get_property(PIPELINE_REQ_PROP) or ''
    if echoed == '' then
        _lua_log('ipc: WARNING request property echoed empty after set')
    end

    local deadline = mp.get_time() + (timeout_seconds or 5)
    while mp.get_time() < deadline do
        local resp_json = mp.get_property(PIPELINE_RESP_PROP)
        if resp_json and resp_json ~= '' then
            _last_ipc_last_resp_json = resp_json
            local ok, resp = pcall(utils.parse_json, resp_json)
            if ok and resp and resp.id == id then
                _lua_log('ipc: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success))
                return resp
            end
        end
        mp.wait_event(0.05)
    end

    _lua_log('ipc: timeout waiting response; ' .. label)
    _last_ipc_error = 'timeout waiting response (' .. label .. ')'
    return nil
end

-- IPC helper: return the whole response object (stdout/stderr/error/table)
local function run_pipeline_via_ipc_response(pipeline_cmd, seeds, timeout_seconds)
    local req = { pipeline = pipeline_cmd }
    if seeds then
        req.seeds = seeds
    end
    return _run_helper_request_response(req, timeout_seconds)
end

local function quote_pipeline_arg(s)
    -- Ensure URLs with special characters (e.g. &, #) survive pipeline parsing.
    s = tostring(s or '')
    s = s:gsub('\\', '\\\\'):gsub('"', '\\"')
    return '"' .. s .. '"'
end

local function _is_windows()
    local sep = package and package.config and package.config:sub(1, 1) or '/'
    return sep == '\\'
end

local function _resolve_python_exe(prefer_no_console)
    local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python'
    if (not prefer_no_console) or (not _is_windows()) then
        return python
    end

    local low = tostring(python):lower()
    if low == 'python' then
        return 'pythonw'
    end
    if low == 'python.exe' then
        return 'pythonw.exe'
    end
    if low:sub(-10) == 'python.exe' then
        local candidate = python:sub(1, #python - 10) .. 'pythonw.exe'
        if utils.file_info(candidate) then
            return candidate
        end
        return 'pythonw'
    end
    -- Already pythonw or some other launcher.
    return python
end

local function _extract_target_from_memory_uri(text)
    if type(text) ~= 'string' then
        return nil
    end
    if not text:match('^memory://') then
        return nil
    end
    for line in text:gmatch('[^\r\n]+') do
        line = trim(line)
        if line ~= '' and not line:match('^#') and not line:match('^memory://') then
            return line
        end
    end
    return nil
end

local function _percent_decode(s)
    if type(s) ~= 'string' then
        return s
    end
    return (s:gsub('%%(%x%x)', function(hex)
        return string.char(tonumber(hex, 16))
    end))
end

local function _extract_query_param(url, key)
    if type(url) ~= 'string' then
        return nil
    end
    key = tostring(key or '')
    if key == '' then
        return nil
    end
    local pattern = '[?&]' .. key:gsub('([^%w])', '%%%1') .. '=([^&#]+)'
    local v = url:match(pattern)
    if v then
        return _percent_decode(v)
    end
    return nil
end

local function _current_target()
    local path = mp.get_property('path')
    if not path or path == '' then
        return nil
    end
    local mem = _extract_target_from_memory_uri(path)
    if mem and mem ~= '' then
        return mem
    end
    return path
end

local ImageControl = {
    enabled = false,
    binding_names = {},
    pan_step = 0.05,
    pan_step_slow = 0.02,
    zoom_step = 0.45,
    zoom_step_slow = 0.15,
}

local MAX_IMAGE_ZOOM = 4.5

local function _install_q_block()
    pcall(mp.commandv, 'keybind', 'q', 'script-message', 'medeia-image-quit-block')
end

local function _restore_q_default()
    pcall(mp.commandv, 'keybind', 'q', 'quit')
end

local function _enable_image_section()
    pcall(mp.commandv, 'enable-section', 'image', 'allow-hide-cursor')
end

local function _disable_image_section()
    pcall(mp.commandv, 'disable-section', 'image')
end

mp.register_script_message('medeia-image-quit-block', function()
    if ImageControl.enabled then
        mp.osd_message('Press ESC if you really want to quit', 0.7)
        return
    end
    mp.commandv('quit')
end)

local ImageExtensions = {
    jpg = true,
    jpeg = true,
    png = true,
    gif = true,
    webp = true,
    bmp = true,
    tif = true,
    tiff = true,
    heic = true,
    heif = true,
    avif = true,
    ico = true,
}

local function _clean_path_for_extension(path)
    if type(path) ~= 'string' then
        return nil
    end
    local clean = path:match('([^?]+)') or path
    clean = clean:match('([^#]+)') or clean
    local last = clean:match('([^/\\]+)$') or ''
    local ext = last:match('%.([A-Za-z0-9]+)$')
    if not ext then
        return nil
    end
    return ext:lower()
end

local function _is_image_path(path)
    local ext = _clean_path_for_extension(path)
    return ext and ImageExtensions[ext]
end

local function _get_current_item_is_image()
    local video_info = mp.get_property_native('current-tracks/video')
    if type(video_info) == 'table' then
        if video_info.image == true then
            return true
        end
        if video_info.image == false then
            return false
        end
    end
    local target = _current_target()
    if target then
        return _is_image_path(target)
    end
    return false
end


local function _set_image_property(value)
    pcall(mp.set_property_native, 'user-data/mpv/image', value and true or false)
end

local function _show_image_status(message)
    local zoom = mp.get_property_number('video-zoom') or 0
    local pan_x = mp.get_property_number('video-pan-x') or 0
    local pan_y = mp.get_property_number('video-pan-y') or 0
    local zoom_percent = math.floor((1 + zoom) * 100 + 0.5)
    local text = string.format('Image: zoom %d%% pan %+.2f %+.2f', zoom_percent, pan_x, pan_y)
    if message and message ~= '' then
        text = message .. ' | ' .. text
    end
    mp.osd_message(text, 0.7)
end

local function _change_pan(dx, dy)
    local pan_x = mp.get_property_number('video-pan-x') or 0
    local pan_y = mp.get_property_number('video-pan-y') or 0
    mp.set_property_number('video-pan-x', pan_x + dx)
    mp.set_property_number('video-pan-y', pan_y + dy)
    _show_image_status()
end

local function _change_zoom(delta)
    local current = mp.get_property_number('video-zoom') or 0
    local target = current + delta
    if target > MAX_IMAGE_ZOOM then
        target = MAX_IMAGE_ZOOM
    end
    if target < -1.0 then
        target = -1.0
    end
    mp.set_property_number('video-zoom', target)
    mp.set_property('video-unscaled', 'no')
    if target >= MAX_IMAGE_ZOOM then
        mp.osd_message('Image zoom maxed at 450%', 0.7)
    else
        _show_image_status()
    end
end

local function _reset_pan_zoom()
    mp.set_property_number('video-pan-x', 0)
    mp.set_property_number('video-pan-y', 0)
    mp.set_property_number('video-zoom', 0)
    mp.set_property('video-align-x', '0')
    mp.set_property('video-align-y', '0')
    mp.set_property('panscan', 0)
    mp.set_property('video-unscaled', 'no')
    _show_image_status('Zoom reset')
end

local function _sanitize_filename_component(s)
    s = trim(tostring(s or ''))
    if s == '' then
        return 'screenshot'
    end
    -- Windows-unfriendly characters: <>:"/\|?* and control chars
    s = s:gsub('[%c]', '')
    s = s:gsub('[<>:"/\\|%?%*]', '_')
    s = trim(s)
    s = s:gsub('[%.%s]+$', '')
    if s == '' then
        return 'screenshot'
    end
    return s
end

local function _strip_title_extension(title, path)
    title = trim(tostring(title or ''))
    if title == '' then
        return title
    end
    path = tostring(path or '')
    local ext = path:match('%.([%w%d]+)$')
    if not ext or ext == '' then
        return title
    end
    ext = ext:lower()
    local suffix = '.' .. ext
    if title:lower():sub(-#suffix) == suffix then
        return trim(title:sub(1, #title - #suffix))
    end
    return title
end

local function _capture_screenshot()
    local function _format_time_label(seconds)
        local total = math.max(0, math.floor(tonumber(seconds or 0) or 0))
        local hours = math.floor(total / 3600)
        local minutes = math.floor(total / 60) % 60
        local secs = total % 60
        local parts = {}
        if hours > 0 then
            table.insert(parts, ('%dh'):format(hours))
        end
        if minutes > 0 or hours > 0 then
            table.insert(parts, ('%dm'):format(minutes))
        end
        table.insert(parts, ('%ds'):format(secs))
        return table.concat(parts)
    end

    local time = mp.get_property_number('time-pos') or mp.get_property_number('time') or 0
    local label = _format_time_label(time)

    local raw_title = trim(tostring(mp.get_property('media-title') or ''))
    local raw_path = tostring(mp.get_property('path') or '')
    if raw_title == '' then
        raw_title = 'screenshot'
    end
    raw_title = _strip_title_extension(raw_title, raw_path)
    local safe_title = _sanitize_filename_component(raw_title)

    local filename = safe_title .. '_' .. label .. '.png'
    local temp_dir = mp.get_property('user-data/medeia-config-temp') or os.getenv('TEMP') or os.getenv('TMP') or '/tmp'
    local out_path = utils.join_path(temp_dir, filename)

    local function do_screenshot(mode)
        mode = mode or 'video'
        local ok, err = pcall(function()
            return mp.commandv('screenshot-to-file', out_path, mode)
        end)
        return ok, err
    end

    -- Try 'video' first (no OSD). If that fails (e.g. audio mode without video/art),
    -- try 'window' as fallback.
    local ok = do_screenshot('video')
    if not ok then
        _lua_log('screenshot: video-mode failed; trying window-mode')
        ok = do_screenshot('window')
    end

    if not ok then
        _lua_log('screenshot: BOTH video and window modes FAILED')
        mp.osd_message('Screenshot failed (no frames)', 2)
        return
    end

    _ensure_selected_store_loaded()
    local selected_store = _get_selected_store()
    selected_store = trim(tostring(selected_store or ''))
    selected_store = selected_store:gsub('^\"', ''):gsub('\"$', '')

    if selected_store == '' then
        mp.osd_message('Select a store first (Store button)', 2)
        return
    end
    
    mp.osd_message('Saving screenshot...', 1)
    
    -- optimization: use persistent pipeline helper instead of spawning new python process
    -- escape paths for command line
    local cmd = 'add-file -store "' .. selected_store .. '" -path "' .. out_path .. '"'
    
    local resp = run_pipeline_via_ipc_response(cmd, nil, 10)
    
    if resp and resp.success then
        mp.osd_message('Screenshot saved to store: ' .. selected_store, 3)
    else
        local err = (resp and resp.error) or (resp and resp.stderr) or 'IPC error'
        mp.osd_message('Screenshot upload failed: ' .. tostring(err), 5)
    end
end

mp.register_script_message('medeia-image-screenshot', function()
    _capture_screenshot()
end)


local CLIP_MARKER_SLOT_COUNT = 2
local clip_markers = {}
local initial_chapters = nil

local function _format_clip_marker_label(time)
    if type(time) ~= 'number' then
        return '0s'
    end
    local total = math.max(0, math.floor(time))
    local hours = math.floor(total / 3600)
    local minutes = math.floor(total / 60) % 60
    local seconds = total % 60
    local parts = {}
    if hours > 0 then
        table.insert(parts, ('%dh'):format(hours))
    end
    if minutes > 0 or hours > 0 then
        table.insert(parts, ('%dm'):format(minutes))
    end
    table.insert(parts, ('%ds'):format(seconds))
    return table.concat(parts)
end

local function _apply_clip_chapters()
    local chapters = {}
    if initial_chapters then
        for _, chapter in ipairs(initial_chapters) do table.insert(chapters, chapter) end
    end
    for idx = 1, CLIP_MARKER_SLOT_COUNT do
        local time = clip_markers[idx]
        if time and type(time) == 'number' then
            table.insert(chapters, {
                time = time,
                title = _format_clip_marker_label(time),
            })
        end
    end
    table.sort(chapters, function(a, b) return (a.time or 0) < (b.time or 0) end)
    mp.set_property_native('chapter-list', chapters)
end

local function _reset_clip_markers()
    for idx = 1, CLIP_MARKER_SLOT_COUNT do
        clip_markers[idx] = nil
    end
    _apply_clip_chapters()
end

local function _capture_clip()
    local time = mp.get_property_number('time-pos') or mp.get_property_number('time')
    if not time then
        mp.osd_message('Cannot capture clip; no time available', 0.7)
        return
    end
    local slot = nil
    for idx = 1, CLIP_MARKER_SLOT_COUNT do
        if not clip_markers[idx] then
            slot = idx
            break
        end
    end
    if not slot then
        local best = math.huge
        for idx = 1, CLIP_MARKER_SLOT_COUNT do
            local existing = clip_markers[idx]
            local distance = math.abs((existing or 0) - time)
            if distance < best then
                best = distance
                slot = idx
            end
        end
        slot = slot or 1
    end
    clip_markers[slot] = time
    _apply_clip_chapters()
    mp.commandv('screenshot-to-file', ('clip-%s-%.0f.png'):format(os.date('%Y%m%d-%H%M%S'), time))
    local label = _format_clip_marker_label(time)
    mp.osd_message(('Clip marker %d set at %s'):format(slot, label), 0.7)
end

mp.register_event('file-loaded', function()
    initial_chapters = mp.get_property_native('chapter-list') or {}
    _reset_clip_markers()
end)

mp.register_script_message('medeia-image-clip', function()
    _capture_clip()
end)

local function _get_trim_range_from_clip_markers()
    local times = {}
    for idx = 1, CLIP_MARKER_SLOT_COUNT do
        local t = clip_markers[idx]
        if type(t) == 'number' then
            table.insert(times, t)
        end
    end
    table.sort(times, function(a, b) return a < b end)
    if #times < 2 then
        return nil
    end
    local start_t = times[1]
    local end_t = times[2]
    if type(start_t) ~= 'number' or type(end_t) ~= 'number' then
        return nil
    end
    if end_t <= start_t then
        return nil
    end
    return _format_clip_marker_label(start_t) .. '-' .. _format_clip_marker_label(end_t)
end

local function _audio_only()
    mp.commandv('set', 'vid', 'no')
    mp.osd_message('Audio-only playback enabled', 1)
end

mp.register_script_message('medeia-audio-only', function()
    _audio_only()
end)

local function _bind_image_key(key, name, fn, opts)
    opts = opts or {}
    if ImageControl.binding_names[name] then
        pcall(mp.remove_key_binding, name)
        ImageControl.binding_names[name] = nil
    end
    local ok, err = pcall(mp.add_forced_key_binding, key, name, fn, opts)
    if ok then
        ImageControl.binding_names[name] = true
    else
        mp.msg.warn('Failed to add image binding ' .. tostring(key) .. ': ' .. tostring(err))
    end
end

local function _unbind_image_keys()
    for name in pairs(ImageControl.binding_names) do
        pcall(mp.remove_key_binding, name)
        ImageControl.binding_names[name] = nil
    end
end

local function _activate_image_controls()
    if ImageControl.enabled then
        return
    end
    ImageControl.enabled = true
    _set_image_property(true)
    _enable_image_section()
    mp.osd_message('Image viewer controls enabled', 1.2)

    _bind_image_key('LEFT', 'image-pan-left', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true})
    _bind_image_key('RIGHT', 'image-pan-right', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true})
    _bind_image_key('s', 'image-pan-s', function() _change_pan(0, ImageControl.pan_step) end, {repeatable=true})
    _bind_image_key('a', 'image-pan-a', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true})
    _bind_image_key('d', 'image-pan-d', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true})
    _bind_image_key('Shift+RIGHT', 'image-pan-right-fine', function() _change_pan(ImageControl.pan_step_slow, 0) end, {repeatable=true})
    _bind_image_key('Shift+UP', 'image-pan-up-fine', function() _change_pan(0, -ImageControl.pan_step_slow) end, {repeatable=true})
    _bind_image_key('Shift+DOWN', 'image-pan-down-fine', function() _change_pan(0, ImageControl.pan_step_slow) end, {repeatable=true})
    _bind_image_key('h', 'image-pan-h', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true})
    _bind_image_key('l', 'image-pan-l', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true})
    _bind_image_key('j', 'image-pan-j', function() _change_pan(0, ImageControl.pan_step) end, {repeatable=true})
    _bind_image_key('k', 'image-pan-k', function() _change_pan(0, -ImageControl.pan_step) end, {repeatable=true})
    _bind_image_key('w', 'image-pan-w', function() _change_pan(0, -ImageControl.pan_step) end, {repeatable=true})
    _bind_image_key('s', 'image-pan-s', function() _change_pan(0, ImageControl.pan_step) end, {repeatable=true})
    _bind_image_key('a', 'image-pan-a', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true})
    _bind_image_key('d', 'image-pan-d', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true})

    _bind_image_key('=', 'image-zoom-in', function() _change_zoom(ImageControl.zoom_step) end, {repeatable=true})
    _disable_image_section()
    _bind_image_key('-', 'image-zoom-out', function() _change_zoom(-ImageControl.zoom_step) end, {repeatable=true})
    _bind_image_key('+', 'image-zoom-in-fine', function() _change_zoom(ImageControl.zoom_step_slow) end, {repeatable=true})
    _bind_image_key('_', 'image-zoom-out-fine', function() _change_zoom(-ImageControl.zoom_step_slow) end, {repeatable=true})
    _bind_image_key('0', 'image-zoom-reset', _reset_pan_zoom)
    _bind_image_key('Space', 'image-status', function() _show_image_status('Image status') end)
    _bind_image_key('f', 'image-screenshot', _capture_screenshot)
    _install_q_block()
end

local function _deactivate_image_controls()
    if not ImageControl.enabled then
        return
    end
    ImageControl.enabled = false
    _set_image_property(false)
    _restore_q_default()
    _unbind_image_keys()
    mp.osd_message('Image viewer controls disabled', 1.0)
    mp.set_property('panscan', 0)
    mp.set_property('video-zoom', 0)
    mp.set_property_number('video-pan-x', 0)
    mp.set_property_number('video-pan-y', 0)
    mp.set_property('video-align-x', '0')
    mp.set_property('video-align-y', '0')
end

local function _update_image_mode()
    local should_image = _get_current_item_is_image()
    if should_image then
        _activate_image_controls()
    else
        _deactivate_image_controls()
    end
end

mp.register_event('file-loaded', function()
    _update_image_mode()
end)

mp.register_event('shutdown', function()
    _restore_q_default()
end)

_update_image_mode()

local function _extract_store_hash(target)
    if type(target) ~= 'string' or target == '' then
        return nil
    end
    local hash = _extract_query_param(target, 'hash')
    local store = _extract_query_param(target, 'store')
    if hash and store then
        local h = tostring(hash):lower()
        if h:match('^[0-9a-f]+$') and #h == 64 then
            return { store = tostring(store), hash = h }
        end
    end
    return nil
end

local function _pick_folder_windows()
    -- Native folder picker via PowerShell + WinForms.
    local ps = [[Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = 'Select download folder'; $d.ShowNewFolderButton = $true; if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }]]
    local res = utils.subprocess({
        -- Hide the PowerShell console window (dialog still shows).
        args = { 'powershell', '-NoProfile', '-WindowStyle', 'Hidden', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', ps },
        cancellable = false,
    })
    if res and res.status == 0 and res.stdout then
        local out = trim(tostring(res.stdout))
        if out ~= '' then
            return out
        end
    end
    return nil
end

local function _store_names_key(names)
    if type(names) ~= 'table' or #names == 0 then
        return ''
    end
    local normalized = {}
    for _, name in ipairs(names) do
        normalized[#normalized + 1] = trim(tostring(name or ''))
    end
    return table.concat(normalized, '\0')
end

local function _run_pipeline_request_async(pipeline_cmd, seeds, timeout_seconds, cb)
    cb = cb or function() end
    pipeline_cmd = trim(tostring(pipeline_cmd or ''))
    if pipeline_cmd == '' then
        cb(nil, 'empty pipeline command')
        return
    end
    ensure_mpv_ipc_server()
    local req = { pipeline = pipeline_cmd }
    if seeds then
        req.seeds = seeds
    end
    _run_helper_request_async(req, timeout_seconds or 30, cb)
end

local function _refresh_store_cache(timeout_seconds, on_complete)
    ensure_mpv_ipc_server()

    local prev_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0
    local prev_key = _store_names_key(_cached_store_names)

    local cached_json = mp.get_property('user-data/medeia-store-choices-cached')
    _lua_log('stores: cache_read cached_json=' .. tostring(cached_json) .. ' len=' .. tostring(cached_json and #cached_json or 0))

    if cached_json and cached_json ~= '' then
        local function handle_cached(resp)
            if not resp or type(resp) ~= 'table' or type(resp.choices) ~= 'table' then
                _lua_log('stores: cache_parse result missing choices table; resp_type=' .. tostring(type(resp)))
                return false
            end

            local out = {}
            for _, v in ipairs(resp.choices) do
                local name = trim(tostring(v or ''))
                if name ~= '' then
                    out[#out + 1] = name
                end
            end
            _cached_store_names = out
            _store_cache_loaded = true
            local preview = ''
            if #out > 0 then
                preview = table.concat(out, ', ')
            end
            _lua_log('stores: loaded ' .. tostring(#out) .. ' stores from cache: ' .. tostring(preview))
            if type(on_complete) == 'function' then
                on_complete(true, _store_names_key(out) ~= prev_key)
            end
            return true
        end

        local ok, cached_resp = pcall(utils.parse_json, cached_json)
        _lua_log('stores: cache_parse ok=' .. tostring(ok) .. ' resp_type=' .. tostring(type(cached_resp)))
        if ok then
            if type(cached_resp) == 'string' then
                _lua_log('stores: cache_parse returned string, trying again...')
                ok, cached_resp = pcall(utils.parse_json, cached_resp)
                _lua_log('stores: cache_parse retry ok=' .. tostring(ok) .. ' resp_type=' .. tostring(type(cached_resp)))
            end
            if ok then
                if handle_cached(cached_resp) then
                    return true
                end
            end
        else
            _lua_log('stores: cache_parse failed ok=' .. tostring(ok) .. ' resp=' .. tostring(cached_resp))
        end
    else
        _lua_log('stores: cache_empty cached_json=' .. tostring(cached_json))
    end

    _lua_log('stores: requesting store-choices via helper (fallback)')
    _run_helper_request_async({ op = 'store-choices' }, timeout_seconds or 1, function(resp, err)
        local success = false
        local changed = false
        if resp and resp.success and type(resp.choices) == 'table' then
            local out = {}
            for _, v in ipairs(resp.choices) do
                local name = trim(tostring(v or ''))
                if name ~= '' then
                    out[#out + 1] = name
                end
            end
            _cached_store_names = out
            _store_cache_loaded = true
            local preview = ''
            if #out > 0 then
                preview = table.concat(out, ', ')
            end
            _lua_log('stores: loaded ' .. tostring(#out) .. ' stores via helper request: ' .. tostring(preview))
            success = true
            changed = (#out ~= prev_count) or (_store_names_key(out) ~= prev_key)
        else
            _lua_log(
                'stores: failed to load store choices via helper; success='
                    .. tostring(resp and resp.success or false)
                    .. ' choices_type='
                    .. tostring(resp and type(resp.choices) or 'nil')
                    .. ' stderr='
                    .. tostring(resp and resp.stderr or '')
                    .. ' error='
                    .. tostring(resp and resp.error or err or '')
            )
        end
        if type(on_complete) == 'function' then
            on_complete(success, changed)
        end
    end)
    return false
end

local function _uosc_open_list_picker(menu_type, title, items)
    local menu_data = {
        type = menu_type,
        title = title,
        items = items or {},
    }
    if ensure_uosc_loaded() then
        mp.commandv('script-message-to', 'uosc', 'open-menu', utils.format_json(menu_data))
    else
        _lua_log('menu: uosc not available; cannot open-menu')
    end
end

local function _open_store_picker()
    _ensure_selected_store_loaded()

    local selected = _get_selected_store()
    local cached_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0
    local cached_preview = ''
    if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then
        cached_preview = table.concat(_cached_store_names, ', ')
    end
    _lua_log(
        'stores: open picker selected='
            .. tostring(selected)
            .. ' cached_count='
            .. tostring(cached_count)
            .. ' cached='
            .. tostring(cached_preview)
    )

    local function build_items()
        local selected = _get_selected_store()
        local items = {}

        if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then
            for _, name in ipairs(_cached_store_names) do
                name = trim(tostring(name or ''))
                if name ~= '' then
                    local payload = { store = name }
                    items[#items + 1] = {
                        title = name,
                        active = (selected ~= '' and name == selected) and true or false,
                        value = { 'script-message-to', mp.get_script_name(), 'medeia-store-select', utils.format_json(payload) },
                    }
                end
            end
        else
            items[#items + 1] = {
                title = 'No stores found',
                hint = 'Configure stores in config.conf',
                selectable = false,
            }
        end

        return items
    end

    -- Open immediately with whatever cache we have.
    _uosc_open_list_picker(STORE_PICKER_MENU_TYPE, 'Store', build_items())

    -- Best-effort refresh; retry briefly to avoid races where the helper isn't
    -- ready/observing yet at the exact moment the menu opens.
    local function attempt_refresh(tries_left)
        _refresh_store_cache(1.2, function(success, changed)
            if success and changed then
                _lua_log('stores: reopening menu (store list changed)')
                _uosc_open_list_picker(STORE_PICKER_MENU_TYPE, 'Store', build_items())
            end
        end)

        if tries_left > 0 then
            mp.add_timeout(0.25, function()
                attempt_refresh(tries_left - 1)
            end)
        end
    end

    mp.add_timeout(0.05, function()
        attempt_refresh(6)
    end)
end

mp.register_script_message('medeia-store-picker', function()
    _open_store_picker()
end)

mp.register_script_message('medeia-store-select', function(json)
    local ok, ev = pcall(utils.parse_json, json)
    if not ok or type(ev) ~= 'table' then
        return
    end
    local store = trim(tostring(ev.store or ''))
    if store == '' then
        return
    end
    _set_selected_store(store)
    mp.osd_message('Store: ' .. store, 2)
end)

-- No-op handler for placeholder menu items.
mp.register_script_message('medios-nop', function()
    return
end)

local _pending_download = nil
local _pending_format_change = nil

-- Per-file state (class-like) for format caching.
local FileState = {}
FileState.__index = FileState

function FileState.new()
    return setmetatable({
        url = nil,
        formats = nil,
        formats_table = nil, -- back-compat alias
    }, FileState)
end

function FileState:has_formats()
    return type(self.formats) == 'table'
        and type(self.formats.rows) == 'table'
        and #self.formats.rows > 0
end

function FileState:set_formats(url, tbl)
    self.url = url
    self.formats = tbl
    self.formats_table = tbl
end

M.file = M.file or FileState.new()

-- Cache yt-dlp format lists per URL so Change Format is instant.
local _formats_cache = {}
local _formats_inflight = {}
local _formats_waiters = {}

local _ipc_async_busy = false
local _ipc_async_queue = {}

local function _is_http_url(u)
    if type(u) ~= 'string' then
        return false
    end
    return u:match('^https?://') ~= nil
end

local function _cache_formats_for_url(url, tbl)
    if type(url) ~= 'string' or url == '' then
        return
    end
    if type(tbl) ~= 'table' then
        return
    end
    _formats_cache[url] = { table = tbl, ts = mp.get_time() }
    if type(M.file) == 'table' and M.file.set_formats then
        M.file:set_formats(url, tbl)
    else
        M.file.url = url
        M.file.formats = tbl
        M.file.formats_table = tbl
    end
end

local function _get_cached_formats_table(url)
    if type(url) ~= 'string' or url == '' then
        return nil
    end
    local hit = _formats_cache[url]
    if type(hit) == 'table' and type(hit.table) == 'table' then
        return hit.table
    end
    return nil
end

function FileState:fetch_formats(cb)
    local url = tostring(self.url or '')
    if url == '' or not _is_http_url(url) then
        if cb then cb(false, 'not a url') end
        return
    end

    if _extract_store_hash(url) then
        if cb then cb(false, 'store-hash url') end
        return
    end

    local cached = _get_cached_formats_table(url)
    if type(cached) == 'table' then
        self:set_formats(url, cached)
        if cb then cb(true, nil) end
        return
    end

    if _formats_inflight[url] then
        _formats_waiters[url] = _formats_waiters[url] or {}
        if cb then table.insert(_formats_waiters[url], cb) end
        return
    end
    _formats_inflight[url] = true
    _formats_waiters[url] = _formats_waiters[url] or {}
    if cb then table.insert(_formats_waiters[url], cb) end

    _run_helper_request_async({ op = 'ytdlp-formats', data = { url = url } }, 90, function(resp, err)
        _formats_inflight[url] = nil

        local ok = false
        local reason = err
        if resp and resp.success and type(resp.table) == 'table' then
            ok = true
            reason = nil
            self:set_formats(url, resp.table)
            _cache_formats_for_url(url, resp.table)
            _lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url')
        else
            if type(resp) == 'table' then
                if resp.error and tostring(resp.error) ~= '' then
                    reason = tostring(resp.error)
                elseif resp.stderr and tostring(resp.stderr) ~= '' then
                    reason = tostring(resp.stderr)
                end
            end
        end

        local waiters = _formats_waiters[url] or {}
        _formats_waiters[url] = nil
        for _, fn in ipairs(waiters) do
            pcall(fn, ok, reason)
        end
    end)
end

local function _prefetch_formats_for_url(url)
    url = tostring(url or '')
    if url == '' or not _is_http_url(url) then
        return
    end
    if type(M.file) == 'table' then
        M.file.url = url
        if M.file.fetch_formats then
            M.file:fetch_formats(nil)
        end
    end
end

local function _open_loading_formats_menu(title)
    _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, title or 'Pick format', {
        {
            title = 'Loading formats…',
            hint = 'Fetching format list',
            value = { 'script-message-to', mp.get_script_name(), 'medios-nop', '{}' },
        },
    })
end

local function _debug_dump_formatted_formats(url, tbl, items)
    local row_count = 0
    if type(tbl) == 'table' and type(tbl.rows) == 'table' then
        row_count = #tbl.rows
    end
    local item_count = 0
    if type(items) == 'table' then
        item_count = #items
    end

    _lua_log('formats-dump: url=' .. tostring(url or '') .. ' rows=' .. tostring(row_count) .. ' menu_items=' .. tostring(item_count))

    -- Dump the formatted picker items (first 30) so we can confirm the
    -- list is being built and looks sane.
    if type(items) == 'table' then
        local limit = 30
        for i = 1, math.min(#items, limit) do
            local it = items[i] or {}
            local title = tostring(it.title or '')
            local hint = tostring(it.hint or '')
            _lua_log('formats-item[' .. tostring(i) .. ']: ' .. title .. (hint ~= '' and (' | ' .. hint) or ''))
        end
        if #items > limit then
            _lua_log('formats-dump: (truncated; total=' .. tostring(#items) .. ')')
        end
    end
end

local function _current_ytdl_format_string()
    -- Preferred: mpv exposes the active ytdl format string.
    local fmt = trim(tostring(mp.get_property_native('ytdl-format') or ''))
    if fmt ~= '' then
        return fmt
    end

    -- Fallbacks: option value, or raw info if available.
    local opt = trim(tostring(mp.get_property('options/ytdl-format') or ''))
    if opt ~= '' then
        return opt
    end

    local raw = mp.get_property_native('ytdl-raw-info')
    if type(raw) == 'table' then
        if raw.format_id and tostring(raw.format_id) ~= '' then
            return tostring(raw.format_id)
        end
        local rf = raw.requested_formats
        if type(rf) == 'table' then
            local parts = {}
            for _, item in ipairs(rf) do
                if type(item) == 'table' and item.format_id and tostring(item.format_id) ~= '' then
                    parts[#parts + 1] = tostring(item.format_id)
                end
            end
            if #parts >= 1 then
                return table.concat(parts, '+')
            end
        end
    end

    return nil
end

local function _run_pipeline_detached(pipeline_cmd, on_failure)
    if not pipeline_cmd or pipeline_cmd == '' then
        return false
    end
    ensure_mpv_ipc_server()
    if not ensure_pipeline_helper_running() then
        if type(on_failure) == 'function' then
            on_failure(nil, 'helper not running')
        end
        return false
    end
    _run_helper_request_async({ op = 'run-detached', data = { pipeline = pipeline_cmd } }, 1.0, function(resp, err)
        if resp and resp.success then
            return
        end
        if type(on_failure) == 'function' then
            on_failure(resp, err)
        end
    end)
    return true
end

local function _open_save_location_picker_for_pending_download()
    if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then
        return
    end

    local function build_items()
        local items = {
            {
                title = 'Pick folder…',
                hint = 'Save to a local folder',
                value = { 'script-message-to', mp.get_script_name(), 'medios-download-pick-path', '{}' },
            },
        }

        if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then
            for _, name in ipairs(_cached_store_names) do
                name = trim(tostring(name or ''))
                if name ~= '' then
                    local payload = { store = name }
                    items[#items + 1] = {
                        title = name,
                        value = { 'script-message-to', mp.get_script_name(), 'medios-download-pick-store', utils.format_json(payload) },
                    }
                end
            end
        end
        return items
    end

    -- Always open immediately with whatever store cache we have.
    _uosc_open_list_picker(DOWNLOAD_STORE_MENU_TYPE, 'Save location', build_items())

    -- Best-effort refresh; if it succeeds, reopen menu with stores.
    mp.add_timeout(0.05, function()
        if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then
            return
        end
        _refresh_store_cache(1.5, function(success, changed)
            if success and changed then
                _uosc_open_list_picker(DOWNLOAD_STORE_MENU_TYPE, 'Save location', build_items())
            end
        end)
    end)
end

-- Prime store cache shortly after load (best-effort; picker also refreshes on-demand).
mp.add_timeout(0.10, function()
    pcall(_ensure_selected_store_loaded)
    if not _store_cache_loaded then
        pcall(_refresh_store_cache, 1.5)
    end
end)

local function _apply_ytdl_format_and_reload(url, fmt)
    if not url or url == '' or not fmt or fmt == '' then
        return
    end

    local pos = mp.get_property_number('time-pos')
    local paused = mp.get_property_native('pause') and true or false

    _lua_log('change-format: setting options/ytdl-format=' .. tostring(fmt))
    pcall(mp.set_property, 'options/ytdl-format', tostring(fmt))

    if pos and pos > 0 then
        mp.commandv('loadfile', url, 'replace', 'start=' .. tostring(pos))
    else
        mp.commandv('loadfile', url, 'replace')
    end

    if paused then
        mp.set_property_native('pause', true)
    end
end

local function _start_download_flow_for_current()
    local target = _current_target()
    if not target or target == '' then
        mp.osd_message('No current item', 2)
        return
    end

    _lua_log('download: current target=' .. tostring(target))

    local store_hash = _extract_store_hash(target)
    if store_hash then
        if not _is_windows() then
            mp.osd_message('Download folder picker is Windows-only', 4)
            return
        end
        local folder = _pick_folder_windows()
        if not folder or folder == '' then
            return
        end
        ensure_mpv_ipc_server()
        local pipeline_cmd = 'get-file -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder)
        M.run_pipeline(pipeline_cmd, nil, function(_, err)
            if err then
                mp.osd_message('Download failed: ' .. tostring(err), 5)
            end
        end)
        mp.osd_message('Download started', 2)
        return
    end

    -- Non-store URL flow: use the current yt-dlp-selected format and ask for save location.
    local url = tostring(target)
    local fmt = _current_ytdl_format_string()

    if not fmt or fmt == '' then
        _lua_log('download: could not determine current ytdl format string')
        mp.osd_message('Cannot determine current format; use Change Format first', 5)
        return
    end

    _lua_log('download: using current format=' .. tostring(fmt))
    _pending_download = { url = url, format = fmt }
    _open_save_location_picker_for_pending_download()
end

mp.register_script_message('medios-download-current', function()
    _start_download_flow_for_current()
end)

mp.register_script_message('medios-change-format-current', function()
    local target = _current_target()
    if not target or target == '' then
        mp.osd_message('No current item', 2)
        return
    end

    local store_hash = _extract_store_hash(target)
    if store_hash then
        mp.osd_message('Change Format is only for URL playback', 4)
        return
    end

    local url = tostring(target)

    -- Ensure file state is tracking the current URL.
    if type(M.file) == 'table' then
        M.file.url = url
    end

    -- If formats were already prefetched for this URL, open instantly.
    local cached_tbl = nil
    if type(M.file) == 'table' and type(M.file.formats) == 'table' then
        cached_tbl = M.file.formats
    else
        cached_tbl = _get_cached_formats_table(url)
    end
    if type(cached_tbl) == 'table' and type(cached_tbl.rows) == 'table' and #cached_tbl.rows > 0 then
        _pending_format_change = { url = url, token = 'cached', formats_table = cached_tbl }

        local items = {}
        for idx, row in ipairs(cached_tbl.rows) do
            local cols = row.columns or {}
            local id_val = ''
            local res_val = ''
            local ext_val = ''
            local size_val = ''
            for _, c in ipairs(cols) do
                if c.name == 'ID' then id_val = tostring(c.value or '') end
                if c.name == 'Resolution' then res_val = tostring(c.value or '') end
                if c.name == 'Ext' then ext_val = tostring(c.value or '') end
                if c.name == 'Size' then size_val = tostring(c.value or '') end
            end
            local label = id_val ~= '' and id_val or ('Format ' .. tostring(idx))
            local hint_parts = {}
            if res_val ~= '' and res_val ~= 'N/A' then table.insert(hint_parts, res_val) end
            if ext_val ~= '' then table.insert(hint_parts, ext_val) end
            if size_val ~= '' and size_val ~= 'N/A' then table.insert(hint_parts, size_val) end
            local hint = table.concat(hint_parts, ' | ')

            local payload = { index = idx }
            items[#items + 1] = {
                title = label,
                hint = hint,
                value = { 'script-message-to', mp.get_script_name(), 'medios-change-format-pick', utils.format_json(payload) },
            }
        end

        _debug_dump_formatted_formats(url, cached_tbl, items)
        _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
        return
    end

    local token = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999))
    _pending_format_change = { url = url, token = token }
    _open_loading_formats_menu('Change format')

    -- Non-blocking: ask the per-file state to fetch formats in the background.
    if type(M.file) == 'table' and M.file.fetch_formats then
        _lua_log('change-format: formats not cached yet; fetching in background')
        M.file:fetch_formats(function(ok, err)
            if type(_pending_format_change) ~= 'table' or _pending_format_change.token ~= token then
                return
            end
            if not ok then
                local msg2 = tostring(err or '')
                if msg2 == '' then
                    msg2 = 'unknown'
                end
                _lua_log('change-format: formats failed: ' .. msg2)
                mp.osd_message('Failed to load format list: ' .. msg2, 7)
                _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', {
                    {
                        title = 'Failed to load format list',
                        hint = msg2,
                        value = { 'script-message-to', mp.get_script_name(), 'medios-nop', '{}' },
                    },
                })
                return
            end

            local tbl = (type(M.file.formats) == 'table') and M.file.formats or _get_cached_formats_table(url)
            if type(tbl) ~= 'table' or type(tbl.rows) ~= 'table' or #tbl.rows == 0 then
                mp.osd_message('No formats available', 4)
                return
            end

            local items = {}
            for idx, row in ipairs(tbl.rows) do
                local cols = row.columns or {}
                local id_val = ''
                local res_val = ''
                local ext_val = ''
                local size_val = ''
                for _, c in ipairs(cols) do
                    if c.name == 'ID' then id_val = tostring(c.value or '') end
                    if c.name == 'Resolution' then res_val = tostring(c.value or '') end
                    if c.name == 'Ext' then ext_val = tostring(c.value or '') end
                    if c.name == 'Size' then size_val = tostring(c.value or '') end
                end
                local label = id_val ~= '' and id_val or ('Format ' .. tostring(idx))
                local hint_parts = {}
                if res_val ~= '' and res_val ~= 'N/A' then table.insert(hint_parts, res_val) end
                if ext_val ~= '' then table.insert(hint_parts, ext_val) end
                if size_val ~= '' and size_val ~= 'N/A' then table.insert(hint_parts, size_val) end
                local hint = table.concat(hint_parts, ' | ')

                local payload = { index = idx }
                items[#items + 1] = {
                    title = label,
                    hint = hint,
                    value = { 'script-message-to', mp.get_script_name(), 'medios-change-format-pick', utils.format_json(payload) },
                }
            end

            _pending_format_change.formats_table = tbl
            _debug_dump_formatted_formats(url, tbl, items)
            _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
        end)
    end
end)

-- Prefetch formats for yt-dlp-supported URLs on load so Change Format is instant.
mp.register_event('file-loaded', function()
    local target = _current_target()
    if not target or target == '' then
        return
    end
    local url = tostring(target)
    if not _is_http_url(url) then
        return
    end
    _prefetch_formats_for_url(url)
end)

mp.register_script_message('medios-change-format-pick', function(json)
    if type(_pending_format_change) ~= 'table' or not _pending_format_change.url then
        return
    end
    local ok, ev = pcall(utils.parse_json, json)
    if not ok or type(ev) ~= 'table' then
        return
    end
    local idx = tonumber(ev.index or 0) or 0
    if idx <= 0 then
        return
    end
    local tbl = _pending_format_change.formats_table
    if type(tbl) ~= 'table' or type(tbl.rows) ~= 'table' or not tbl.rows[idx] then
        return
    end
    local row = tbl.rows[idx]
    local sel = row.selection_args
    local fmt = nil
    if type(sel) == 'table' then
        for i = 1, #sel do
            if tostring(sel[i]) == '-format' and sel[i + 1] then
                fmt = tostring(sel[i + 1])
                break
            end
        end
    end
    if not fmt or fmt == '' then
        mp.osd_message('Invalid format selection', 3)
        return
    end

    local url = tostring(_pending_format_change.url)
    _pending_format_change = nil
    _apply_ytdl_format_and_reload(url, fmt)
end)

mp.register_script_message('medios-download-pick-store', function(json)
    if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then
        return
    end
    local ok, ev = pcall(utils.parse_json, json)
    if not ok or type(ev) ~= 'table' then
        return
    end
    local store = trim(tostring(ev.store or ''))
    if store == '' then
        return
    end

    local url = tostring(_pending_download.url)
    local fmt = tostring(_pending_download.format)

    local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) .. ' -format ' .. quote_pipeline_arg(fmt)
        .. ' | add-file -store ' .. quote_pipeline_arg(store)

    local function run_pipeline_direct()
        M.run_pipeline(pipeline_cmd, nil, function(_, err)
            if err then
                mp.osd_message('Download failed: ' .. tostring(err), 5)
            end
        end)
    end

    if not _run_pipeline_detached(pipeline_cmd, function()
        run_pipeline_direct()
    end) then
        run_pipeline_direct()
    end
    mp.osd_message('Download started', 3)
    _pending_download = nil
end)

mp.register_script_message('medios-download-pick-path', function()
    if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then
        return
    end
    if not _is_windows() then
        mp.osd_message('Folder picker is Windows-only', 4)
        return
    end

    local folder = _pick_folder_windows()
    if not folder or folder == '' then
        return
    end

    local url = tostring(_pending_download.url)
    local fmt = tostring(_pending_download.format)

    local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) .. ' -format ' .. quote_pipeline_arg(fmt)
        .. ' | add-file -path ' .. quote_pipeline_arg(folder)

    local function run_pipeline_direct()
        M.run_pipeline(pipeline_cmd, nil, function(_, err)
            if err then
                mp.osd_message('Download failed: ' .. tostring(err), 5)
            end
        end)
    end

    if not _run_pipeline_detached(pipeline_cmd, function()
        run_pipeline_direct()
    end) then
        run_pipeline_direct()
    end
    mp.osd_message('Download started', 3)
    _pending_download = nil
end)

local function run_pipeline_via_ipc(pipeline_cmd, seeds, timeout_seconds)
    if not ensure_pipeline_helper_running() then
        return nil
    end

    -- Avoid a race where we send the request before the helper has connected
    -- and installed its property observer, which would cause a timeout and
    -- force a noisy CLI fallback.
    do
        local deadline = mp.get_time() + 1.0
        while mp.get_time() < deadline do
            if _is_pipeline_helper_ready() then
                break
            end
            mp.wait_event(0.05)
        end
        if not _is_pipeline_helper_ready() then
            _pipeline_helper_started = false
            return nil
        end
    end

    local id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999))
    local req = { id = id, pipeline = pipeline_cmd }
    if seeds then
        req.seeds = seeds
    end

    -- Clear any previous response to reduce chances of reading stale data.
    mp.set_property(PIPELINE_RESP_PROP, '')
    mp.set_property(PIPELINE_REQ_PROP, utils.format_json(req))

    local deadline = mp.get_time() + (timeout_seconds or 5)
    while mp.get_time() < deadline do
        local resp_json = mp.get_property(PIPELINE_RESP_PROP)
        if resp_json and resp_json ~= '' then
            local ok, resp = pcall(utils.parse_json, resp_json)
            if ok and resp and resp.id == id then
                if resp.success then
                    return resp.stdout or ''
                end
                local details = ''
                if resp.error and tostring(resp.error) ~= '' then
                    details = tostring(resp.error)
                end
                if resp.stderr and tostring(resp.stderr) ~= '' then
                    if details ~= '' then
                        details = details .. "\n"
                    end
                    details = details .. tostring(resp.stderr)
                end
                local log_path = resp.log_path
                if log_path and tostring(log_path) ~= '' then
                    details = (details ~= '' and (details .. "\n") or '') .. 'Log: ' .. tostring(log_path)
                end
                return nil, (details ~= '' and details or 'unknown')
            end
        end
        mp.wait_event(0.05)
    end
    -- Helper may have crashed or never started; allow retry on next call.
    _pipeline_helper_started = false
    return nil
end

-- Detect CLI path
local function detect_script_dir()
    local dir = mp.get_script_directory()
    if dir and dir ~= "" then return dir end

    -- Fallback to debug info path
    local src = debug.getinfo(1, "S").source
    if src and src:sub(1, 1) == "@" then
        local path = src:sub(2)
        local parent = path:match("(.*)[/\\]")
        if parent and parent ~= "" then
            return parent
        end
    end

    -- Fallback to working directory
    local cwd = utils.getcwd()
    if cwd and cwd ~= "" then return cwd end
    return nil
end

local script_dir = detect_script_dir() or ""
if not opts.cli_path then
    -- Try to locate CLI.py by walking up from this script directory.
    -- Typical layout here is: <repo>/MPV/LUA/main.lua, and <repo>/CLI.py
    opts.cli_path = find_file_upwards(script_dir, "CLI.py", 6) or "CLI.py"
end

-- Ref: mpv_lua_api.py was removed in favor of pipeline_helper (run_pipeline_via_ipc_response).
-- This placeholder comment ensures we don't have code shifting issues.

-- Run a Medeia pipeline command via the Python pipeline helper (IPC request/response).
-- Calls the callback with stdout on success or error message on failure.
function M.run_pipeline(pipeline_cmd, seeds, cb)
    cb = cb or function() end
    pipeline_cmd = trim(tostring(pipeline_cmd or ''))
    if pipeline_cmd == '' then
        cb(nil, 'empty pipeline command')
        return
    end
    ensure_mpv_ipc_server()

    _run_pipeline_request_async(pipeline_cmd, seeds, 30, function(resp, err)
        if resp and resp.success then
            cb(resp.stdout or '', nil)
            return
        end
        local details = err or ''
        if details == '' and type(resp) == 'table' then
            if resp.error and tostring(resp.error) ~= '' then
                details = tostring(resp.error)
            elseif resp.stderr and tostring(resp.stderr) ~= '' then
                details = tostring(resp.stderr)
            end
        end
        if details == '' then
            details = 'unknown'
        end
        _lua_log('pipeline failed cmd=' .. tostring(pipeline_cmd) .. ' err=' .. details)
        cb(nil, details)
    end)
end

-- Helper to run pipeline and parse JSON output
function M.run_pipeline_json(pipeline_cmd, seeds, cb)
    cb = cb or function() end
    if not pipeline_cmd:match('output%-json$') then
        pipeline_cmd = pipeline_cmd .. ' | output-json'
    end
    M.run_pipeline(pipeline_cmd, seeds, function(output, err)
        if output then
            local ok, data = pcall(utils.parse_json, output)
            if ok then
                cb(data, nil)
                return
            end
            _lua_log('Failed to parse JSON: ' .. output)
            cb(nil, 'malformed JSON response')
            return
        end
        cb(nil, err)
    end)
end

-- Command: Get info for current file
function M.get_file_info()
    local path = mp.get_property('path')
    if not path then
        return
    end

    local seed = {{path = path}}

    M.run_pipeline_json('get-metadata', seed, function(data, err)
        if data then
            _lua_log('Metadata: ' .. utils.format_json(data))
            mp.osd_message('Metadata loaded (check console)', 3)
            return
        end
        if err then
            mp.osd_message('Failed to load metadata: ' .. tostring(err), 3)
        end
    end)
end

-- Command: Delete current file
function M.delete_current_file()
    local path = mp.get_property('path')
    if not path then
        return
    end

    local seed = {{path = path}}

    M.run_pipeline('delete-file', seed, function(_, err)
        if err then
            mp.osd_message('Delete failed: ' .. tostring(err), 3)
            return
        end
        mp.osd_message('File deleted', 3)
        mp.command('playlist-next')
    end)
end

-- Command: Load a URL via pipeline (Ctrl+Enter in prompt)
function M.open_load_url_prompt()
    local menu_data = {
        type = LOAD_URL_MENU_TYPE,
        title = 'Load URL',
        search_style = 'palette',
        search_debounce = 'submit',
        on_search = 'callback',
        footnote = 'Paste/type URL, then Ctrl+Enter to load.',
        callback = {mp.get_script_name(), 'medios-load-url-event'},
        items = {},
    }

    local json = utils.format_json(menu_data)
    if ensure_uosc_loaded() then
        mp.commandv('script-message-to', 'uosc', 'open-menu', json)
    else
        _lua_log('menu: uosc not available; cannot open-menu')
    end
end

-- Open the command submenu with tailored cmdlets (screenshot, clip, trim prompt)
function M.open_cmd_menu()
    local items = {
        {
            title = 'Screenshot',
            hint = 'Capture a screenshot',
            value = { 'script-message-to', mp.get_script_name(), 'medios-cmd-exec', utils.format_json({ cmd = 'screenshot' }) },
        },
        {
            title = 'Capture clip marker',
            hint = 'Place a clip marker at current time',
            value = { 'script-message-to', mp.get_script_name(), 'medios-cmd-exec', utils.format_json({ cmd = 'clip' }) },
        },
        {
            title = 'Trim file',
            hint = 'Trim current file (prompt for range)',
            value = { 'script-message-to', mp.get_script_name(), 'medios-cmd-exec', utils.format_json({ cmd = 'trim' }) },
        },
    }

    local menu_data = {
        type = CMD_MENU_TYPE,
        title = 'Cmd',
        search_style = 'palette',
        search_debounce = 'submit',
        footnote = 'Type to filter or pick a command',
        items = items,
    }

    local json = utils.format_json(menu_data)
    if ensure_uosc_loaded() then
        mp.commandv('script-message-to', 'uosc', 'open-menu', json)
    else
        _lua_log('menu: uosc not available; cannot open cmd menu')
    end
end

-- Prompt for trim range via an input box and callback
local function _start_trim_with_range(range)
    _lua_log('=== TRIM START: range=' .. tostring(range))
    mp.osd_message('Trimming...', 10)
    
    -- Load the trim module for direct FFmpeg trimming
    local script_dir = mp.get_script_directory()
    _lua_log('trim: script_dir=' .. tostring(script_dir))
    
    -- Try multiple locations for trim.lua
    local trim_paths = {}
    if script_dir and script_dir ~= '' then
        table.insert(trim_paths, script_dir .. '/trim.lua')
        table.insert(trim_paths, script_dir .. '/LUA/trim.lua')  -- if called from parent
        table.insert(trim_paths, script_dir .. '/../trim.lua')
    end
    
    -- Also try absolute path
    table.insert(trim_paths, '/medios/Medios-Macina/MPV/LUA/trim.lua')
    table.insert(trim_paths, 'C:/medios/Medios-Macina/MPV/LUA/trim.lua')
    
    local trim_module = nil
    local load_err = nil
    
    for _, trim_path in ipairs(trim_paths) do
        _lua_log('trim: trying path=' .. trim_path)
        local ok, result = pcall(loadfile, trim_path)
        if ok and result then
            trim_module = result()
            _lua_log('trim: loaded successfully from ' .. trim_path)
            break
        else
            load_err = tostring(result or 'unknown error')
            _lua_log('trim: failed to load from ' .. trim_path .. ' (' .. load_err .. ')')
        end
    end
    
    if not trim_module or not trim_module.trim_file then
        mp.osd_message('ERROR: Could not load trim module from any path', 3)
        _lua_log('trim: FAILED - all paths exhausted, last error=' .. tostring(load_err))
        return
    end
    
    range = trim(tostring(range or ''))
    _lua_log('trim: after_trim range=' .. tostring(range))
    
    if range == '' then
        mp.osd_message('Trim cancelled (no range provided)', 3)
        _lua_log('trim: CANCELLED - empty range')
        return
    end

    local target = _current_target()
    if not target or target == '' then
        mp.osd_message('No file to trim', 3)
        _lua_log('trim: FAILED - no target')
        return
    end
    _lua_log('trim: target=' .. tostring(target))

    local store_hash = _extract_store_hash(target)
    if store_hash then
        _lua_log('trim: store_hash detected store=' .. tostring(store_hash.store) .. ' hash=' .. tostring(store_hash.hash))
    else
        _lua_log('trim: store_hash=nil (local file)')
    end
    
    -- Get the selected store (this reads from saved config or mpv property)
    _ensure_selected_store_loaded()
    local selected_store = _get_selected_store()
    -- Strip any existing quotes from the store name
    selected_store = selected_store:gsub('^"', ''):gsub('"$', '')
    _lua_log('trim: selected_store=' .. tostring(selected_store or 'NONE'))
    _lua_log('trim: _cached_store_names=' .. tostring(_cached_store_names and #_cached_store_names or 0))
    _lua_log('trim: _selected_store_index=' .. tostring(_selected_store_index or 'nil'))

    local stream = trim(tostring(mp.get_property('stream-open-filename') or ''))
    if stream == '' then
        stream = tostring(target)
    end
    _lua_log('trim: stream=' .. tostring(stream))

    local title = trim(tostring(mp.get_property('media-title') or ''))
    if title == '' then
        title = 'clip'
    end
    _lua_log('trim: title=' .. tostring(title))

    -- ===== TRIM IN LUA USING FFMPEG =====
    mp.osd_message('Starting FFmpeg trim...', 1)
    _lua_log('trim: calling trim_module.trim_file with range=' .. range)
    
    -- Get temp directory from config or use default
    local temp_dir = mp.get_property('user-data/medeia-config-temp') or os.getenv('TEMP') or os.getenv('TMP') or '/tmp'
    _lua_log('trim: using temp_dir=' .. temp_dir)
    
    local success, output_path, error_msg = trim_module.trim_file(stream, range, temp_dir)
    
    if not success then
        mp.osd_message('Trim failed: ' .. error_msg, 3)
        _lua_log('trim: FAILED - ' .. error_msg)
        return
    end
    
    _lua_log('trim: FFmpeg SUCCESS - output_path=' .. output_path)
    mp.osd_message('Trim complete, uploading...', 2)
    
    -- ===== UPLOAD TO PYTHON FOR STORAGE AND METADATA =====
    local pipeline_cmd = nil
    _lua_log('trim: === BUILDING UPLOAD PIPELINE ===')
    _lua_log('trim: store_hash=' .. tostring(store_hash and (store_hash.store .. '/' .. store_hash.hash) or 'nil'))
    _lua_log('trim: selected_store=' .. tostring(selected_store or 'nil'))
    
    if store_hash then
        -- Original file is from a store - set relationship to it
        _lua_log('trim: building store file pipeline (original from store)')
        if selected_store then
            pipeline_cmd =
                'get-tag -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
                ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
                ' | add-file -path ' .. quote_pipeline_arg(output_path) ..
                ' -store "' .. selected_store .. '"' ..
                ' | add-relationship -store "' .. selected_store .. '"' ..
                ' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
        else
            pipeline_cmd =
                'get-tag -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
                ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
                ' | add-file -path ' .. quote_pipeline_arg(output_path) ..
                ' -store "' .. store_hash.store .. '"' ..
                ' | add-relationship -store "' .. store_hash.store .. '"' ..
                ' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
        end
    else
        -- Local file: save to selected store if available
        _lua_log('trim: local file pipeline (not from store)')
        if selected_store then
            _lua_log('trim: building add-file command to selected_store=' .. selected_store)
            -- Don't add title if empty - the file path will be used as title by default
            pipeline_cmd = 'add-file -path ' .. quote_pipeline_arg(output_path) ..
                ' -store "' .. selected_store .. '"'
            _lua_log('trim: pipeline_cmd=' .. pipeline_cmd)
        else
            mp.osd_message('Trim complete: ' .. output_path, 5)
            _lua_log('trim: no store selected, trim complete at ' .. output_path)
            return
        end
    end

    if not pipeline_cmd or pipeline_cmd == '' then
        mp.osd_message('Trim error: could not build upload command', 3)
        _lua_log('trim: FAILED - empty pipeline_cmd')
        return
    end

    _lua_log('trim: final upload_cmd=' .. pipeline_cmd)
    _lua_log('trim: === CALLING PIPELINE HELPER FOR UPLOAD ===')
    
    -- Optimization: use persistent pipeline helper
    local response = run_pipeline_via_ipc_response(pipeline_cmd, nil, 60)
    
    if not response then
        response = { success = false, error = "Timeout or IPC error" }
    end
    
    _lua_log('trim: api response success=' .. tostring(response.success))
    _lua_log('trim: api response error=' .. tostring(response.error or 'nil'))
    _lua_log('trim: api response stderr=' .. tostring(response.stderr or 'nil'))
    _lua_log('trim: api response returncode=' .. tostring(response.returncode or 'nil'))
    
    if response.stderr and response.stderr ~= '' then
        _lua_log('trim: STDERR OUTPUT: ' .. response.stderr)
    end
    
    if response.success then
        local msg = 'Trim and upload completed'
        if selected_store then
            msg = msg .. ' (store: ' .. selected_store .. ')'
        end
        mp.osd_message(msg, 5)
        _lua_log('trim: SUCCESS - ' .. msg)
    else
        local err_msg = response.error or response.stderr or 'unknown error'
        mp.osd_message('Upload failed: ' .. err_msg, 5)
        _lua_log('trim: upload FAILED - ' .. err_msg)
    end
end

function M.open_trim_prompt()
    _lua_log('=== OPEN_TRIM_PROMPT called')
    
    local marker_range = _get_trim_range_from_clip_markers()
    _lua_log('trim_prompt: marker_range=' .. tostring(marker_range or 'NONE'))
    
    if marker_range then
        _lua_log('trim_prompt: using auto-detected markers, starting trim')
        mp.osd_message('Using clip markers: ' .. marker_range, 2)
        _start_trim_with_range(marker_range)
        return
    end

    _lua_log('trim_prompt: no clip markers detected, showing prompt')
    mp.osd_message('Set 2 clip markers with the marker button, or enter range manually', 3)
    
    local selected_store = _cached_store_names and #_cached_store_names > 0 and _selected_store_index 
        and _cached_store_names[_selected_store_index] or nil
    local store_hint = selected_store and ' (saving to: ' .. selected_store .. ')' or ' (no store selected; will save locally)'

    local menu_data = {
        type = TRIM_PROMPT_MENU_TYPE,
        title = 'Trim file',
        search_style = 'palette',
        search_debounce = 'submit',
        on_search = 'callback',
        footnote = "Enter time range (e.g. '00:03:45-00:03:55' or '1h3m-1h10m30s') and press Enter" .. store_hint,
        callback = { mp.get_script_name(), 'medios-trim-run' },
        items = {
            {
                title = 'Enter range...',
                hint = 'Type range and press Enter',
                value = { 'script-message-to', mp.get_script_name(), 'medios-trim-run' },
            }
        }
    }

    local json = utils.format_json(menu_data)
    if ensure_uosc_loaded() then
        mp.commandv('script-message-to', 'uosc', 'open-menu', json)
    else
        _lua_log('menu: uosc not available; cannot open trim prompt')
    end
end

-- Handlers for the command submenu
mp.register_script_message('medios-open-cmd', function()
    M.open_cmd_menu()
end)

mp.register_script_message('medios-cmd-exec', function(json)
    local ok, ev = pcall(utils.parse_json, json)
    if not ok or type(ev) ~= 'table' then
        return
    end
    local cmd = trim(tostring(ev.cmd or ''))
    if cmd == 'screenshot' then
        _capture_screenshot()
    elseif cmd == 'clip' then
        _capture_clip()
    elseif cmd == 'trim' then
        M.open_trim_prompt()
    else
        mp.osd_message('Unknown cmd ' .. tostring(cmd), 2)
    end
end)

mp.register_script_message('medios-trim-run', function(json)
    local ok, ev = pcall(utils.parse_json, json)
    local range = nil
    if ok and type(ev) == 'table' then
        if ev.type == 'search' then
            range = trim(tostring(ev.query or ''))
        end
    end
    _start_trim_with_range(range)
end)

mp.register_script_message('medios-load-url', function()
    M.open_load_url_prompt()
end)

mp.register_script_message('medios-load-url-event', function(json)
    local ok, event = pcall(utils.parse_json, json)
    if not ok or type(event) ~= 'table' then
        return
    end
    if event.type ~= 'search' then
        return
    end

    local url = trim(tostring(event.query or ''))
    if url == '' then
        return
    end

    ensure_mpv_ipc_server()
    local pipeline_cmd = '.mpv -url ' .. quote_pipeline_arg(url) .. ' -play'
    M.run_pipeline(pipeline_cmd, nil, function(_, err)
        if err then
            mp.osd_message('Load URL failed: ' .. tostring(err), 3)
            return
        end
        if ensure_uosc_loaded() then
            mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE)
        else
            _lua_log('menu: uosc not available; cannot close-menu')
        end
    end)
end)

-- Menu integration with UOSC
function M.show_menu()
    local menu_data = {
        title = "Medios Macina",
        items = {
            { title = "Get Metadata", value = "script-binding medios-info", hint = "Ctrl+i" },
            { title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" },
            { title = "Load URL", value = {"script-message-to", mp.get_script_name(), "medios-load-url"} },
            { title = "Cmd", value = {"script-message-to", mp.get_script_name(), "medios-open-cmd"}, hint = "Run quick commands (screenshot, trim, etc)" },
            { title = "Download", value = {"script-message-to", mp.get_script_name(), "medios-download-current"} },
            { title = "Change Format", value = {"script-message-to", mp.get_script_name(), "medios-change-format-current"} },
        }
    }
    
    local json = utils.format_json(menu_data)
    if ensure_uosc_loaded() then
        mp.commandv('script-message-to', 'uosc', 'open-menu', json)
    else
        _lua_log('menu: uosc not available; cannot open-menu')
    end
end

-- Keybindings
mp.add_key_binding("m", "medios-menu", M.show_menu)
mp.add_key_binding("mbtn_right", "medios-menu-right-click", M.show_menu)
mp.add_key_binding("ctrl+i", "medios-info", M.get_file_info)
mp.add_key_binding("ctrl+del", "medios-delete", M.delete_current_file)

-- Lyrics toggle (requested: 'L')
mp.add_key_binding("l", "medeia-lyric-toggle", lyric_toggle)
mp.add_key_binding("L", "medeia-lyric-toggle-shift", lyric_toggle)



-- Start the persistent pipeline helper eagerly at launch.
-- This avoids spawning Python per command and works cross-platform via MPV IPC.
mp.add_timeout(0, function()
    pcall(ensure_mpv_ipc_server)
    pcall(_lua_log, 'medeia-lua loaded version=' .. MEDEIA_LUA_VERSION)

    -- Load optional modules (kept in separate files).
    pcall(function()
        local script_dir = mp.get_script_directory() or ''
        local candidates = {}
        if script_dir ~= '' then
            table.insert(candidates, script_dir .. '/sleep_timer.lua')
            table.insert(candidates, script_dir .. '/LUA/sleep_timer.lua')
            table.insert(candidates, script_dir .. '/../sleep_timer.lua')
        end
        table.insert(candidates, 'C:/medios/Medios-Macina/MPV/LUA/sleep_timer.lua')
        for _, p in ipairs(candidates) do
            local ok, chunk = pcall(loadfile, p)
            if ok and chunk then
                pcall(chunk)
                break
            end
        end
    end)
end)

return M
