-- Resolume OSC Layout Builder
-- First production pass for one unified grandMA3 Lua plugin.
--
-- Runtime macro commands are generated from docs/SYNTAX_RULES.md:
--   SendOSC "Resolume" '/composition/layers/1/clips/1/connect,i,1'
--   Go+ #[Macro 'Content Layer 1 Appearance reset']
--   Assign #[Appearance 'CLIP ON'] At #[Macro 'Content Layer 1 Clip 1']

local VERSION = "v0.3"

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 esc_sq(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 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 = 2, name = "Back" },
            { value = 1, name = "Cancel" },
        },
        backColor = "Window.Plugins",
        icon = "object_smart",
    })
    if not ret then return "cancel" end
    if ret.result == 3 then return "build" end
    if ret.result == 2 then return "back" end
    return "cancel"
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 parse_positive_count(input, field_name)
    local n = to_int(input, 0)
    if n <= 0 then
        return nil, field_name .. " must be a positive number."
    end
    return n, nil
end

local function list_from_count(count)
    local out = {}
    for i = 1, count do out[#out + 1] = i end
    return out
end

local function range_text_from_count(count)
    if count <= 1 then return "1" end
    return "1-" .. tostring(count)
end

local function contains_number(list, n)
    for _, v in ipairs(list or {}) do
        if v == n then return true end
    end
    return false
end

local function parse_group_mapping(input, groups, layers)
    local raw = trim(input or "")
    if raw == "" then
        return nil, "Group-to-layer mapping is required, for example: 1=1-5,2=6-12"
    end

    local map, used_layer = {}, {}
    for _, part in ipairs(split_csv(raw)) do
        local g_text, layer_text = part:match("^%s*(%d+)%s*=%s*(.-)%s*$")
        if not g_text or trim(layer_text) == "" then
            return nil, "Mapping entries must look like: 1=1-5"
        end

        local g = tonumber(g_text)
        if not contains_number(groups, g) then
            return nil, "Mapping references group " .. tostring(g) .. ", but that group is not enabled."
        end

        local parsed_layers, err = parse_number_list(layer_text)
        if err then return nil, "Group " .. tostring(g) .. " mapping error: " .. err end

        map[g] = {}
        for _, layer in ipairs(parsed_layers) do
            if not contains_number(layers, layer) then
                return nil, "Mapping references layer " .. tostring(layer) .. ", but that layer is not enabled."
            end
            if used_layer[layer] and used_layer[layer] ~= g then
                return nil, string.format("Layer %d is mapped to both group %d and group %d.", layer, used_layer[layer], g)
            end
            used_layer[layer] = g
            map[g][#map[g] + 1] = layer
        end
    end

    for _, g in ipairs(groups) do
        if not map[g] or #map[g] == 0 then
            return nil, "Group " .. tostring(g) .. " has no mapped layers."
        end
        table.sort(map[g])
    end

    return map, 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 macro_exists(no)
    if not no or no <= 0 then return false end
    return first_object("Macro " .. tostring(no)) ~= nil
end

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

    local h = first_object('Macro "' .. 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("Macro *")
    if type(objs) == "table" then
        local target = label:lower()
        for _, m in ipairs(objs) do
            if IsObjectValid(m) then
                local n = tonumber(safe_get_any(m, { "no", "No" })) or 0
                local nm = safe_get_any(m, { "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 find_appearance_no_by_label(label)
    label = trim(label or "")
    if label == "" then return nil end

    local h = first_object('Appearance "' .. 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("Appearance *")
    if type(objs) == "table" then
        local target = label:lower()
        for _, a in ipairs(objs) do
            if IsObjectValid(a) then
                local n = tonumber(safe_get_any(a, { "no", "No" })) or 0
                local nm = safe_get_any(a, { "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 list_used_appearance_numbers()
    local used = {}
    local objs = ObjectList("Appearance *")
    if type(objs) == "table" then
        for _, h in ipairs(objs) do
            local n = tonumber(safe_get_any(h, { "no", "No" })) or 0
            if n > 0 then used[n] = true end
        end
    end
    return used
end

local function first_free_appearance(used)
    local n = 1
    while used[n] do n = n + 1 end
    used[n] = true
    return n
end

local function set_appearance_colors(app_no, fg_rgba01, bg)
    cmd('Set Appearance %d Property "Color" "%s"', app_no, esc_dq(fg_rgba01))
    cmd('Set Appearance %d Property "BackR" "%d"', app_no, bg.r)
    cmd('Set Appearance %d Property "BackG" "%d"', app_no, bg.g)
    cmd('Set Appearance %d Property "BackB" "%d"', app_no, bg.b)
    cmd('Set Appearance %d Property "BackAlpha" "%d"', app_no, bg.a)
end

local function create_default_appearance(app_no, label, fg_rgba01, bg)
    cmd("Store Appearance %d", app_no)
    set_appearance_colors(app_no, fg_rgba01, bg)
    cmd('Label Appearance %d "%s"', app_no, esc_dq(label))
end

local function ensure_appearance(label, used, fg_rgba01, bg)
    label = sanitize_label(label)
    if label == "" then return end
    if find_appearance_no_by_label(label) then return end
    local app_no = first_free_appearance(used)
    create_default_appearance(app_no, label, fg_rgba01, bg)
end

local function ensure_default_appearances(cfg)
    local used = list_used_appearance_numbers()
    local specs = {
        { cfg.clip_on, "0,1,0,1", { r = 0, g = 90, b = 0, a = 255 } },
        { cfg.clip_off, "0.75,0.75,0.75,1", { r = 12, g = 12, b = 12, a = 255 } },
        { cfg.column_on, "0,0.85,1,1", { r = 0, g = 75, b = 100, a = 255 } },
        { cfg.column_off, "0.55,0.75,0.8,1", { r = 10, g = 24, b = 28, a = 255 } },
        { cfg.layer_select_on, "1,1,1,1", { r = 210, g = 210, b = 210, a = 255 } },
        { cfg.layer_select_off, "0.7,0.7,0.7,1", { r = 38, g = 38, b = 38, a = 255 } },
        { cfg.group_select_on, "1,0.15,0.15,1", { r = 170, g = 0, b = 0, a = 255 } },
        { cfg.group_select_off, "0.75,0.15,0.15,1", { r = 15, g = 15, b = 15, a = 255 } },
        { cfg.layer_disconnect_app, "1,1,1,1", { r = 210, g = 0, b = 0, a = 255 } },
        { cfg.group_disconnect_app, "1,1,1,1", { r = 170, g = 30, b = 0, a = 255 } },
        { cfg.composition_disconnect_app, "1,1,1,1", { r = 255, g = 0, b = 0, a = 255 } },
        { cfg.initiate_app, "0,1,1,1", { r = 0, g = 80, b = 90, a = 255 } },
    }

    local seen = {}
    for _, spec in ipairs(specs) do
        local label = sanitize_label(spec[1])
        local key = label:lower()
        if label ~= "" and not seen[key] then
            ensure_appearance(label, used, spec[2], spec[3])
            seen[key] = true
        end
    end
end

local function list_used_macro_numbers()
    local used = {}
    local objs = ObjectList("Macro *")
    if type(objs) == "table" then
        for _, h in ipairs(objs) do
            local n = tonumber(safe_get_any(h, { "no", "No" })) or 0
            if n > 0 then used[n] = true end
        end
    end
    return used
end

local function first_free_macro(used, start_at)
    local n = math.max(1, start_at or 1)
    while used[n] do n = n + 1 end
    used[n] = true
    return n
end

local function macro_ref(name)
    return string.format("#[Macro '%s']", esc_sq(name))
end

local function appearance_ref(name)
    return string.format("#[Appearance '%s']", esc_sq(name))
end

local function sendosc_cmd(output_name, path, value)
    return string.format('SendOSC "%s" \'%s,i,%d\'', esc_dq(output_name), path, value)
end

local function assign_appearance_cmd(appearance_name, macro_name)
    return string.format("Assign %s At %s", appearance_ref(appearance_name), macro_ref(macro_name))
end

local function go_macro_cmd(macro_name)
    return "Go+ " .. macro_ref(macro_name)
end

local function prefix_text(prefix)
    prefix = sanitize_label(prefix)
    if prefix == "" then return "Content" end
    return prefix
end

local function default_group_label(group_no)
    local defaults = {
        [1] = "Under FX",
        [2] = "Content",
        [3] = "Camera",
        [4] = "FX Color",
    }
    return defaults[group_no] or string.format("Group %d", group_no)
end

local function group_label(cfg, group_no)
    local label = cfg.group_labels and cfg.group_labels[group_no] or nil
    label = sanitize_label(label or "")
    if label ~= "" then return label end
    return string.format("Group %d", group_no)
end

local function appearance_input(inputs, name, default)
    local value = sanitize_label(input_value(inputs, name))
    if value == "" then return default end
    return value
end

local function clip_reset_name(cfg, layer)
    return string.format("%s Layer %d Appearance reset", cfg.prefix, layer)
end

local function clip_macro_name(cfg, layer, clip)
    return string.format("%s Layer %d Clip %d", cfg.prefix, layer, clip)
end

local function layer_select_reset_name(cfg)
    return string.format("%s Layer Selection Appearance reset", cfg.prefix)
end

local function layer_select_macro_name(cfg, layer)
    return string.format("%s Layer Select %d", cfg.prefix, layer)
end

local function group_select_reset_name(cfg)
    return string.format("%s Group Selection Appearance reset", cfg.prefix)
end

local function group_select_macro_name(cfg, group_no)
    return string.format("%s %s Group Select", cfg.prefix, group_label(cfg, group_no))
end

local function column_reset_name(cfg, group_no)
    return string.format("%s %s Column Appearance reset", cfg.prefix, group_label(cfg, group_no))
end

local function column_macro_name(cfg, group_no, column)
    return string.format("%s %s Column %d", cfg.prefix, group_label(cfg, group_no), column)
end

local function layer_disconnect_name(cfg, layer)
    return string.format("%s Layer %d Disconnect", cfg.prefix, layer)
end

local function group_disconnect_name(cfg, group_no)
    return string.format("%s %s Group Disconnect", cfg.prefix, group_label(cfg, group_no))
end

local function composition_disconnect_name(cfg)
    return string.format("%s Composition Disconnect", cfg.prefix)
end

local function all_reset_name(cfg)
    return string.format("%s All Appearance reset", cfg.prefix)
end

local function initiate_name(cfg)
    return string.format("%s INITIATE", cfg.prefix)
end

local function add_macro(defs, name, lines, initial_appearance)
    defs[#defs + 1] = {
        name = name,
        lines = lines,
        initial_appearance = initial_appearance,
    }
end

local function pack_cmds(cmds, max_len)
    max_len = max_len or 3500
    local out = {}
    local cur = ""

    for _, c in ipairs(cmds or {}) do
        c = trim(c or "")
        if c ~= "" then
            if cur == "" then
                cur = c
            else
                local candidate = cur .. " ; " .. c
                if #candidate > max_len then
                    out[#out + 1] = cur
                    cur = c
                else
                    cur = candidate
                end
            end
        end
    end

    if cur ~= "" then out[#out + 1] = cur end
    if #out == 0 then out[1] = "" end
    return out
end

local function build_macro_defs(cfg)
    local defs = {}

    add_macro(defs, initiate_name(cfg), {
        go_macro_cmd(all_reset_name(cfg)),
    }, cfg.initiate_app)

    for _, layer in ipairs(cfg.layers) do
        local lines = {}
        for _, clip in ipairs(cfg.clips) do
            lines[#lines + 1] = assign_appearance_cmd(cfg.clip_off, clip_macro_name(cfg, layer, clip))
        end
        add_macro(defs, clip_reset_name(cfg, layer), pack_cmds(lines), nil)
    end

    for _, layer in ipairs(cfg.layers) do
        for _, clip in ipairs(cfg.clips) do
            local lines = {
                go_macro_cmd(clip_reset_name(cfg, layer)),
                sendosc_cmd(cfg.osc_output, string.format("/composition/layers/%d/clips/%d/connect", layer, clip), 1),
            }
            if cfg.clip_release then
                lines[#lines + 1] = sendosc_cmd(cfg.osc_output, string.format("/composition/layers/%d/clips/%d/connect", layer, clip), 0)
            end
            lines[#lines + 1] = assign_appearance_cmd(cfg.clip_on, clip_macro_name(cfg, layer, clip))
            add_macro(defs, clip_macro_name(cfg, layer, clip), lines, cfg.clip_off)
        end
    end

    local layer_select_reset_lines = {}
    for _, layer in ipairs(cfg.layers) do
        layer_select_reset_lines[#layer_select_reset_lines + 1] = assign_appearance_cmd(cfg.layer_select_off, layer_select_macro_name(cfg, layer))
    end
    add_macro(defs, layer_select_reset_name(cfg), pack_cmds(layer_select_reset_lines), nil)

    for _, layer in ipairs(cfg.layers) do
        add_macro(defs, layer_select_macro_name(cfg, layer), {
            go_macro_cmd(layer_select_reset_name(cfg)),
            sendosc_cmd(cfg.osc_output, string.format("/composition/layers/%d/select", layer), 1),
            assign_appearance_cmd(cfg.layer_select_on, layer_select_macro_name(cfg, layer)),
        }, cfg.layer_select_off)
    end

    for _, group_no in ipairs(cfg.groups) do
        local column_reset_lines = {}
        for _, column in ipairs(cfg.columns) do
            column_reset_lines[#column_reset_lines + 1] = assign_appearance_cmd(cfg.column_off, column_macro_name(cfg, group_no, column))
        end
        add_macro(defs, column_reset_name(cfg, group_no), pack_cmds(column_reset_lines), nil)

        for _, column in ipairs(cfg.columns) do
            local mapped_layers = cfg.group_layers[group_no]
            local lines = {}
            local reset_cmds = {}
            for i = #mapped_layers, 1, -1 do
                reset_cmds[#reset_cmds + 1] = go_macro_cmd(clip_reset_name(cfg, mapped_layers[i]))
            end
            reset_cmds[#reset_cmds + 1] = go_macro_cmd(column_reset_name(cfg, group_no))
            for _, packed in ipairs(pack_cmds(reset_cmds)) do
                lines[#lines + 1] = packed
            end
            lines[#lines + 1] = sendosc_cmd(cfg.osc_output, string.format("/composition/groups/%d/columns/%d/connect", group_no, column), 1)
            local assign_cmds = {}
            for _, layer in ipairs(mapped_layers) do
                assign_cmds[#assign_cmds + 1] = assign_appearance_cmd(cfg.clip_on, clip_macro_name(cfg, layer, column))
            end
            for _, packed in ipairs(pack_cmds(assign_cmds)) do
                lines[#lines + 1] = packed
            end
            lines[#lines + 1] = assign_appearance_cmd(cfg.column_on, column_macro_name(cfg, group_no, column))
            add_macro(defs, column_macro_name(cfg, group_no, column), lines, cfg.column_off)
        end
    end

    local group_select_reset_lines = {}
    for _, group_no in ipairs(cfg.groups) do
        group_select_reset_lines[#group_select_reset_lines + 1] = assign_appearance_cmd(cfg.group_select_off, group_select_macro_name(cfg, group_no))
    end
    add_macro(defs, group_select_reset_name(cfg), pack_cmds(group_select_reset_lines), nil)

    for _, group_no in ipairs(cfg.groups) do
        add_macro(defs, group_select_macro_name(cfg, group_no), {
            go_macro_cmd(group_select_reset_name(cfg)),
            sendosc_cmd(cfg.osc_output, string.format("/composition/groups/%d/select", group_no), 1),
            assign_appearance_cmd(cfg.group_select_on, group_select_macro_name(cfg, group_no)),
        }, cfg.group_select_off)
    end

    for _, layer in ipairs(cfg.layers) do
        add_macro(defs, layer_disconnect_name(cfg, layer), {
            sendosc_cmd(cfg.osc_output, string.format("/composition/layers/%d/clear", layer), 1),
            sendosc_cmd(cfg.osc_output, string.format("/composition/layers/%d/clear", layer), 0),
            go_macro_cmd(clip_reset_name(cfg, layer)),
        }, cfg.layer_disconnect_app)
    end

    for _, group_no in ipairs(cfg.groups) do
        local lines = {
            sendosc_cmd(cfg.osc_output, string.format("/composition/groups/%d/disconnectlayers", group_no), 1),
            sendosc_cmd(cfg.osc_output, string.format("/composition/groups/%d/disconnectlayers", group_no), 0),
        }
        for _, layer in ipairs(cfg.group_layers[group_no]) do
            lines[#lines + 1] = go_macro_cmd(clip_reset_name(cfg, layer))
        end
        lines[#lines + 1] = go_macro_cmd(column_reset_name(cfg, group_no))
        add_macro(defs, group_disconnect_name(cfg, group_no), lines, cfg.group_disconnect_app)
    end

    local all_reset_lines = {}
    for _, layer in ipairs(cfg.layers) do
        all_reset_lines[#all_reset_lines + 1] = go_macro_cmd(clip_reset_name(cfg, layer))
    end
    for _, layer in ipairs(cfg.layers) do
        all_reset_lines[#all_reset_lines + 1] = assign_appearance_cmd(cfg.layer_disconnect_app, layer_disconnect_name(cfg, layer))
    end
    all_reset_lines[#all_reset_lines + 1] = go_macro_cmd(layer_select_reset_name(cfg))
    for _, group_no in ipairs(cfg.groups) do
        all_reset_lines[#all_reset_lines + 1] = go_macro_cmd(column_reset_name(cfg, group_no))
    end
    for _, group_no in ipairs(cfg.groups) do
        all_reset_lines[#all_reset_lines + 1] = assign_appearance_cmd(cfg.group_disconnect_app, group_disconnect_name(cfg, group_no))
    end
    all_reset_lines[#all_reset_lines + 1] = assign_appearance_cmd(cfg.composition_disconnect_app, composition_disconnect_name(cfg))
    all_reset_lines[#all_reset_lines + 1] = assign_appearance_cmd(cfg.initiate_app, initiate_name(cfg))
    all_reset_lines[#all_reset_lines + 1] = go_macro_cmd(group_select_reset_name(cfg))
    add_macro(defs, all_reset_name(cfg), pack_cmds(all_reset_lines), nil)

    add_macro(defs, composition_disconnect_name(cfg), {
        sendosc_cmd(cfg.osc_output, "/composition/disconnectall", 1),
        sendosc_cmd(cfg.osc_output, "/composition/disconnectall", 0),
        go_macro_cmd(all_reset_name(cfg)),
    }, cfg.composition_disconnect_app)

    return defs
end

local function macro_number_map(defs)
    local map = {}
    for _, def in ipairs(defs or {}) do
        map[def.name] = def.no
    end
    return map
end

local function layout_add(elements, macro_name, x, y, w, h)
    elements[#elements + 1] = {
        macro_name = macro_name,
        x = math.floor(x),
        y = math.floor(y),
        w = math.floor(w),
        h = math.floor(h),
    }
end

local function desc_copy(list)
    local out = {}
    for _, v in ipairs(list or {}) do out[#out + 1] = v end
    table.sort(out, function(a, b) return a > b end)
    return out
end

local function build_layout_elements(cfg)
    local elements = {}
    local clip_x = -3538
    local select_x = -3792
    local clear_x = -3980
    local top_y = 1428
    local x_step = cfg.layout_x_step or 190
    local y_step = -(cfg.layout_y_step or 157)
    local section_gap = -(cfg.layout_group_gap or 140)
    local button_w = 135
    local button_h = 125
    local column_h = 105

    local y = top_y
    layout_add(elements, composition_disconnect_name(cfg), clear_x, y + 190, 220, 130)
    layout_add(elements, initiate_name(cfg), select_x, y + 190, 135, 125)

    for _, layer in ipairs(desc_copy(cfg.standalone_layers)) do
        layout_add(elements, layer_disconnect_name(cfg, layer), clear_x, y, button_w, button_h)
        layout_add(elements, layer_select_macro_name(cfg, layer), select_x, y, button_w, button_h)
        for clip_index, clip in ipairs(cfg.clips) do
            layout_add(elements, clip_macro_name(cfg, layer, clip), clip_x + ((clip_index - 1) * x_step), y, button_w, button_h)
        end
        y = y + y_step
    end

    if #cfg.standalone_layers > 0 then
        y = y + section_gap
    end

    local groups_desc = desc_copy(cfg.groups)
    for _, group_no in ipairs(groups_desc) do
        layout_add(elements, group_disconnect_name(cfg, group_no), clear_x, y, button_w, button_h)
        layout_add(elements, group_select_macro_name(cfg, group_no), select_x, y, button_w, button_h)
        for column_index, column in ipairs(cfg.columns) do
            layout_add(elements, column_macro_name(cfg, group_no, column), clip_x + ((column_index - 1) * x_step), y, button_w, column_h)
        end
        y = y + y_step

        for _, layer in ipairs(desc_copy(cfg.group_layers[group_no])) do
            layout_add(elements, layer_disconnect_name(cfg, layer), clear_x, y, button_w, button_h)
            layout_add(elements, layer_select_macro_name(cfg, layer), select_x, y, button_w, button_h)
            for clip_index, clip in ipairs(cfg.clips) do
                layout_add(elements, clip_macro_name(cfg, layer, clip), clip_x + ((clip_index - 1) * x_step), y, button_w, button_h)
            end
            y = y + y_step
        end

        y = y + section_gap
    end

    return elements
end

local function set_layout_element(layout_no, element_no, element)
    local addr = string.format("Layout %d.%d", layout_no, element_no)
    cmd('Set %s Property "PosX" "%d"', addr, element.x)
    cmd('Set %s Property "PosY" "%d"', addr, element.y)
    cmd('Set %s Property "Width" "%d"', addr, element.w)
    cmd('Set %s Property "Height" "%d"', addr, element.h)
end

local function build_layout(cfg, defs)
    if not cfg.build_layout then return 0 end

    local by_name = macro_number_map(defs)
    local elements = build_layout_elements(cfg)

    for index, element in ipairs(elements) do
        local macro_no = by_name[element.macro_name]
        if macro_no then
            cmd("Assign Macro %d At Layout %d", macro_no, cfg.layout_no)
            set_layout_element(cfg.layout_no, index, element)
        end
    end

    return #elements
end

local function allocate_macros(defs, start_macro, overwrite)
    local used = list_used_macro_numbers()
    local conflicts = {}

    if start_macro and start_macro > 0 then
        local n = start_macro
        for _, def in ipairs(defs) do
            def.no = n
            if used[n] then conflicts[#conflicts + 1] = n end
            used[n] = true
            n = n + 1
        end
    else
        local next_at = 1
        for _, def in ipairs(defs) do
            local n = first_free_macro(used, next_at)
            def.no = n
            next_at = n + 1
        end
    end

    if #conflicts > 0 and not overwrite then
        return false, "Macro range conflict. First conflicts: " .. table.concat(conflicts, ", "), conflicts
    end

    local label_conflicts = {}
    for _, def in ipairs(defs) do
        local existing_no = find_macro_no_by_label(def.name)
        if existing_no and existing_no ~= def.no then
            label_conflicts[#label_conflicts + 1] = string.format("%s at Macro %d", def.name, existing_no)
        end
    end
    if #label_conflicts > 0 then
        local lines = { "Existing macro labels would make object references ambiguous:" }
        for i = 1, math.min(20, #label_conflicts) do
            lines[#lines + 1] = "- " .. label_conflicts[i]
        end
        if #label_conflicts > 20 then
            lines[#lines + 1] = string.format("...and %d more", #label_conflicts - 20)
        end
        return false, table.concat(lines, "\n"), conflicts
    end

    return true, nil, conflicts
end

local function use_existing_macros(defs)
    local missing = {}
    for _, def in ipairs(defs) do
        local existing_no = find_macro_no_by_label(def.name)
        if existing_no then
            def.no = existing_no
        else
            missing[#missing + 1] = def.name
        end
    end

    if #missing > 0 then
        local lines = { "Cannot build layout because these generated macros were not found:" }
        for i = 1, math.min(20, #missing) do
            lines[#lines + 1] = "- " .. missing[i]
        end
        if #missing > 20 then
            lines[#lines + 1] = string.format("...and %d more", #missing - 20)
        end
        return false, table.concat(lines, "\n")
    end

    return true, nil
end

local function store_macro_lines(macro_no, line_count)
    line_count = math.max(1, line_count or 1)
    for i = 1, line_count do
        cmd("Store Macro %d.%d", macro_no, i)
    end
end

local function set_macro_line(macro_no, line_no, command_text)
    cmd('Set Macro %d.%d Property "Command" "%s"', macro_no, line_no, esc_property_value(command_text or ""))
    cmd('Set Macro %d.%d Property "Wait" "Follow"', macro_no, line_no)
    cmd('Set Macro %d.%d Property "Execute" "Yes"', macro_no, line_no)
    cmd('Set Macro %d.%d Property "Enabled" "Yes"', macro_no, line_no)
    cmd('Set Macro %d.%d Property "AddToCmdLine" "No"', macro_no, line_no)
end

local assign_initial_appearance

local function prepare_macro(def, overwrite)
    if overwrite and macro_exists(def.no) then
        cmd("Delete Macro %d /NoConfirmation", def.no)
    end
    store_macro_lines(def.no, #def.lines)
    cmd('Label Macro %d "%s"', def.no, esc_dq(def.name))
    if def.initial_appearance and trim(def.initial_appearance) ~= "" then
        assign_initial_appearance(def.initial_appearance, def.name, def.no)
    end
end

local function fill_macro(def)
    for i, line in ipairs(def.lines) do
        set_macro_line(def.no, i, line)
    end
end

assign_initial_appearance = function(appearance_name, macro_name, macro_no)
    if macro_no then
        local app_no = find_appearance_no_by_label(appearance_name)
        if app_no then
            cmd("Assign Appearance %d '%s' At Macro %d '%s'", app_no, esc_sq(appearance_name), macro_no, esc_sq(macro_name))
        else
            cmd("Assign %s At Macro %d", appearance_ref(appearance_name), macro_no)
        end
    else
        Cmd(assign_appearance_cmd(appearance_name, macro_name))
    end
end

local function seed_initial_appearances(defs)
    for _, def in ipairs(defs or {}) do
        if def.initial_appearance and trim(def.initial_appearance) ~= "" and def.no then
            assign_initial_appearance(def.initial_appearance, def.name, def.no)
        end
    end
end

local function build(cfg)
    if not cfg.build_macros and not cfg.build_layout then
        info("Nothing selected", "Turn on Build Macros, Build Layout View, or both.")
        return
    end

    local defs = build_macro_defs(cfg)
    local conflicts = {}
    if cfg.build_macros then
        local ok, err
        ok, err, conflicts = allocate_macros(defs, cfg.start_macro, cfg.overwrite)
        if not ok then
            info("Cannot build", err)
            return
        end
    else
        local ok, err = use_existing_macros(defs)
        if not ok then
            info("Cannot build layout", err)
            return
        end
    end

    local group_name_parts = {}
    for _, group_no in ipairs(cfg.groups) do
        group_name_parts[#group_name_parts + 1] = string.format("%d=%s", group_no, group_label(cfg, group_no))
    end

    local preview = {
        "OSC output: " .. cfg.osc_output,
        "Prefix: " .. cfg.prefix,
        "Layers: " .. cfg.layers_text,
        "Standalone layers: " .. (cfg.standalone_text ~= "" and cfg.standalone_text or "(none)"),
        "Clips: " .. cfg.clips_text,
        "Groups: " .. cfg.groups_text,
        "Group names: " .. table.concat(group_name_parts, ", "),
        "Columns: " .. cfg.columns_text,
        "Mapping: " .. cfg.mapping_text,
        string.format("Layout spacing: X %d, row %d, group gap %d", cfg.layout_x_step, cfg.layout_y_step, cfg.layout_group_gap),
        "",
        "Build macros: " .. tostring(cfg.build_macros),
        string.format("Macros in set: %d", #defs),
        "Overwrite existing allocated macros: " .. tostring(cfg.overwrite),
        "Build layout view: " .. tostring(cfg.build_layout),
    }

    if cfg.build_macros then
        preview[#preview + 1] = string.format("Macro range: %d-%d", defs[1].no, defs[#defs].no)
    else
        preview[#preview + 1] = "Macro source: existing generated macros by name"
    end

    if cfg.build_layout then
        local layout_count = #build_layout_elements(cfg)
        preview[#preview + 1] = string.format("Layout target: Layout %d", cfg.layout_no)
        preview[#preview + 1] = string.format("Layout elements to add: %d", layout_count)
        preview[#preview + 1] = "Layout target should be empty before building."
    end

    if #conflicts > 0 then
        preview[#preview + 1] = "Allocated macro conflicts to overwrite: " .. table.concat(conflicts, ", ")
    end

    preview[#preview + 1] = ""
    preview[#preview + 1] = "First generated macro:"
    preview[#preview + 1] = defs[1].name
    preview[#preview + 1] = defs[1].lines[1] or ""
    preview[#preview + 1] = ""
    preview[#preview + 1] = "Sample clip macro:"
    for _, def in ipairs(defs) do
        if def.name == clip_macro_name(cfg, cfg.layers[1], cfg.clips[1]) then
            preview[#preview + 1] = def.name
            for i = 1, math.min(4, #def.lines) do
                preview[#preview + 1] = def.lines[i]
            end
            break
        end
    end

    local action = confirm("Confirm Resolume OSC Build", preview)
    if action == "cancel" then return end
    if action == "back" then return "back" end

    if cfg.build_macros then
        ensure_default_appearances(cfg)
        for _, def in ipairs(defs) do
            prepare_macro(def, cfg.overwrite)
        end
        for _, def in ipairs(defs) do
            fill_macro(def)
        end
    elseif cfg.build_layout then
        ensure_default_appearances(cfg)
        seed_initial_appearances(defs)
    end
    local layout_count = build_layout(cfg, defs)

    if cfg.build_macros and layout_count > 0 then
        info("Done", string.format("Built %d Resolume OSC macros and added %d layout elements.", #defs, layout_count))
    elseif cfg.build_macros then
        info("Done", string.format("Built %d Resolume OSC macros.", #defs))
    else
        info("Done", string.format("Added %d layout elements from existing Resolume OSC macros.", layout_count))
    end
end

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

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

    cfg.prefix = prefix_text(input_value(inputs, "Macro Prefix"))
    cfg.layers_text = trim(input_value(inputs, "All Layers"))
    cfg.standalone_text = trim(input_value(inputs, "Standalone Layers"))
    cfg.clips_text = trim(input_value(inputs, "Clips"))
    cfg.columns_text = trim(input_value(inputs, "Columns"))

    cfg.layers = parse_number_list(cfg.layers_text)
    if not cfg.layers then return nil, "Layers must be a list or range like 1-5." end
    if cfg.standalone_text ~= "" then
        local standalone_layers, standalone_err = parse_number_list(cfg.standalone_text)
        if standalone_err then return nil, "Standalone Layers error: " .. standalone_err end
        for _, layer in ipairs(standalone_layers) do
            if not contains_number(cfg.layers, layer) then
                return nil, "Standalone layer " .. tostring(layer) .. " is not included in All Layers."
            end
        end
        cfg.standalone_layers = standalone_layers
    else
        cfg.standalone_layers = {}
    end

    cfg.clips = parse_number_list(cfg.clips_text)
    if not cfg.clips then return nil, "Clips must be a list or range like 1-10." end

    local group_count, group_count_err = parse_positive_count(input_value(inputs, "Group Count"), "Group Count")
    if group_count_err then return nil, group_count_err end
    cfg.group_count = group_count
    cfg.groups = list_from_count(group_count)
    cfg.groups_text = range_text_from_count(group_count)

    cfg.columns = parse_number_list(cfg.columns_text)
    if not cfg.columns then return nil, "Columns must be a list or range like 1-10." end

    cfg.clip_release = to_bool01(input_value(inputs, "Clip Release After Press (0/1)"), true)
    cfg.build_macros = to_bool01(input_value(inputs, "Build Macros (0/1)"), true)
    cfg.start_macro = to_int(input_value(inputs, "Start Macro # (0=auto)"), 0)
    if cfg.start_macro < 0 then cfg.start_macro = 0 end
    cfg.overwrite = to_bool01(input_value(inputs, "Overwrite Allocated Macros (0/1)"), false)
    cfg.build_layout = to_bool01(input_value(inputs, "Build Layout View (0/1)"), false)
    cfg.layout_no = to_int(input_value(inputs, "Target Layout #"), 1)
    if cfg.layout_no <= 0 then cfg.layout_no = 1 end
    cfg.layout_x_step = to_int(input_value(inputs, "Layout Column Spacing"), 190)
    cfg.layout_y_step = to_int(input_value(inputs, "Layout Row Spacing"), 157)
    cfg.layout_group_gap = to_int(input_value(inputs, "Layout Group Gap"), 140)
    if cfg.layout_x_step <= 0 then cfg.layout_x_step = 190 end
    if cfg.layout_y_step <= 0 then cfg.layout_y_step = 157 end
    if cfg.layout_group_gap < 0 then cfg.layout_group_gap = 0 end

    return cfg, nil
end

local function read_appearance_config(cfg, ret)
    local inputs = ret.inputs
    cfg.clip_on = appearance_input(inputs, "Clip ON Appearance", "CLIP ON")
    cfg.clip_off = appearance_input(inputs, "Clip OFF Appearance", "CLIP OFF")
    cfg.column_on = appearance_input(inputs, "Column ON Appearance", "COLUMN ON")
    cfg.column_off = appearance_input(inputs, "Column OFF Appearance", "COLUMN OFF")
    cfg.layer_select_on = appearance_input(inputs, "Layer Select ON Appearance", "SELECT ON")
    cfg.layer_select_off = appearance_input(inputs, "Layer Select OFF Appearance", "SELECT OFF")
    cfg.group_select_on = appearance_input(inputs, "Group Select ON Appearance", "SELECT ON")
    cfg.group_select_off = appearance_input(inputs, "Group Select OFF Appearance", "SELECT OFF")
    cfg.layer_disconnect_app = appearance_input(inputs, "Layer Disconnect Appearance", "LAYER CLEAR")
    cfg.group_disconnect_app = appearance_input(inputs, "Group Disconnect Appearance", "GROUP CLEAR")
    cfg.composition_disconnect_app = appearance_input(inputs, "Composition Disconnect Appearance", "COMP CLEAR")
    cfg.initiate_app = appearance_input(inputs, "Initiate Appearance", "INITIATE")

    local appearance_values = {
        cfg.clip_on,
        cfg.clip_off,
        cfg.column_on,
        cfg.column_off,
        cfg.layer_select_on,
        cfg.layer_select_off,
        cfg.group_select_on,
        cfg.group_select_off,
        cfg.layer_disconnect_app,
        cfg.group_disconnect_app,
        cfg.composition_disconnect_app,
        cfg.initiate_app,
    }
    for _, appearance_name in ipairs(appearance_values) do
        if appearance_name == "" then
            return nil, "Appearance names cannot be blank."
        end
        if tonumber(appearance_name) then
            return nil, "Use appearance names like CLIP ON and CLIP OFF, not appearance numbers."
        end
    end

    return cfg, nil
end

local function default_group_layers_text(cfg, group_no)
    if cfg.group_count == 4 and cfg.standalone_text == "17-18" then
        if group_no == 1 then return "1-5" end
        if group_no == 2 then return "6-13" end
        if group_no == 3 then return "14-15" end
        if group_no == 4 then return "16" end
    end
    return ""
end

local function read_group_config(cfg, ret)
    local inputs = ret.inputs
    cfg.group_labels = {}
    cfg.group_layers = {}
    local used_layer = {}
    local mapping_parts = {}

    for _, group_no in ipairs(cfg.groups) do
        local name_key = string.format("Group %d Name", group_no)
        local layers_key = string.format("Group %d Layers", group_no)
        local label = sanitize_label(inputs[name_key])
        if label == "" then label = default_group_label(group_no) end
        cfg.group_labels[group_no] = label

        local layer_text = trim(inputs[layers_key])
        if layer_text == "" then
            return nil, string.format("Group %d (%s) needs a layer range.", group_no, label)
        end

        local layers, err = parse_number_list(layer_text)
        if err then
            return nil, string.format("Group %d (%s) layer error: %s", group_no, label, err)
        end

        cfg.group_layers[group_no] = {}
        for _, layer in ipairs(layers) do
            if not contains_number(cfg.layers, layer) then
                return nil, string.format("Group %d (%s) uses layer %d, but it is not included in All Layers.", group_no, label, layer)
            end
            if contains_number(cfg.standalone_layers, layer) then
                return nil, string.format("Layer %d is marked standalone and also assigned to Group %d (%s).", layer, group_no, label)
            end
            if used_layer[layer] and used_layer[layer] ~= group_no then
                return nil, string.format("Layer %d is assigned to more than one group.", layer)
            end
            used_layer[layer] = group_no
            cfg.group_layers[group_no][#cfg.group_layers[group_no] + 1] = layer
        end

        table.sort(cfg.group_layers[group_no])
        mapping_parts[#mapping_parts + 1] = string.format("%d=%s", group_no, layer_text)
    end

    for _, layer in ipairs(cfg.layers) do
        if not contains_number(cfg.standalone_layers, layer) and not used_layer[layer] then
            return nil, string.format("Layer %d is in All Layers but is neither standalone nor assigned to a group.", layer)
        end
    end

    cfg.mapping_text = table.concat(mapping_parts, ",")
    return cfg, nil
end

local function group_setup_dialog(cfg)
    local inputs = {}
    for _, group_no in ipairs(cfg.groups) do
        inputs[#inputs + 1] = {
            name = string.format("Group %d Name", group_no),
            value = default_group_label(group_no),
            vkPlugin = "TextInput",
            maxTextLength = 64,
        }
        inputs[#inputs + 1] = {
            name = string.format("Group %d Layers", group_no),
            value = default_group_layers_text(cfg, group_no),
            vkPlugin = "TextInput",
            maxTextLength = 64,
        }
    end

    return msgbox({
        title = "Resolume OSC Builder: Groups",
        message = "Name each Resolume group and enter the composition layers inside it. Every non-standalone layer must be assigned to exactly one group.",
        commands = {
            { value = 2, name = "Next" },
            { value = 3, name = "Back" },
            { value = 1, name = "Cancel" },
        },
        inputs = inputs,
        backColor = "Window.Plugins",
        icon = "object_smart",
    })
end

local function appearance_setup_dialog()
    return msgbox({
        title = "Resolume OSC Builder: Appearances",
        message = "Enter appearance labels for each button family. Leave a field blank to use and create the default label.",
        commands = {
            { value = 2, name = "Build" },
            { value = 3, name = "Back" },
            { value = 1, name = "Cancel" },
        },
        inputs = {
            { name = "30 Clip ON Appearance", value = "CLIP ON", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "31 Clip OFF Appearance", value = "CLIP OFF", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "32 Column ON Appearance", value = "COLUMN ON", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "33 Column OFF Appearance", value = "COLUMN OFF", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "34 Layer Select ON Appearance", value = "SELECT ON", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "35 Layer Select OFF Appearance", value = "SELECT OFF", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "36 Group Select ON Appearance", value = "SELECT ON", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "37 Group Select OFF Appearance", value = "SELECT OFF", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "38 Layer Disconnect Appearance", value = "LAYER CLEAR", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "39 Group Disconnect Appearance", value = "GROUP CLEAR", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "40 Composition Disconnect Appearance", value = "COMP CLEAR", vkPlugin = "TextInput", maxTextLength = 64 },
            { name = "41 Initiate Appearance", value = "INITIATE", vkPlugin = "TextInput", maxTextLength = 64 },
        },
        backColor = "Window.Plugins",
        icon = "object_smart",
    })
end

local function main()
    while true do
        local ret = msgbox({
            title = "Resolume OSC Layout Builder " .. VERSION,
            message = "First enter build mode, OSC settings, and composition size. The next pages ask for groups and appearances.",
            commands = {
                { value = 2, name = "Next" },
                { value = 1, name = "Cancel" },
            },
            inputs = {
                { name = "01 Build Macros (0/1)", value = "1", whiteFilter = "01", vkPlugin = "TextInput", maxTextLength = 1 },
                { name = "02 Build Layout View (0/1)", value = "0", whiteFilter = "01", vkPlugin = "TextInput", maxTextLength = 1 },
                { name = "03 Target Layout #", value = "1", vkPlugin = "TextInput", maxTextLength = 8 },
                { name = "04 OSC Output Name", value = "Resolume", vkPlugin = "TextInput", maxTextLength = 64 },
                { name = "05 Macro Prefix", value = "Resolume", vkPlugin = "TextInput", maxTextLength = 64 },
                { name = "06 Start Macro # (0=auto)", value = "0", vkPlugin = "TextInput", maxTextLength = 8 },
                { name = "07 Overwrite Allocated Macros (0/1)", value = "0", whiteFilter = "01", vkPlugin = "TextInput", maxTextLength = 1 },
                { name = "10 All Layers", value = "1-18", vkPlugin = "TextInput", maxTextLength = 64 },
                { name = "11 Standalone Layers", value = "17-18", vkPlugin = "TextInput", maxTextLength = 64 },
                { name = "12 Clips", value = "1-26", vkPlugin = "TextInput", maxTextLength = 64 },
                { name = "13 Group Count", value = "4", vkPlugin = "TextInput", maxTextLength = 4 },
                { name = "14 Columns", value = "1-26", vkPlugin = "TextInput", maxTextLength = 64 },
                { name = "15 Clip Release After Press (0/1)", value = "1", whiteFilter = "01", vkPlugin = "TextInput", maxTextLength = 1 },
                { name = "16 Layout Column Spacing", value = "190", vkPlugin = "TextInput", maxTextLength = 8 },
                { name = "17 Layout Row Spacing", value = "157", vkPlugin = "TextInput", maxTextLength = 8 },
                { name = "18 Layout Group Gap", value = "140", vkPlugin = "TextInput", maxTextLength = 8 },
            },
            backColor = "Window.Plugins",
            icon = "object_smart",
        })

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

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

        local group_ret = group_setup_dialog(cfg)
        if not group_ret then
            return
        elseif group_ret.result == 3 then
            -- Back to the first setup page.
        elseif group_ret.result ~= 2 then
            return
        else

            local group_err
            cfg, group_err = read_group_config(cfg, group_ret)
            if group_err then
                info("Group config error", group_err)
                return
            end

            local appearance_ret = appearance_setup_dialog()
            if not appearance_ret then
                return
            elseif appearance_ret.result == 3 then
                -- Back to the first setup page.
            elseif appearance_ret.result ~= 2 then
                return
            else
                local appearance_err
                cfg, appearance_err = read_appearance_config(cfg, appearance_ret)
                if appearance_err then
                    info("Appearance config error", appearance_err)
                    return
                end

                local ok, build_result = pcall(function()
                    return build(cfg)
                end)
                if not ok then
                    info("Build error", tostring(build_result))
                    return
                end
                if build_result ~= "back" then return end
            end
        end
    end
end

return main
