-- Resolume Piano/Trigger Sequence Builder
-- Standalone grandMA3 Lua plugin for clip press/release sequence controls.
--
-- This file is intentionally separate from the existing Resolume control builder.
-- It creates Sequences and executor assignments only.
--
-- OSC command syntax is locked:
--   SendOSC "Resolume" '/composition/layers/1/clips/1/connect,i,1'
--   SendOSC "Resolume" '/composition/layers/1/clips/1/connect,i,0'
--
-- Confirmed Piano target structure:
--   Cue 1 Name: ON
--   Cue 1 Command:  SendOSC "Resolume" '/composition/layers/1/clips/1/connect,i,1'
--   OffCue Command: SendOSC "Resolume" '/composition/layers/1/clips/1/connect,i,0'
--
-- Console validation still required for the exact executor key property name.

local VERSION = "v0.5-no-dialog"

local CONFIG = {
    mode = "Piano", -- "Piano" or "Trigger"
    osc_output = "Resolume",
    group_no = 0, -- 0 means no group label in the sequence name
    layers = "1",
    clips = "1",
    sequence_start = 2000,
    executor_page = 7,
    executor_start = 402,
    executor_row_width = 8,
    prefix = "", -- blank uses PIANO or TRIGGER
    overwrite = false,
}

local MA3_SEQUENCE_SYNTAX = {
    cue_command_property = "Command",
    offcue_address = 'Cue "OffCue"',
    executor_key_property = "Key",
}

local function trim(s)
    return (s or ""):match("^%s*(.-)%s*$")
end

local function esc_dq(s)
    s = s or ""
    s = s:gsub("\\", "\\\\")
    s = s:gsub('"', '\\"')
    return s
end

local function esc_property_value(s)
    s = s or ""
    s = s:gsub('"', '""')
    return s
end

local function sanitize_label(s)
    s = trim(s or "")
    s = s:gsub("%s+", " ")
    s = s:gsub('"', "")
    return s
end

local function to_int(s, default)
    s = trim(s)
    if s == "" then return default end
    local n = tonumber(s)
    if not n then return default end
    return math.floor(n)
end

local function to_bool01(s, default)
    return to_int(s, default and 1 or 0) ~= 0
end

local function input_value(inputs, name)
    if inputs[name] ~= nil then return inputs[name] end
    for k, v in pairs(inputs or {}) do
        if type(k) == "string" and k:sub(-#name) == name then
            return v
        end
    end
    return nil
end

local function cmd(fmt, ...)
    Cmd(string.format(fmt, ...))
end

local function echo(message)
    cmd('Echo "%s"', esc_dq(message or ""))
end

local function msgbox(opts)
    local ret = MessageBox(opts)
    if not ret or not ret.success then return nil end
    return ret
end

local function info(title, message)
    msgbox({
        title = title,
        message = message,
        commands = { { value = 1, name = "OK" } },
        backColor = "Window.Plugins",
        icon = "object_smart",
    })
end

local function confirm(title, lines)
    local ret = msgbox({
        title = title,
        message = table.concat(lines, "\n"),
        commands = {
            { value = 3, name = "Build" },
            { value = 1, name = "Cancel" },
        },
        backColor = "Window.Plugins",
        icon = "object_smart",
    })
    if not ret or ret.result ~= 3 then return false end
    return true
end

local function split_csv(s)
    local out = {}
    s = trim(s or "")
    if s == "" then return out end
    for part in string.gmatch(s, "([^,]+)") do
        local t = trim(part)
        if t ~= "" then out[#out + 1] = t end
    end
    return out
end

local function parse_number_list(input)
    local s = trim(input or "")
    if s == "" then return nil, "Value is empty." end
    s = s:gsub("THRU", "-"):gsub("thru", "-")

    local nums, seen = {}, {}
    local function add(n)
        n = math.floor(n)
        if n <= 0 then return end
        if not seen[n] then
            seen[n] = true
            nums[#nums + 1] = n
        end
    end

    local parts = split_csv(s)
    if #parts == 0 then parts = { s } end

    for _, p in ipairs(parts) do
        local a, b = p:match("^%s*(%d+)%s*%-%s*(%d+)%s*$")
        if a and b then
            local x, y = tonumber(a), tonumber(b)
            if x > y then x, y = y, x end
            for n = x, y do add(n) end
        else
            local single = p:match("^%s*(%d+)%s*$")
            if not single then return nil, "Could not parse token: " .. p end
            add(tonumber(single))
        end
    end

    table.sort(nums)
    if #nums == 0 then return nil, "No positive numbers found." end
    return nums, nil
end

local function safe_get(obj, key)
    if not obj then return "" end
    local ok, v = pcall(function()
        if obj[key] ~= nil then return obj[key] end
        return obj:Get(key)
    end)
    if ok and v ~= nil then return tostring(v) end
    return ""
end

local function safe_get_any(obj, keys)
    for _, k in ipairs(keys) do
        local v = safe_get(obj, k)
        if v ~= "" then return v end
    end
    return ""
end

local function first_object(path)
    local objs = ObjectList(path)
    if type(objs) == "table" then
        for _, o in ipairs(objs) do
            if IsObjectValid(o) then return o end
        end
        return nil
    end
    if IsObjectValid(objs) then return objs end
    return nil
end

local function object_exists(path)
    return first_object(path) ~= nil
end

local function sequence_exists(no)
    if not no or no <= 0 then return false end
    return object_exists("Sequence " .. tostring(no))
end

local function executor_exists(page_no, executor_no)
    if not page_no or page_no <= 0 or not executor_no or executor_no <= 0 then return false end
    return object_exists(string.format("Page %d.%d", page_no, executor_no))
        or object_exists(string.format("Executor %d.%d", page_no, executor_no))
        or object_exists(string.format("Executor %d", executor_no))
end

local function find_sequence_no_by_label(label)
    label = trim(label or "")
    if label == "" then return nil end

    local h = first_object('Sequence "' .. label .. '"')
    if h then
        local n = tonumber(safe_get_any(h, { "no", "No" })) or 0
        if n > 0 then return n end
    end

    local objs = ObjectList("Sequence *")
    if type(objs) == "table" then
        local target = label:lower()
        for _, s in ipairs(objs) do
            if IsObjectValid(s) then
                local n = tonumber(safe_get_any(s, { "no", "No" })) or 0
                local nm = safe_get_any(s, { "Name", "name", "Label", "label" })
                if n > 0 and nm ~= "" and nm:lower() == target then return n end
            end
        end
    end
    return nil
end

local function sendosc_cmd(output_name, layer, clip, value)
    return string.format(
        'SendOSC "%s" \'/composition/layers/%d/clips/%d/connect,i,%d\'',
        esc_dq(output_name),
        layer,
        clip,
        value
    )
end

local function mode_key_function(mode)
    if mode == "Piano" then return "Temp" end
    return "Toggle"
end

local function sequence_name(cfg, layer, clip)
    if cfg.group_no and cfg.group_no > 0 then
        return string.format("%s G%d L%d C%d", cfg.prefix, cfg.group_no, layer, clip)
    end
    return string.format("%s L%d C%d", cfg.prefix, layer, clip)
end

local function executor_for_index(cfg, index)
    local row = math.floor((index - 1) / cfg.executor_row_width)
    local col = (index - 1) % cfg.executor_row_width
    return cfg.executor_start + (row * 100) + col
end

local function build_defs(cfg)
    local defs = {}
    local index = 0
    for _, layer in ipairs(cfg.layers) do
        for _, clip in ipairs(cfg.clips) do
            index = index + 1
            defs[#defs + 1] = {
                layer = layer,
                clip = clip,
                sequence_no = cfg.sequence_start + index - 1,
                executor_no = executor_for_index(cfg, index),
                name = sequence_name(cfg, layer, clip),
                on_command = sendosc_cmd(cfg.osc_output, layer, clip, 1),
                off_command = sendosc_cmd(cfg.osc_output, layer, clip, 0),
                key_function = mode_key_function(cfg.mode),
            }
        end
    end
    return defs
end

local function detect_conflicts(defs, cfg)
    local conflicts = {}
    local seen_seq, seen_exec = {}, {}

    for _, def in ipairs(defs) do
        if seen_seq[def.sequence_no] then
            conflicts[#conflicts + 1] = string.format("Duplicate generated Sequence %d", def.sequence_no)
        end
        seen_seq[def.sequence_no] = true

        local exec_key = string.format("%d.%d", cfg.executor_page, def.executor_no)
        if seen_exec[exec_key] then
            conflicts[#conflicts + 1] = "Duplicate generated Executor " .. exec_key
        end
        seen_exec[exec_key] = true

        local existing_label_no = find_sequence_no_by_label(def.name)
        if existing_label_no and existing_label_no ~= def.sequence_no then
            conflicts[#conflicts + 1] = string.format("Existing sequence label '%s' at Sequence %d", def.name, existing_label_no)
        end

        if sequence_exists(def.sequence_no) then
            conflicts[#conflicts + 1] = string.format("Sequence %d already exists", def.sequence_no)
        end

        if executor_exists(cfg.executor_page, def.executor_no) then
            conflicts[#conflicts + 1] = string.format("Executor %d.%d may already be assigned", cfg.executor_page, def.executor_no)
        end
    end

    return conflicts
end

local function store_sequence(def, cfg)
    if cfg.overwrite and sequence_exists(def.sequence_no) then
        cmd("Delete Sequence %d /NoConfirmation", def.sequence_no)
    end

    cmd("Store Sequence %d", def.sequence_no)
    cmd('Label Sequence %d "%s"', def.sequence_no, esc_dq(def.name))
    cmd("Store Sequence %d Cue 1", def.sequence_no)
    cmd('Label Sequence %d Cue 1 "ON"', def.sequence_no)
    cmd(
        'Set Sequence %d Cue 1 Property "%s" "%s"',
        def.sequence_no,
        MA3_SEQUENCE_SYNTAX.cue_command_property,
        esc_property_value(def.on_command)
    )
    cmd(
        'Set Sequence %d %s Property "%s" "%s"',
        def.sequence_no,
        MA3_SEQUENCE_SYNTAX.offcue_address,
        MA3_SEQUENCE_SYNTAX.cue_command_property,
        esc_property_value(def.off_command)
    )
end

local function assign_executor(def, cfg)
    if cfg.overwrite and executor_exists(cfg.executor_page, def.executor_no) then
        cmd("Delete Page %d.%d /NoConfirmation", cfg.executor_page, def.executor_no)
    end

    cmd("Assign Sequence %d At Page %d.%d", def.sequence_no, cfg.executor_page, def.executor_no)
    cmd(
        'Set Page %d.%d Property "%s" "%s"',
        cfg.executor_page,
        def.executor_no,
        MA3_SEQUENCE_SYNTAX.executor_key_property,
        def.key_function
    )
end

local function build(cfg)
    local defs = build_defs(cfg)
    if #defs == 0 then
        info("Nothing to build", "No layer/clip targets were generated.")
        return
    end

    local conflicts = detect_conflicts(defs, cfg)
    if #conflicts > 0 and not cfg.overwrite then
        local lines = { "Overwrite is off. Resolve these conflicts or enable overwrite:" }
        for i = 1, math.min(18, #conflicts) do
            lines[#lines + 1] = "- " .. conflicts[i]
        end
        if #conflicts > 18 then
            lines[#lines + 1] = string.format("...and %d more", #conflicts - 18)
        end
        echo("Resolume Piano/Trigger Builder: cannot build. Conflicts found.")
        for _, line in ipairs(lines) do echo(line) end
        return
    end

    local preview = {
        "Mode: " .. cfg.mode,
        "Executor key function: " .. mode_key_function(cfg.mode),
        "OSC output: " .. cfg.osc_output,
        "Prefix: " .. cfg.prefix,
        "Group: " .. (cfg.group_no > 0 and tostring(cfg.group_no) or "(none)"),
        "Layers: " .. cfg.layers_text,
        "Clips: " .. cfg.clips_text,
        string.format("Sequences: %d-%d", defs[1].sequence_no, defs[#defs].sequence_no),
        string.format("Executors: Page %d, first %d, row width %d", cfg.executor_page, cfg.executor_start, cfg.executor_row_width),
        string.format("Count: %d sequences and executor assignments", #defs),
        "Overwrite: " .. tostring(cfg.overwrite),
        "",
        "First generated sequence:",
        defs[1].name,
        "Cue 1: " .. defs[1].on_command,
        "OffCue: " .. defs[1].off_command,
    }

    if #conflicts > 0 then
        preview[#preview + 1] = ""
        preview[#preview + 1] = "Conflicts to overwrite:"
        for i = 1, math.min(10, #conflicts) do
            preview[#preview + 1] = "- " .. conflicts[i]
        end
        if #conflicts > 10 then
            preview[#preview + 1] = string.format("...and %d more", #conflicts - 10)
        end
    end

    echo("Resolume Piano/Trigger Sequence Builder " .. VERSION)
    for _, line in ipairs(preview) do echo(line) end

    local created, assigned, failures = 0, 0, {}
    for _, def in ipairs(defs) do
        local ok, err = pcall(function()
            store_sequence(def, cfg)
            created = created + 1
            assign_executor(def, cfg)
            assigned = assigned + 1
        end)
        if not ok then
            failures[#failures + 1] = string.format("%s: %s", def.name, tostring(err))
        end
    end

    local summary = {
        string.format("Mode: %s", cfg.mode),
        string.format("Created sequences: %d", created),
        string.format("Assigned executors: %d", assigned),
        string.format("Failures: %d", #failures),
    }
    for i = 1, math.min(8, #failures) do
        summary[#summary + 1] = failures[i]
    end
    for _, line in ipairs(summary) do echo(line) end
end

local function read_config_from_constants()
    local cfg = {}

    cfg.mode = CONFIG.mode == "Trigger" and "Trigger" or "Piano"
    cfg.osc_output = sanitize_label(CONFIG.osc_output)
    if cfg.osc_output == "" then cfg.osc_output = "Resolume" end

    cfg.group_no = to_int(CONFIG.group_no, 0)
    if cfg.group_no < 0 then cfg.group_no = 0 end

    cfg.layers_text = trim(CONFIG.layers)
    cfg.clips_text = trim(CONFIG.clips)

    local layers, layer_err = parse_number_list(cfg.layers_text)
    if layer_err then return nil, "Layers error: " .. layer_err end
    cfg.layers = layers

    local clips, clip_err = parse_number_list(cfg.clips_text)
    if clip_err then return nil, "Clips error: " .. clip_err end
    cfg.clips = clips

    cfg.sequence_start = to_int(CONFIG.sequence_start, 1)
    if cfg.sequence_start <= 0 then return nil, "Sequence Start must be 1 or higher." end

    cfg.executor_page = to_int(CONFIG.executor_page, 1)
    if cfg.executor_page <= 0 then return nil, "Executor Page must be 1 or higher." end

    cfg.executor_start = to_int(CONFIG.executor_start, 201)
    if cfg.executor_start <= 0 then return nil, "Executor Start must be 1 or higher." end

    cfg.executor_row_width = to_int(CONFIG.executor_row_width, 8)
    if cfg.executor_row_width <= 0 then return nil, "Executor Row Width must be 1 or higher." end

    cfg.prefix = sanitize_label(CONFIG.prefix)
    if cfg.prefix == "" then cfg.prefix = cfg.mode == "Piano" and "PIANO" or "TRIGGER" end

    cfg.overwrite = CONFIG.overwrite == true

    return cfg, nil
end

local function read_config(ret)
    local inputs = ret.inputs
    local cfg = {}

    local mode_value = to_int(input_value(inputs, "Mode (1=Piano 2=Trigger)"), 1)
    cfg.mode = mode_value == 2 and "Trigger" or "Piano"

    cfg.osc_output = sanitize_label(input_value(inputs, "OSC Output Name"))
    if cfg.osc_output == "" then cfg.osc_output = "Resolume" end

    cfg.group_no = to_int(input_value(inputs, "Group Number (0=none)"), 0)
    if cfg.group_no < 0 then return nil, "Group Number cannot be negative." end

    cfg.layers_text = trim(input_value(inputs, "Layers"))
    cfg.clips_text = trim(input_value(inputs, "Clips"))

    local layers, layer_err = parse_number_list(cfg.layers_text)
    if layer_err then return nil, "Layers error: " .. layer_err end
    cfg.layers = layers

    local clips, clip_err = parse_number_list(cfg.clips_text)
    if clip_err then return nil, "Clips error: " .. clip_err end
    cfg.clips = clips

    cfg.sequence_start = to_int(input_value(inputs, "Sequence Start"), 1)
    if cfg.sequence_start <= 0 then return nil, "Sequence Start must be 1 or higher." end

    cfg.executor_page = to_int(input_value(inputs, "Executor Page"), 1)
    if cfg.executor_page <= 0 then return nil, "Executor Page must be 1 or higher." end

    cfg.executor_start = to_int(input_value(inputs, "Executor Start"), 201)
    if cfg.executor_start <= 0 then return nil, "Executor Start must be 1 or higher." end

    cfg.executor_row_width = to_int(input_value(inputs, "Executor Row Width"), 8)
    if cfg.executor_row_width <= 0 then return nil, "Executor Row Width must be 1 or higher." end

    cfg.prefix = sanitize_label(input_value(inputs, "Name Prefix"))
    if cfg.prefix == "" then cfg.prefix = cfg.mode == "Piano" and "PIANO" or "TRIGGER" end

    cfg.overwrite = to_bool01(input_value(inputs, "Overwrite Existing (0/1)"), false)

    return cfg, nil
end

local function main(display_handle, argument)
    local cfg, err = read_config_from_constants()
    if err then
        echo("Resolume Piano/Trigger Builder config error: " .. err)
        return
    end
    build(cfg)
end

local function dialog_main(display_handle, argument)
    local ret = msgbox({
        title = "Resolume Piano/Trigger Sequence Builder " .. VERSION,
        message = "Build one sequence per Resolume clip. Piano uses Temp; Trigger uses Toggle.",
        commands = {
            { value = 2, name = "Next" },
            { value = 1, name = "Cancel" },
        },
        inputs = {
            { name = "01 Mode (1=Piano 2=Trigger)", value = "1", vkPlugin = "TextInput", maxTextLength = 1 },
            { name = "02 OSC Output Name", value = "Resolume", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "03 Group Number (0=none)", value = "0", vkPlugin = "TextInput", maxTextLength = 8 },
            { name = "04 Layers", value = "1-5", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "05 Clips", value = "1-8", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "06 Sequence Start", value = "1", vkPlugin = "TextInput", maxTextLength = 8 },
            { name = "07 Executor Page", value = "1", vkPlugin = "TextInput", maxTextLength = 8 },
            { name = "08 Executor Start", value = "201", vkPlugin = "TextInput", maxTextLength = 8 },
            { name = "09 Executor Row Width", value = "8", vkPlugin = "TextInput", maxTextLength = 8 },
            { name = "10 Name Prefix", value = "", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "11 Overwrite Existing (0/1)", value = "0", vkPlugin = "TextInput", maxTextLength = 1 },
        },
        backColor = "Window.Plugins",
        icon = "object_smart",
    })

    if not ret or ret.result ~= 2 then return end

    local cfg, err = read_config(ret)
    if err then
        info("Config error", err)
        return
    end

    build(cfg)
end

return main
