local GSE = GSE
local Statics = GSE.Static
local L = GSE.L

local GNOME = "Storage"

function GSE.ImportLegacyStorage(Library)
    if GSE.isEmpty(GSESequences) then
        GSESequences = {}
    end
    for i = 0, 13 do
        if GSE.isEmpty(GSESequences[i]) then
            GSESequences[i] = {}
        end
    end

    if not GSE.isEmpty(Library) then
        for k, v in pairs(Library) do
            for i, j in pairs(v) do
                local compressedVersion = GSE.EncodeMessage({i, j})
                GSESequences[k][i] = compressedVersion
            end
        end
    end
    GSELegacyLibraryBackup = GSELibrary
    GSELibrary = nil
end

--- Add a sequence to the library
function GSE.OOCAddSequenceToCollection(sequenceName, sequence, classid)
    -- Check its not a GSE2 Sequence
    if GSE.isEmpty(sequence.Versions) then
        GSE.Print(string.format("%s " .. L["was unable to be interpreted."], sequenceName), L["Unrecognised Import"])
        return
    end
    -- check for version flags.
    if sequence.MetaData.EnforceCompatability and not string.match(GSE.VersionString, "development") then
        if GSE.ParseVersion(sequence.MetaData.GSEVersion) > (GSE.VersionNumber) then
            GSE.Print(
                string.format(
                    L[
                        "This macro uses features that are not available in this version. You need to update GSE to %s in order to use this macro."
                    ],
                    sequence.MetaData.GSEVersion
                )
            )
            GSE.PrintDebugMessage(
                "Macro Version " .. sequence.MetaData.GSEVersion .. " Required Version: " .. GSE.VersionString,
                "Storage"
            )
            return
        end
    end

    GSE.PrintDebugMessage("Attempting to import " .. sequenceName, "Storage")
    GSE.PrintDebugMessage("Classid not supplied - " .. tostring(GSE.isEmpty(classid)), "Storage")
    -- Remove Spaces or commas from SequenceNames and replace with _'s
    sequenceName = string.gsub(sequenceName, " ", "_")
    sequenceName = string.gsub(sequenceName, ",", "_")

    -- check Sequence TOC matches the current TOC
    local gameversion, build, date, tocversion = GetBuildInfo()
    if GSE.isEmpty(sequence.MetaData.TOC) or sequence.MetaData.TOC ~= tocversion then
        GSE.Print(
            string.format(
                L["WARNING ONLY"] ..
                    ": " ..
                        L[
                            "Sequence Named %s was not specifically designed for this version of the game.  It may need adjustments."
                        ],
                sequenceName
            )
        )
    end

    -- Check for collisions
    local found = false
    if (GSE.isEmpty(classid) or classid == 0) and not GSE.isEmpty(sequence.MetaData.SpecID) then
        classid = tonumber(GSE.GetClassIDforSpec(sequence.MetaData.SpecID))
    elseif GSE.isEmpty(sequence.MetaData.SpecID) then
        sequence.MetaData.SpecID = GSE.GetCurrentClassID()
        classid = GSE.GetCurrentClassID()
    end
    GSE.PrintDebugMessage("Classid now - " .. tostring(classid or 0), "Storage")
    if GSE.isEmpty(GSE.Library[classid]) then
        GSE.Library[classid] = {}
    end
    if not GSE.isEmpty(GSE.Library[classid][sequenceName]) then
        found = true
        GSE.PrintDebugMessage("Macro Exists", "Storage")
    end
    if found then
        -- Check if modified
        if GSE.isEmpty(GSE.Library[classid][sequenceName]["MetaData"].ManualIntervention) then
            -- Macro hasnt been touched.
            GSE.PrintDebugMessage(L["No changes were made to "] .. sequenceName, "Storage")
        else
            -- Perform choice.
            -- First check if GUI.
            if GSE.GUI then
                -- Show dialog.
                GSE.GUIShowCompareWindow(sequenceName, classid, sequence)
            else
                GSE.PerformMergeAction(GSEOptions.DefaultImportAction, classid, sequenceName, sequence)
            end
        end
    else
        GSE.PrintDebugMessage("Creating New Macro", "Storage")
        -- New Sequence
        GSE.PerformMergeAction("REPLACE", classid, sequenceName, sequence)
    end
    if classid == GSE.GetCurrentClassID() or classid == 0 then
        GSE.PrintDebugMessage("As its the current class updating buttons", "Storage")
        GSE.UpdateSequence(sequenceName, sequence.Versions[sequence.MetaData.Default])
    end
    GSE:SendMessage(Statics.Messages.SEQUENCE_UPDATED, sequenceName)
end

function GSE.OOCPerformMergeAction(action, classid, sequenceName, newSequence)
    -- Refuse to merge/replace with a Macros-only payload. Auto-rename
    -- has been retired; the user must re-export through gse.tools so
    -- the source is in the current schema before re-import.
    if type(newSequence) == "table" and newSequence.Macros ~= nil and newSequence.Versions == nil then
        GSE.Print(string.format(
            L["Sequence '%s' is incompatible with the current version of GSE. Upload it to https://gse.tools to update it to the current format, then re-import."],
            sequenceName), L["Import"])
        return
    end
    if GSE.isEmpty(newSequence.LastUpdated) then
        newSequence.LastUpdated = GSE.GetTimestamp()
    end
    if sequenceName:len() > 28 then
        local tempseqName = sequenceName:sub(1, 28)
        GSE.Print(
            string.format(
                L[
                    "Your sequence name was longer than 27 characters.  It has been shortened from %s to %s so that your macro will work."
                ],
                sequenceName,
                tempseqName
            ),
            "GSE Storage"
        )
        sequenceName = tempseqName
    end
    if action == "MERGE" then
        -- Both sides need a Versions table. Migration above set them up;
        -- belt-and-braces: if either is still nil, init/empty out so the
        -- ipairs/insert doesn't crash and we don't silently no-op.
        if type(GSE.Library[classid][sequenceName].Versions) ~= "table" then
            GSE.Library[classid][sequenceName].Versions = {}
        end
        if type(newSequence.Versions) ~= "table" then
            newSequence.Versions = {}
        end
        for k, v in ipairs(newSequence.Versions) do
            GSE.PrintDebugMessage("adding " .. k, "Storage")
            table.insert(GSE.Library[classid][sequenceName].Versions, v)
        end
        GSE.PrintDebugMessage("Finished colliding entry entry", "Storage")
        GSE.Print(string.format(L["Extra Macro Versions of %s has been added."], sequenceName), GNOME)
        GSE.ComputeSequenceDependencies(GSE.Library[classid][sequenceName])
        GSESequences[classid][sequenceName] = GSE.EncodeMessage({sequenceName, GSE.Library[classid][sequenceName]})
    elseif action == "REPLACE" then
        GSE.Library[classid][sequenceName] = {}
        GSE.Library[classid][sequenceName] = newSequence
        GSE.PrintDebugMessage("About to encode: Sequence " .. sequenceName)
        GSE.PrintDebugMessage(" New Entry: " .. GSE.Dump(GSE.Library[classid][sequenceName]), "Storage")
        GSE.ComputeSequenceDependencies(GSE.Library[classid][sequenceName])
        GSESequences[classid][sequenceName] = GSE.EncodeMessage({sequenceName, GSE.Library[classid][sequenceName]})
        GSE.Print(sequenceName .. L[" was updated to new version."], "Storage")
    elseif action == "RENAME" then
        GSE.Library[classid][sequenceName] = {}
        GSE.Library[classid][sequenceName] = newSequence
        GSE.ComputeSequenceDependencies(GSE.Library[classid][sequenceName])
        GSESequences[classid][sequenceName] = GSE.EncodeMessage({sequenceName, GSE.Library[classid][sequenceName]})
        GSE.Print(sequenceName .. L[" was imported as a new macro."], "Storage")
        GSE.PrintDebugMessage(
            "Sequence " .. sequenceName .. " New Entry: " .. GSE.Dump(GSE.Library[classid][sequenceName]),
            "Storage"
        )
    else
        GSE.Print(L["No changes were made to "] .. sequenceName, GNOME)
    end
    GSE.Library[classid][sequenceName]["MetaData"].ManualIntervention = false
    GSE.PrintDebugMessage(
        "Sequence " .. sequenceName .. " Finalised Entry: " .. GSE.Dump(GSE.Library[classid][sequenceName]),
        "Storage"
    )
    GSE:SendMessage(Statics.Messages.SEQUENCE_UPDATED, sequenceName)
end

--- Load a collection of Sequences
function GSE.ImportMacroCollection(Sequences)
    for k, v in pairs(Sequences) do
        GSE.AddSequenceToCollection(k, v)
    end
end

--- Add a macro for a sequence and register it in the list of known sequences
function GSE.CreateMacroIcon(sequenceName, icon, forceglobalstub)
    local sequenceIndex = GetMacroIndexByName(sequenceName)
    local numAccountMacros, numCharacterMacros = GetNumMacros()
    if sequenceIndex > 0 then
        -- Sequence exists, do nothing
        GSE.PrintDebugMessage("Moving on - macro for " .. sequenceName .. " already exists.", GNOME)
    else
        -- Create Sequence as a player sequence
        if numCharacterMacros >= MAX_CHARACTER_MACROS and not GSEOptions.overflowPersonalMacros and not forceglobalstub then
            GSE.Print(
                GSEOptions.AuthorColour ..
                    L["Close to Maximum Personal Macros.|r  You can have a maximum of "] ..
                        MAX_CHARACTER_MACROS ..
                            L[" macros per character.  You currently have "] ..
                                GSEOptions.EmphasisColour ..
                                    numCharacterMacros ..
                                        L[
                                            "|r.  As a result this macro was not created.  Please delete some macros and reenter "
                                        ] ..
                                            GSEOptions.CommandColour .. L["/gse|r again."],
                GNOME
            )
        elseif numAccountMacros >= MAX_ACCOUNT_MACROS and GSEOptions.overflowPersonalMacros then
            GSE.Print(
                L["Close to Maximum Macros.|r  You can have a maximum of "] ..
                    MAX_CHARACTER_MACROS ..
                        L[" macros per character.  You currently have "] ..
                            GSEOptions.EmphasisColour ..
                                numCharacterMacros ..
                                    L["|r.  You can also have a  maximum of "] ..
                                        MAX_ACCOUNT_MACROS ..
                                            L[" macros per Account.  You currently have "] ..
                                                GSEOptions.EmphasisColour ..
                                                    numAccountMacros ..
                                                        L[
                                                            "|r. As a result this macro was not created.  Please delete some macros and reenter "
                                                        ] ..
                                                            GSEOptions.CommandColour .. L["/gse|r again."],
                GNOME
            )
        else
            CreateMacro(
                sequenceName,
                (GSEOptions.setDefaultIconQuestionMark and "INV_MISC_QUESTIONMARK" or icon),
                GSE.CreateMacroString(sequenceName),
                (forceglobalstub and false or GSE.SetMacroLocation())
            )
        end
    end
end

local function fixContainer(v)
    local fixedTable = {}
    for k, val in pairs(v) do
        if type(v[k]) == "table" then
            if tonumber(k) then
                fixedTable[tonumber(k)] = {}
                fixedTable[tonumber(k)] = fixContainer(val)
            else
                fixedTable[k] = fixContainer(val)
            end
        else
            fixedTable[k] = val
        end
    end
    for k, val in ipairs(v) do
        if type(v[k]) == "table" then
            fixedTable[k] = fixContainer(val)
        else
            fixedTable[k] = val
        end
    end
    return fixedTable
end

function GSE.processWAGOImport(input, dontencode)
    -- Pre-#1853 records stored versions under "Macros" instead of "Versions".
    -- The auto-rename has been retired: refuse to interpret a Macros-only
    -- record so the user can re-export from gse.tools and re-import.
    -- Returns nil; callers must handle that as "abort this import".
    if input and type(input) == "table" and input.Macros ~= nil and input.Versions == nil then
        local name = (input.MetaData and input.MetaData.Name) or input.SequenceName or "<unknown>"
        GSE.Print(string.format(
            L["Sequence '%s' is incompatible with the current version of GSE. Upload it to https://gse.tools to update it to the current format, then re-import."],
            name), L["Import"])
        return nil
    end
    for k, v in ipairs(input) do
        if type(v) == "table" then
            input[k] = fixContainer(v)
        end
    end
    for k, v in pairs(input) do
        if type(v) == "table" then
            input[k] = fixContainer(v)
        end
    end
    if dontencode then
        return input
    else
        return GSE.EncodeMessage(input)
    end
end

--- Load a serialised Sequence
-- skipDialogs: when true, suppress per-sequence StaticPopup confirmation dialogs
-- (version-mismatch and checksum warnings). Used by collection imports so that
-- the synchronous loop over N sequences does not clobber a single shared popup
-- slot, which would silently drop all but the last sequence.
-- forcemerge: when true, route conflict resolution to PerformMergeAction("MERGE")
-- without showing the compare dialog. Mutually exclusive with forcereplace —
-- if both are true, forcereplace wins (defensive).
function GSE.ImportSerialisedSequence(importstring, forcereplace, skipDialogs, forcemerge)
    local decompresssuccess, actiontable
    if type(importstring) == "table" then
        decompresssuccess, actiontable = true, importstring
    else
        decompresssuccess, actiontable = GSE.DecodeMessage(importstring)
    end
    GSE.PrintDebugMessage(string.format("Decomsuccess: %s ", tostring(decompresssuccess)), Statics.SourceTransmission)

    if decompresssuccess and actiontable then
        if actiontable.type == "COLLECTION" then
            actiontable = actiontable.payload
            -- Sequences/Variables/Macros in a COLLECTION payload are keyed by
            -- name and their values are the raw data tables (no {name, data}
            -- array wrapper). Propagate the key into the object's identity
            -- field so the recursive call can resolve it.
            for name, v in pairs(actiontable["Variables"]) do
                if type(v) == "table" and not v.name then v.name = name end
                GSE.ImportSerialisedSequence(v, forcereplace, true, forcemerge)
            end
            for name, v in pairs(actiontable["Sequences"]) do
                if type(v) == "table" then
                    v.MetaData = v.MetaData or {}
                    if not v.MetaData.Name then v.MetaData.Name = name end
                end
                GSE.ImportSerialisedSequence(v, forcereplace, true, forcemerge)
            end
            for name, v in pairs(actiontable["Macros"]) do
                if type(v) == "table" and not v.name then v.name = name end
                GSE.ImportSerialisedSequence(v, forcereplace, true, forcemerge)
            end
            GSE:SendMessage(Statics.Messages.COLLECTION_IMPORTED)
        elseif actiontable.objectType == "MACRO" then
            actiontable.objectType = nil
            local oocaction = {
                ["action"] = "importmacro",
                ["node"] = actiontable
            }
            GSE.EnqueueOOC(oocaction)
        elseif actiontable.objectType == "VARIABLE" then
            actiontable.objectType = nil
            local oocaction = {
                ["action"] = "updatevariable",
                ["variable"] = actiontable,
                ["name"] = actiontable.name
            }
            GSE.EnqueueOOC(oocaction)
        else
            actiontable.objectType = nil
            GSE.PrintDebugMessage(
                string.format(
                    "tablerows: %s   type cell1 %s cell2 %s",
                    #actiontable,
                    type(actiontable[1]),
                    type(actiontable[2])
                ),
                Statics.SourceTransmission
            )
            local k, v = actiontable[1], actiontable[2]
            if actiontable.MetaData and actiontable.MetaData.Name then
                k = actiontable.MetaData.Name
                v = actiontable
            end
            local seqName = k
            v = GSE.processWAGOImport(v, true)
            -- processWAGOImport returns nil when it refuses an import
            -- (e.g. pre-#1853 records still using `Macros`). It already
            -- printed the user-facing message; we just abort this branch.
            if not v then return false end

            if v.MetaData.GSEVersion and v.MetaData.GSEVersion > 3200 then
                if v.MetaData.GSEVersion < GSE.VersionNumber then
                    if skipDialogs then
                        -- Inside a collection import: log and proceed rather than
                        -- showing a popup that would be clobbered by the next item.
                        GSE.Print(string.format(L["Sequence '%s' was created with an older version of GSE (%s) - importing anyway as part of collection."], seqName, tostring(v.MetaData.GSEVersion)), L["Import"])
                    else
                        -- Older sequence: always show the older-version dialog.
                        -- OnAccept will chain to the checksum dialog for sequences
                        -- >= Statics.ChecksumMinVersion (checksums were introduced then).
                        StaticPopup_Show("GSE_SEQUENCE_OLDER_VERSION", seqName, tostring(v.MetaData.GSEVersion),
                            {seqName = seqName, sequence = v, forcereplace = forcereplace})
                        return decompresssuccess
                    end
                end
            else
                GSE.Print(
                        L["This macro is not compatible with this version of the game and cannot be imported."],
                        L["Import"]
                    )
                return
            end
            -- Warn if the sequence has been modified after it was exported.
            -- If the checksum is invalid the user must confirm before the import proceeds.
            -- Suppress the dialog inside collection imports to avoid clobbering.
            if GSE.VerifySequenceChecksum then
                local integrity = GSE.VerifySequenceChecksum(v)
                if integrity ~= true and not skipDialogs then
                    StaticPopup_Show("GSE_SEQUENCE_INTEGRITY_WARNING", seqName, nil,
                        {seqName = seqName, sequence = v, forcereplace = forcereplace})
                    return decompresssuccess
                end
            end

            if forcereplace then
                GSE.PerformMergeAction("REPLACE", GSE.GetClassIDforSpec(v.MetaData.SpecID), seqName, v)
            elseif forcemerge then
                -- Route to MERGE without compare dialog. The classid lookup
                -- mirrors AddSequenceToCollection's resolution: prefer the
                -- spec's class id, fall back to scanning class libs for an
                -- existing same-name sequence.
                local classid = GSE.GetClassIDforSpec(v.MetaData and v.MetaData.SpecID)
                if (classid == nil or classid == 0) and GSE.Library then
                    for cid = 0, 13 do
                        if GSE.Library[cid] and GSE.Library[cid][seqName] then classid = cid break end
                    end
                end
                GSE.PerformMergeAction("MERGE", classid or 0, seqName, v)
            else
                GSE.AddSequenceToCollection(seqName, v)
            end

            GSE:SendMessage(Statics.Messages.SEQUENCE_UPDATED, seqName)
        end
    else
        GSE.Print(L["Unable to interpret sequence."], GNOME)
        decompresssuccess = false
    end

    return decompresssuccess
end

--- This function removes any macro stubs that do not relate to a GSE macro
function GSE.CleanOrphanSequences()
    local maxmacros = MAX_ACCOUNT_MACROS + MAX_CHARACTER_MACROS + 2
    local todelete = {}
    for macid = 1, maxmacros do
        local found = false
        local mname, mtexture, mbody = GetMacroInfo(macid)
        if not GSE.isEmpty(mname) then
            if not GSE.isEmpty(GSE.Library[GSE.GetCurrentClassID()][mname]) then
                found = true
            end
            if not GSE.isEmpty(GSE.Library[0][mname]) then
                found = true
            end

            if not found then
                -- Check if body is a gs one and delete the orphan
                todelete[mname] = true
            end
        end
    end
    for k, _ in pairs(todelete) do
        GSE.DeleteMacroStub(k)
    end
end

--- This function dumps what is currently running on an existing button.
function GSE.DebugDumpButton(SequenceName)
    GSE.Print("====================================\nStart GSE Button Dump\n====================================")
    GSE.Print("Button name: " .. SequenceName)
    GSE.Print("Step Id: " .. _G[SequenceName]:GetAttribute("step"))
    GSE.Print("ms: " .. _G[SequenceName]:GetAttribute("ms"))
    GSE.Print("====================================\nStep\n====================================")
    GSE.Print(GSE.SequencesExec[SequenceName][_G[SequenceName]:GetAttribute("step")])
    GSE.Print("====================================\nEnd GSE Button Dump\n====================================")
end

--- Moves Macros hidden in Global Macros to their appropriate class.
function GSE.MoveMacroToClassFromGlobal()
    for k, v in pairs(GSE.Library[0]) do
        if not GSE.isEmpty(v.SpecID) and tonumber(v.SpecID) > 0 then
            if v.SpecID < 13 then
                GSE.Library[v.SpecID][k] = v
                GSE.Print(string.format(L["Moved %s to class %s."], k, Statics.SpecIDList[v.SpecID]))
                GSE.Library[0][k] = nil
            else
                GSE.Library[GSE.GetClassIDforSpec(v.SpecID)][k] = v
                GSE.Print(
                    string.format(L["Moved %s to class %s."], k, Statics.SpecIDList[GSE.GetClassIDforSpec(v.SpecID)])
                )
                GSE.Library[0][k] = nil
            end
        end
    end
    GSE.ReloadSequences()
end

-- ============================================================
-- Sequence error checker: module-level helpers
-- ============================================================

--- MetaData keys that store Macros array index references.
local seqContextKeys = {
    "Default", "Scenario", "Arena", "PVP", "Raid",
    "Normal", "Mythic", "Timewalking", "Party"
}

--- Set of valid WoW macro slash commands (warcraft.wiki.gg/wiki/Macro_commands).
-- Built once at load time from Statics + comprehensive wiki list.
local validMacroSlashCmds = (function()
    local s = {}
    for cmd in pairs(Statics.CastCmds or {}) do s[cmd] = true end
    for _, cmd in ipairs(Statics.MacroCommands or {}) do s[cmd] = true end
    for _, cmd in ipairs({
        -- Combat / casting
        "castrandom", "castsequence", "changeactionbar", "stopcasting",
        "stopspelltarget", "swapactionbar", "userandom", "spell",
        -- Targeting
        "tar", "targetexact", "targetenemyplayer", "targetfriendplayer",
        "targetparty", "targetraid", "targetlastenemy", "targetlastfriend",
        "targetlasttarget",
        -- Pet
        "petassist", "petautocasttoggle", "petdefensive", "petdismiss",
        "petfollow", "petmoveto", "petpassive", "petstay",
        -- System
        "console", "click", "disableaddons", "enableaddons", "help",
        "logout", "macrohelp", "played", "quit", "random", "reload",
        "run", "script", "stopmacro", "time", "timetest", "who",
        -- Character / inventory
        "equip", "equipset", "equipslot", "friend", "follow", "ignore",
        "inspect", "leavevehicle", "randompet", "removefriend", "settitle",
        "trade", "unignore", "summonpet", "dismisspet", "randomfavoritepet",
        -- UI / Blizzard frames
        "achievements", "calendar", "guildfinder", "dungeonfinder", "loot",
        "macro", "raidfinder", "share", "stopwatch",
        -- Chat (full names and abbreviations)
        "afk", "announce", "battleground", "emote", "dnd", "guild",
        "join", "leave", "party", "raid", "rw", "reply", "say",
        "whisper", "yell", "s", "y", "g", "p", "bg", "i", "o", "me",
        -- Party / Raid
        "clearworldmarker", "invite", "readycheck", "requestinvite",
        "targetmarker", "uninvite", "worldmarker", "raidinfo", "promote",
        "ffa", "master", "mainassist", "mainassistoff", "maintank",
        "maintankoff",
        -- Guild
        "guilddemote", "guilddisband", "guildinfo", "guildinvite",
        "guildleader", "guildquit", "guildmotd", "guildpromote",
        "guildroster", "guildremove",
        -- PvP
        "duel", "forfeit", "pvp", "wargame",
        -- Miscellaneous
        "in", "showtooltip", "show",
    }) do
        s[cmd] = true
    end
    return s
end)()

--- Returns (ipairsCount, totalNumericCount, maxNumericIndex) for a table.
-- Reveals array gaps: if totalNumericCount > ipairsCount, there are unreachable entries.
local function arrayStats(t)
    local ipCount = 0
    for _ in ipairs(t) do ipCount = ipCount + 1 end
    local numCount, maxIdx = 0, 0
    for k in pairs(t) do
        if type(k) == "number" and k >= 1 then
            numCount = numCount + 1
            if k > maxIdx then maxIdx = k end
        end
    end
    return ipCount, numCount, maxIdx
end

--- Inspects one sequence for structural and content issues.
-- Returns a list of human-readable issue strings (empty = no problems).
local function checkSeqStructure(classlibid, seqname, seq) -- luacheck: ignore classlibid seqname
    local issues = {}

    if type(seq) ~= "table" then
        table.insert(issues, L["Sequence is not a table"])
        return issues
    end

    -- Top-level required tables
    if GSE.isEmpty(seq.MetaData) or type(seq.MetaData) ~= "table" then
        table.insert(issues, L["Missing MetaData table"])
        return issues
    end
    -- Pre-#1853 schema detection. Auto-rename has been retired; this
    -- record can't be processed until it goes through gse.tools and
    -- comes back in the current shape. Specific-over-generic so the
    -- user sees the actual remedy rather than "missing Macros table".
    if seq.Macros ~= nil and seq.Versions == nil then
        table.insert(issues, string.format(
            L["Sequence '%s' is incompatible with the current version of GSE. Upload it to https://gse.tools to update it to the current format, then re-import."],
            seqname))
        return issues
    end
    if type(seq.Versions) ~= "table" then
        table.insert(issues, L["Missing or invalid Macros table"])
        return issues
    end

    -- Required MetaData fields
    if GSE.isEmpty(seq.MetaData.SpecID) then
        table.insert(issues, L["MetaData.SpecID is missing"])
    end

    -- Integrity check: if a checksum is present it must still match the Versions content.
    -- "no_checksum" is not an error — locally created sequences have no checksum.
    if GSE.VerifySequenceChecksum then
        if GSE.VerifySequenceChecksum(seq) == false then
            table.insert(issues, L["Sequence has been altered from its exported state"])
        end
    end

    -- Macros array analysis. Special-case the 0-indexed table — it has
    -- versions but ipairs starts at 1, so editor/runtime see nothing.
    -- Detect it BEFORE the generic "empty" message so the error log says
    -- the actual problem (and FixSequenceStructure can recover them by
    -- remapping 0→1 in step 4).
    local macIp, macNum, macMax = arrayStats(seq.Versions)
    local hasZeroKey = seq.Versions[0] ~= nil
    if hasZeroKey then
        local zeroKeyed = 0
        for k in pairs(seq.Versions) do
            if type(k) == "number" and k == 0 then zeroKeyed = zeroKeyed + 1 end
        end
        table.insert(issues, string.format(
            L["Versions starts at index 0 (Lua ipairs starts at 1 → editor and runtime see no versions). %d entr%s at index 0."],
            zeroKeyed, zeroKeyed == 1 and "y" or "ies"))
        -- Don't bail early — keep checking other structural issues so the
        -- repair report covers everything in one pass.
    elseif macNum == 0 then
        table.insert(issues, L["Macros array is empty (no versions defined)"])
        return issues
    end
    if macNum > macIp then
        table.insert(issues, string.format(
            L["Macros array has gaps: %d version(s) reachable of %d total (max index %d)"],
            macIp, macNum, macMax))
    end

    -- MetaData context version references must point to existing Macros entries
    for _, ctxKey in ipairs(seqContextKeys) do
        local val = seq.MetaData[ctxKey]
        if not GSE.isEmpty(val) then
            local idx = tonumber(val)
            if idx and (idx < 1 or idx > macMax or seq.Versions[idx] == nil) then
                table.insert(issues, string.format(
                    L["MetaData.%s = %d references a non-existent Macros version (max valid index: %d)"],
                    ctxKey, idx, macMax))
            end
        end
    end

    -- Valid Action types
    local validTypes = {
        [Statics.Actions.Loop]   = true,
        [Statics.Actions.If]     = true,
        [Statics.Actions.Repeat] = true,
        [Statics.Actions.Action] = true,
        [Statics.Actions.Pause]  = true,
        [Statics.Actions.Embed]  = true,
    }

    -- Inspect every Macro version (including those beyond array gaps)
    for macIdx, macVer in pairs(seq.Versions) do
        if type(macIdx) == "number" then
            if type(macVer) ~= "table" then
                table.insert(issues, string.format(L["Macros[%d] is not a table"], macIdx))
            elseif type(macVer.Actions) ~= "table" then
                table.insert(issues, string.format(
                    L["Macros[%d].Actions is missing or not a table"], macIdx))
            else
                -- Actions array gap detection
                local actIp, actNum, actMax = arrayStats(macVer.Actions)
                if actNum > actIp then
                    table.insert(issues, string.format(
                        L["Macros[%d].Actions has gaps: %d reachable of %d total (max index %d)"],
                        macIdx, actIp, actNum, actMax))
                end

                -- Inspect every Action (including those beyond gaps)
                for actIdx, action in pairs(macVer.Actions) do
                    if type(actIdx) == "number" and type(action) == "table" then
                        if GSE.isEmpty(action.Type) then
                            table.insert(issues, string.format(
                                L["Macros[%d].Actions[%d] is missing Type field"],
                                macIdx, actIdx))
                        elseif not validTypes[action.Type] then
                            table.insert(issues, string.format(
                                L["Macros[%d].Actions[%d] has unrecognized Type: '%s'"],
                                macIdx, actIdx, tostring(action.Type)))
                        else
                            -- Type-specific required fields
                            if action.Type == Statics.Actions.If then
                                if GSE.isEmpty(action.Variable) then
                                    table.insert(issues, string.format(
                                        L["Macros[%d].Actions[%d] (If) is missing the Variable field"],
                                        macIdx, actIdx))
                                end
                            elseif action.Type == Statics.Actions.Embed then
                                if GSE.isEmpty(action.Sequence) then
                                    table.insert(issues, string.format(
                                        L["Macros[%d].Actions[%d] (Embed) is missing the Sequence field"],
                                        macIdx, actIdx))
                                end
                            elseif action.Type == Statics.Actions.Pause then
                                if GSE.isEmpty(action.Clicks) and GSE.isEmpty(action.MS) then
                                    table.insert(issues, string.format(
                                        L["Macros[%d].Actions[%d] (Pause) has neither Clicks nor MS"],
                                        macIdx, actIdx))
                                end
                            elseif action.Type == Statics.Actions.Action then
                                if not GSE.isEmpty(action.macro) then
                                    local raw = tostring(action.macro)
                                    if raw:sub(1, 1) == "/" then
                                        -- 255-character WoW macro block limit
                                        local unesc = GSE.UnEscapeString(raw)
                                        if #unesc > 255 then
                                            table.insert(issues, string.format(
                                                L["Macros[%d].Actions[%d] macro text exceeds 255 characters (%d chars)"],
                                                macIdx, actIdx, #unesc))
                                        end
                                        -- Unbalanced conditional bracket check
                                        local opens, closes = 0, 0
                                        for _ in raw:gmatch("%[") do opens  = opens  + 1 end
                                        for _ in raw:gmatch("%]") do closes = closes + 1 end
                                        if opens ~= closes then
                                            table.insert(issues, string.format(
                                                L["Macros[%d].Actions[%d] macro text has unbalanced brackets (%d '[' vs %d ']')"],
                                                macIdx, actIdx, opens, closes))
                                        end
                                        -- Unknown slash command check
                                        local cmd = raw:match("^/(%a+)")
                                        if cmd and not validMacroSlashCmds[cmd:lower()] then
                                            table.insert(issues, string.format(
                                                L["Macros[%d].Actions[%d] uses unrecognized slash command: /%s"],
                                                macIdx, actIdx, cmd))
                                        end
                                    end
                                    -- Check for Java-style // comments; GSE only strips lines starting with --
                                    for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
                                        if line:match("^%s*//") then
                                            table.insert(issues, string.format(
                                                L["Macros[%d].Actions[%d] uses // comments instead of --; GSE will not strip these on compile"],
                                                macIdx, actIdx))
                                            break
                                        end
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    return issues
end

-- ---------------------------------------------------------------------------
-- Dependency tracking
-- ---------------------------------------------------------------------------

--- Scan a string for GSE variable references, accumulating names into `found`.
local function scanStringForVarRefs(str, found)
    -- Bracket notation: GSE.V["name"]() or GSE.V['name']()
    for name in str:gmatch('GSE%.V%[["\'](.-)["\']%]') do
        found[name] = true
    end
    -- Dot notation: GSE.V.name() or GSE.V.name(arg)
    for name in str:gmatch('GSE%.V%.([%w_]+)%s*%(') do
        found[name] = true
    end
end

--- Scan a string for WoW macro API references, accumulating macro names into `found`.
-- Matches GetMacroBody("name"), GetMacroIndexByName("name"), GetMacroSpell("name"),
-- GetMacroItem("name"), and any other GetMacro*(name) Lua API call.
local function scanStringForMacroRefs(str, found)
    for name in str:gmatch('GetMacro%a*%s*%(%s*["\']([^"\']+)["\']') do
        found[name] = true
    end
end

--- Recursively walk a table collecting variable refs, Embed sequence names, and macro refs.
local function walkTableForDeps(t, vars, seqs, macros)
    for _, v in pairs(t) do
        if type(v) == "string" then
            scanStringForVarRefs(v, vars)
            scanStringForMacroRefs(v, macros)
        elseif type(v) == "table" then
            -- Use rawget to avoid triggering Statics.TableMetadataFunction __index,
            -- which expects array path keys and errors on plain string keys.
            local vType     = rawget(v, "Type")
            local vtype     = rawget(v, "type")
            local vSequence = rawget(v, "Sequence")
            local vmacro    = rawget(v, "macro")
            if vType == "Embed" and type(vSequence) == "string" then
                seqs[vSequence] = true
            end
            -- Action block with type="macro": if the macro field does not start with
            -- "/", "#", or "=" it is a plain WoW macro name reference, not command text.
            -- "=" prefix means it's a GSE variable expression (e.g. =GSE.V.Name()), not a macro name.
            if vType == "Action" and vtype == "macro" and type(vmacro) == "string"
                    and not rawget(v, "Disabled") then
                local text = GSE.UnEscapeString(vmacro)
                local first = string.sub(text, 1, 1)
                if #text > 0 and first ~= "/" and first ~= "#" and first ~= "=" then
                    macros[text] = true
                end
            end
            if not (vType == "Action" and rawget(v, "Disabled")) then
                walkTableForDeps(v, vars, seqs, macros)
            end
        end
    end
end

--- Compute the direct dependencies of a sequence by scanning its Macros.
-- Mutates sequence.MetaData.Dependencies in place and returns it.
-- {Variables = {"v1",...}, Sequences = {"s1",...}, Macros = {"m1",...}}  (sorted arrays)
function GSE.ComputeSequenceDependencies(sequence)
    if type(sequence) ~= "table" or type(sequence.MetaData) ~= "table" then return end
    local vars, seqs, macros = {}, {}, {}
    if type(sequence.Versions) == "table" then
        walkTableForDeps(sequence.Versions, vars, seqs, macros)
    end
    local varList, seqList, macroList = {}, {}, {}
    for k in pairs(vars) do table.insert(varList, k) end
    for k in pairs(seqs) do table.insert(seqList, k) end
    for k in pairs(macros) do table.insert(macroList, k) end
    table.sort(varList)
    table.sort(seqList)
    table.sort(macroList)
    local deps = {Variables = varList, Sequences = seqList, Macros = macroList}
    sequence.MetaData.Dependencies = deps
    return deps
end

--- Compute the direct variable dependencies of a variable's funct string.
-- Mutates variable.Dependencies in place and returns it.
function GSE.ComputeVariableDependencies(variable)
    if type(variable) ~= "table" then return end
    local vars = {}
    if type(variable.funct) == "string" then
        scanStringForVarRefs(variable.funct, vars)
    end
    local varList = {}
    for k in pairs(vars) do table.insert(varList, k) end
    table.sort(varList)
    local deps = {Variables = varList}
    variable.Dependencies = deps
    return deps
end

--- Resolve the full transitive closure of variable names.
-- varNames: array (or set) of starting variable names.
-- Returns a set table {name = true} of all required variable names.
function GSE.GetTransitiveVariableDeps(varNames)
    local result = {}
    local queue = {}
    -- Accept both array and set as input
    if type(varNames) == "table" then
        for k, v in pairs(varNames) do
            local name = (type(k) == "string" and v == true) and k or v
            if type(name) == "string" and not result[name] then
                result[name] = true
                table.insert(queue, name)
            end
        end
    end
    local i = 1
    while i <= #queue do
        local name = queue[i]
        i = i + 1
        if not GSE.isEmpty(GSEVariables) and not GSE.isEmpty(GSEVariables[name]) then
            local ok, decoded = GSE.DecodeMessage(GSEVariables[name])
            if ok and decoded and decoded.Dependencies and decoded.Dependencies.Variables then
                for _, depname in ipairs(decoded.Dependencies.Variables) do
                    if not result[depname] then
                        result[depname] = true
                        table.insert(queue, depname)
                    end
                end
            end
        end
    end
    return result
end

--- Find all loaded sequences and variables that directly depend on a variable.
-- Only searches classes already in GSE.Library (lazy-loaded classes not forced).
-- Returns { sequences = {{classid=n, name=s}, ...}, variables = {name, ...} }
function GSE.GetVariableDependents(varName)
    local seqs, vars = {}, {}
    for classid = 0, 13 do
        if GSE.Library[classid] then
            for seqname, seq in pairs(GSE.Library[classid]) do
                if type(seq) == "table" and type(seq.MetaData) == "table" then
                    local deps = seq.MetaData.Dependencies
                    if deps and type(deps.Variables) == "table" then
                        for _, vname in ipairs(deps.Variables) do
                            if vname == varName then
                                table.insert(seqs, {classid = classid, name = seqname})
                                break
                            end
                        end
                    end
                end
            end
        end
    end
    if not GSE.isEmpty(GSEVariables) then
        for vname, vdata in pairs(GSEVariables) do
            if vname ~= varName then
                local ok, decoded = GSE.DecodeMessage(vdata)
                if ok and decoded and decoded.Dependencies and type(decoded.Dependencies.Variables) == "table" then
                    for _, depname in ipairs(decoded.Dependencies.Variables) do
                        if depname == varName then
                            table.insert(vars, vname)
                            break
                        end
                    end
                end
            end
        end
    end
    table.sort(vars)
    table.sort(seqs, function(a, b)
        if a.classid ~= b.classid then return a.classid < b.classid end
        return a.name < b.name
    end)
    return {sequences = seqs, variables = vars}
end

--- Find all loaded sequences that embed the given sequence name.
-- Only searches classes already in GSE.Library (lazy-loaded classes not forced).
-- Returns array of {classid=n, name=s}.
function GSE.GetSequenceDependents(seqName)
    local result = {}
    for classid = 0, 13 do
        if GSE.Library[classid] then
            for name, seq in pairs(GSE.Library[classid]) do
                if type(seq) == "table" and type(seq.MetaData) == "table" then
                    local deps = seq.MetaData.Dependencies
                    if deps and type(deps.Sequences) == "table" then
                        for _, sname in ipairs(deps.Sequences) do
                            if sname == seqName then
                                table.insert(result, {classid = classid, name = name})
                                break
                            end
                        end
                    end
                end
            end
        end
    end
    table.sort(result, function(a, b)
        if a.classid ~= b.classid then return a.classid < b.classid end
        return a.name < b.name
    end)
    return result
end

-- Queue of corrupt sequences waiting for the player to act on them.
local corruptQueue = {}

--- Show the next dialog for a corrupt sequence, if any remain in the queue.
-- Called by the StaticPopup OnAccept/OnCancel handlers to chain through all entries.
function GSE.ProcessNextCorruptSequence()
    if #corruptQueue == 0 then return end
    local entry = table.remove(corruptQueue, 1)
    StaticPopupDialogs["GSE_CORRUPT_SEQUENCE"].text = string.format(
        L["GSE_CORRUPT_SEQUENCE_TEXT"], entry.name, entry.classid
    )
    StaticPopup_Show("GSE_CORRUPT_SEQUENCE", nil, nil, entry)
end

--- Build the dialog queue from GSE.CorruptSequences and show the first dialog.
-- Drains the global list so repeated calls do not double-present the same entries.
function GSE.ProcessCorruptSequences()
    for _, entry in ipairs(GSE.CorruptSequences or {}) do
        table.insert(corruptQueue, entry)
    end
    GSE.CorruptSequences = {}
    if #corruptQueue > 0 then
        GSE.Print(string.format(L["%d corrupt sequence(s) found \226\128\148 showing resolution options."], #corruptQueue))
        GSE.ProcessNextCorruptSequence()
    end
end

--- Scans all sequences in GSE.Library for structural and content issues,
-- then checks GSESequences entries for valid encoding.
function GSE.ScanMacrosForErrors()
    GSE.Print(L["Scanning GSE.Library for structural and content issues..."])
    local totalIssues = 0
    local autoFixedCount = 0

    -- Auto-fixable issue detection. For now only the "Versions starts at
    -- index 0" case — FixSequenceStructure remaps 0→1 cleanly and the
    -- result is always equivalent or better. Other issues (missing
    -- MetaData, schema-incompatible, etc.) need user attention so they
    -- continue to surface verbatim. Match by substring on a stable
    -- prefix of the localised string; if a translator drops the prefix
    -- the auto-fix simply doesn't trigger and the user gets the manual
    -- /run hint as before — not a regression.
    local function isAutoFixableIssue(issue)
        return type(issue) == "string" and issue:find("Versions starts at index 0", 1, true) ~= nil
    end

    -- 1. Structural / content checks on GSE.Library (all class IDs, including 0 = global)
    for classlibid = 0, 13 do
        GSE.EnsureClassLoaded(classlibid)
        local classlib = GSE.Library[classlibid]
        if classlib and type(classlib) == "table" then
            for seqname, seq in pairs(classlib) do
                local issues = checkSeqStructure(classlibid, seqname, seq)

                -- Partition issues into auto-fixable vs. the rest. If
                -- any are auto-fixable, run FixSequenceStructure silently
                -- once for the whole sequence (the fix re-keys Versions
                -- end-to-end, which addresses every auto-fixable issue
                -- at once) and re-scan to surface anything that didn't
                -- get cleaned up by the repair.
                local hadAutoFixable = false
                for _, issue in ipairs(issues) do
                    if isAutoFixableIssue(issue) then hadAutoFixable = true; break end
                end
                if hadAutoFixable then
                    if GSE.FixSequenceStructure(classlibid, seqname, true) then
                        autoFixedCount = autoFixedCount + 1
                        -- Re-fetch the sequence post-repair and re-scan
                        -- so the rest of this iteration sees the repaired
                        -- shape, not the broken one we entered with.
                        seq = GSE.Library[classlibid] and GSE.Library[classlibid][seqname]
                        issues = seq and checkSeqStructure(classlibid, seqname, seq) or {}
                    end
                end

                if #issues > 0 then
                    totalIssues = totalIssues + #issues
                    GSE.Print(
                        string.format(L["Issues found in '%s' (class library %d):"], seqname, classlibid),
                        "Error"
                    )
                    for _, issue in ipairs(issues) do
                        GSE.Print("  - " .. issue)
                    end
                    GSE.Print(
                        string.format(
                            L["To attempt automatic repair run: %s/run GSE.FixSequenceStructure(%d, \"%s\")%s"],
                            GSEOptions.CommandColour,
                            classlibid,
                            seqname,
                            Statics.StringReset
                        )
                    )
                end

                -- Runtime compile check for each reachable Macro version
                if type(seq) == "table" and type(seq.Versions) == "table" then
                    for macvidx, macroversion in ipairs(seq.Versions) do
                        if type(macroversion) == "table" and type(macroversion.Actions) == "table" then
                            local ok, result = pcall(GSE.CompileTemplate, macroversion)
                            if not ok then
                                totalIssues = totalIssues + 1
                                GSE.Print(
                                    string.format(
                                        L["Compile error in Macros[%d] of '%s': %s"],
                                        macvidx, seqname, tostring(result)
                                    ),
                                    "Error"
                                )
                            elseif type(result) == "table" then
                                -- Check for steps that would encode to an empty string.
                                -- This happens when GetSpellId returns nil (e.g. after
                                -- resurrection/phasing), leaving a step with only blockPath.
                                -- Such steps crash inside the secure Execute string with
                                -- "attempt to perform arithmetic on local 'sa' (a nil value)".
                                for stepIdx, step in ipairs(result) do
                                    local hasField = false
                                    for k, _ in pairs(step) do
                                        if k ~= "blockPath" then
                                            hasField = true
                                            break
                                        end
                                    end
                                    if not hasField then
                                        totalIssues = totalIssues + 1
                                        GSE.Print(
                                            string.format(
                                                L["Empty step at index %d in Macros[%d] of '%s': spell ID lookup may have failed"],
                                                stepIdx, macvidx, seqname
                                            ),
                                            "Error"
                                        )
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    -- 2. Name collision checks (WW / PVP clash with WoW built-ins)
    for classlibid = 0, 13 do
        local classlib = GSE.Library[classlibid]
        if classlib and type(classlib) == "table" then
            for seqname, _ in pairs(classlib) do
                if seqname == "WW" then
                    GSE.Print(
                        string.format(
                            L[
                                "Macro found by the name %sWW%s. Rename this macro to a different name to be able to use it.  WOW has a hidden button called WW that is executed instead of this macro."
                            ],
                            GSEOptions.CommandColour,
                            Statics.StringReset
                        ),
                        "Error"
                    )
                elseif seqname == "PVP" then
                    GSE.Print(
                        string.format(
                            L[
                                "Macro found by the name %sPVP%s. Rename this macro to a different name to be able to use it.  WOW has a global object called PVP that is referenced instead of this macro."
                            ],
                            GSEOptions.CommandColour,
                            Statics.StringReset
                        ),
                        "Error"
                    )
                end
            end
        end
    end

    -- 3. Dependency checks: missing variables and sequences
    for classlibid = 0, 13 do
        local classlib = GSE.Library[classlibid]
        if classlib and type(classlib) == "table" then
            for seqname, seq in pairs(classlib) do
                if type(seq) == "table" and type(seq.MetaData) == "table" then
                    local deps = seq.MetaData.Dependencies
                    if deps then
                        -- Check variable dependencies
                        if type(deps.Variables) == "table" then
                            for _, vname in ipairs(deps.Variables) do
                                if GSE.isEmpty(GSEVariables) or GSE.isEmpty(GSEVariables[vname]) then
                                    totalIssues = totalIssues + 1
                                    GSE.Print(
                                        string.format(
                                            L["Sequence '%s' (class %d) depends on variable '%s' which does not exist."],
                                            seqname, classlibid, vname
                                        ),
                                        "Error"
                                    )
                                end
                            end
                        end
                        -- Check embedded sequence dependencies
                        if type(deps.Sequences) == "table" then
                            for _, depseq in ipairs(deps.Sequences) do
                                local found = false
                                for chkclass = 0, 13 do
                                    if GSE.Library[chkclass] and not GSE.isEmpty(GSE.Library[chkclass][depseq]) then
                                        found = true
                                        break
                                    end
                                    if GSESequences[chkclass] and not GSE.isEmpty(GSESequences[chkclass][depseq]) then
                                        found = true
                                        break
                                    end
                                end
                                if not found then
                                    totalIssues = totalIssues + 1
                                    GSE.Print(
                                        string.format(
                                            L["Sequence '%s' (class %d) embeds sequence '%s' which does not exist."],
                                            seqname, classlibid, depseq
                                        ),
                                        "Error"
                                    )
                                end
                            end
                        end
                        -- Check WoW macro dependencies (only if WoW API is available)
                        if type(deps.Macros) == "table" and GetMacroIndexByName then
                            for _, macname in ipairs(deps.Macros) do
                                local slot = GetMacroIndexByName(macname)
                                local inStore = not GSE.isEmpty(GSEMacros) and not GSE.isEmpty(GSEMacros[macname])
                                if (not slot or slot == 0) and not inStore then
                                    totalIssues = totalIssues + 1
                                    GSE.Print(
                                        string.format(
                                            L["Sequence '%s' (class %d) depends on macro '%s' which does not exist."],
                                            seqname, classlibid, macname
                                        ),
                                        "Error"
                                    )
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    -- 4. Variable dependency checks
    if not GSE.isEmpty(GSEVariables) then
        for vname, vdata in pairs(GSEVariables) do
            local ok, decoded = GSE.DecodeMessage(vdata)
            if ok and decoded and decoded.Dependencies and type(decoded.Dependencies.Variables) == "table" then
                for _, depname in ipairs(decoded.Dependencies.Variables) do
                    if GSE.isEmpty(GSEVariables[depname]) then
                        totalIssues = totalIssues + 1
                        GSE.Print(
                            string.format(
                                L["Variable '%s' depends on variable '%s' which does not exist."],
                                vname, depname
                            ),
                            "Error"
                        )
                    end
                end
            end
        end
    end

    -- 5. Icon coverage check: report Action blocks whose icon cannot be determined.
    --    These are cosmetic warnings (not functional errors) but help authors notice
    --    that a block will show '?' in the editor.  Pet-ability blocks are skipped
    --    because no client-side icon lookup is available for pet abilities.
    if C_Spell and C_Spell.GetSpellInfo then
        for classlibid = 0, 13 do
            local classlib = GSE.Library[classlibid]
            if classlib and type(classlib) == "table" then
                for seqname, seq in pairs(classlib) do
                    if type(seq) == "table" and type(seq.Versions) == "table" then
                        for vidx, macroversion in ipairs(seq.Versions) do
                            if type(macroversion) == "table" and type(macroversion.Actions) == "table" then
                                for bidx, action in ipairs(macroversion.Actions) do
                                    if action.Type == "Action" and action.type ~= "pet" and not action.Icon then
                                        local hasIcon = false
                                        if action.type == "spell" then
                                            local si = action.spell and C_Spell.GetSpellInfo(action.spell)
                                            hasIcon = si and si.iconID ~= nil
                                        elseif action.type == "item" or action.type == "toy" then
                                            local key = action.item or action.toy
                                            hasIcon = key and select(10, GetItemInfo(key)) ~= nil
                                        elseif action.type == "macro" or GSE.isEmpty(action.type) then
                                            local macro = action.macro and GSE.UnEscapeString(action.macro) or ""
                                            if string.sub(macro, 1, 1) == "/" then
                                                local ss = GSE.GetSpellsFromString(macro)
                                                hasIcon = ss ~= nil and (ss.iconID ~= nil or #ss > 0)
                                            elseif string.sub(macro, 1, 1) == "=" then
                                                -- Variable reference: check the compiled output
                                                local ok, compiled = pcall(GSE.CompileMacroText, macro, Statics.TranslatorMode.String)
                                                if ok and compiled then
                                                    compiled = GSE.UnEscapeString(compiled)
                                                    if string.sub(compiled, 1, 1) == "/" then
                                                        local ss = GSE.GetSpellsFromString(compiled)
                                                        hasIcon = ss ~= nil and (ss.iconID ~= nil or #ss > 0)
                                                    end
                                                end
                                            elseif macro ~= "" then
                                                -- External WoW macro name
                                                if GetMacroIndexByName then
                                                    local midx = GetMacroIndexByName(macro)
                                                    local _, micon = GetMacroInfo(midx)
                                                    hasIcon = micon ~= nil
                                                end
                                            end
                                        end
                                        if not hasIcon then
                                            totalIssues = totalIssues + 1
                                            GSE.Print(
                                                string.format(
                                                    L["Sequence '%s' (class %d) version %d block %d has no icon set (showing ?).  Open the block in the editor and assign an icon."],
                                                    seqname, classlibid, vidx, bidx
                                                )
                                            )
                                        end
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    -- 6. GSESequences encoding check (existing behaviour: remove malformed entries)
    if type(GSESequences) == "table" then
        for classlibid, classlib in ipairs(GSESequences) do
            if type(classlib) == "table" then
                for k, v in pairs(classlib) do
                    if type(v) == "string" and string.sub(v, 1, 6) ~= "!GSE3!" then
                        GSESequences[classlibid][k] = nil
                        GSE.Print(L["Removed unreadable sequence "] .. k, Statics.DebugModules["Storage"])
                    end
                end
            end
        end
    end

    if autoFixedCount > 0 then
        GSE.Print(string.format(
            L["Auto-repaired %d sequence(s) with structurally invalid Versions (index-0 keys remapped to 1-based)."],
            autoFixedCount))
    end
    if totalIssues == 0 then
        GSE.Print(L["Finished scanning for errors.  If no other messages then no errors were found."])
    else
        GSE.Print(string.format(L["%d issue(s) found.  See above for details and fix commands."], totalIssues))
    end

    -- Offer interactive dialogs for any sequences that could not be decoded at all.
    if not GSE.isEmpty(GSE.CorruptSequences) then
        GSE.ProcessCorruptSequences()
    end
end

--- Recursively replaces Java-style // comment lines with -- in all action macro fields.
local function applyJavaCommentFix(actionList)
    for _, action in pairs(actionList) do
        if type(action) == "table" then
            if type(action.macro) == "string" then
                local lines = {}
                for line in (action.macro .. "\n"):gmatch("([^\n]*)\n") do
                    lines[#lines + 1] = line:gsub("^(%s*)//", "%1--")
                end
                if lines[#lines] == "" then table.remove(lines) end
                action.macro = table.concat(lines, "\n")
            end
            -- Recurse into nested numeric sub-tables (Loop children, If branches)
            for k, v in pairs(action) do
                if type(k) == "number" and type(v) == "table" then
                    applyJavaCommentFix(v)
                end
            end
        end
    end
end

--- Repairs structural issues in a sequence in GSE.Library:
-- 1. Clears OOC queue entries for the sequence
-- 2. Migrates pre-#1853 Macros field to Versions
-- 3. Converts Java-style // comment lines to -- in all action macro text
-- 4. Re-indexes the Macros array to remove numeric gaps
-- 5. Re-indexes each Macro version's Actions array to remove gaps
-- 6. Updates MetaData context version references to match the new indices
-- 7. Saves the repaired sequence and queues a recompile
-- Usage: /run GSE.FixSequenceStructure(classLibraryID, "SequenceName")
-- silent: when true, suppress informational/success prints. Errors
-- (invalid class id, missing sequence, schema-incompatible) still print
-- because they signal the caller's request couldn't be honoured. Used
-- by GSE.ScanMacrosForErrors's auto-repair path so the user gets one
-- summary line rather than per-sequence chatter.
function GSE.FixSequenceStructure(classlibid, seqname, silent)
    classlibid = tonumber(classlibid)
    GSE.EnsureSequenceLoaded(classlibid, seqname)
    if not classlibid or not GSE.Library[classlibid] then
        GSE.Print(string.format(L["Invalid class library ID: %s"], tostring(classlibid)))
        return false
    end
    local seq = GSE.Library[classlibid][seqname]
    if GSE.isEmpty(seq) then
        GSE.Print(string.format(L["Sequence '%s' not found in class library %d."], seqname, classlibid))
        return false
    end

    -- 1. Remove any pending OOC queue entries for this sequence
    local kept = {}
    for _, entry in ipairs(GSE.OOCQueue) do
        local entryName = entry.sequencename or entry.name
        if entryName ~= seqname then
            table.insert(kept, entry)
        end
    end
    local removed = #GSE.OOCQueue - #kept
    GSE.OOCQueue = kept
    if removed > 0 and not silent then
        GSE.Print(string.format(L["Cleared %d pending queue entries for '%s'."], removed, seqname))
    end

    -- 2. Refuse to repair pre-#1853 records that still carry "Macros".
    -- The auto-rename is retired; user must re-export through gse.tools
    -- to get the current schema, then re-import.
    if seq.Macros ~= nil and seq.Versions == nil then
        GSE.Print(string.format(
            L["Sequence '%s' is incompatible with the current version of GSE. Upload it to https://gse.tools to update it to the current format, then re-import."],
            seqname), L["Import"])
        return false
    end

    -- 3. Replace Java-style // comment lines with -- in all action macro text
    for _, macver in pairs(seq.Versions or {}) do
        if type(macver) == "table" and type(macver.Actions) == "table" then
            applyJavaCommentFix(macver.Actions)
        end
    end

    -- 4. Re-index Macros array (compact gaps into a clean 1..n sequence).
    -- Includes k == 0 deliberately: a Versions table that starts at [0]
    -- (e.g. from a bad in-game merge or a CBOR round-trip that preserved
    -- 0-based keys) is invisible to ipairs and reads as "no versions" in
    -- the editor and runtime. The previous filter `k >= 1` would have
    -- silently DROPPED such entries; mapping 0→1 instead recovers them.
    if type(seq.Versions) == "table" then
        local oldMacroKeys = {}
        for k in pairs(seq.Versions) do
            if type(k) == "number" and k >= 0 then
                table.insert(oldMacroKeys, k)
            end
        end
        table.sort(oldMacroKeys)

        -- Build old→new index mapping and compacted Macros table
        local macroIndexMap = {}
        local newMacros = {}
        for newIdx, oldIdx in ipairs(oldMacroKeys) do
            macroIndexMap[oldIdx] = newIdx
            newMacros[newIdx] = seq.Versions[oldIdx]
        end
        seq.Versions = newMacros

        -- 5. Re-index Actions arrays within each Macro version
        for _, macroversion in ipairs(seq.Versions) do
            if type(macroversion) == "table" and type(macroversion.Actions) == "table" then
                local oldActKeys = {}
                for k in pairs(macroversion.Actions) do
                    if type(k) == "number" and k >= 1 then
                        table.insert(oldActKeys, k)
                    end
                end
                table.sort(oldActKeys)

                local newActions = {}
                for newIdx, oldIdx in ipairs(oldActKeys) do
                    newActions[newIdx] = macroversion.Actions[oldIdx]
                end
                macroversion.Actions = newActions
            end
        end

        -- 6. Update MetaData context version references using the old→new mapping
        local maxNewIdx = #seq.Versions
        for _, ctxKey in ipairs(seqContextKeys) do
            local val = seq.MetaData[ctxKey]
            if not GSE.isEmpty(val) then
                local oldIdx = tonumber(val)
                if oldIdx then
                    if macroIndexMap[oldIdx] then
                        seq.MetaData[ctxKey] = macroIndexMap[oldIdx]
                    else
                        -- Was pointing to a gap or beyond the end; clamp to max valid index
                        local clamped = maxNewIdx > 0 and maxNewIdx or nil
                        if not silent then
                            GSE.Print(string.format(
                                L["MetaData.%s remapped from non-existent version %d to %d."],
                                ctxKey, oldIdx, clamped or 0
                            ))
                        end
                        seq.MetaData[ctxKey] = clamped
                    end
                end
            end
        end
    end

    -- 7. Save repaired sequence and trigger recompile
    GSE.ReplaceSequence(classlibid, seqname, seq)
    if classlibid == GSE.GetCurrentClassID() or classlibid == 0 then
        GSE.ReloadSequences()
        if not silent then
            GSE.Print(string.format(
                L["'%s' has been repaired and queued for recompile.  Leave combat or /reload to apply."],
                seqname
            ))
        end
    else
        if not silent then
            GSE.Print(string.format(
                L["'%s' repaired. Sequence is for class %d; button will update when that class is played."],
                seqname, classlibid
            ))
        end
    end
    return true
end

--- This creates a pretty export for WLM Forums
function GSE.ExportSequenceHumanReadableFormat(sequence, sequencename)
    local returnstring =
        "# " ..
        sequencename ..
            "\n\n## Talents: " ..
                (GSE.isEmpty(sequence["MetaData"].Talents) and "?,?,?,?,?,?,?" or GSE.Dump(sequence["MetaData"].Talents)) ..
                    "\n\n"
    if not GSE.isEmpty(sequence["MetaData"].Help) then
        returnstring = "\n\n## Usage Information\n" .. sequence["MetaData"].Help .. "\n\n"
    end
    returnstring =
        returnstring ..
        "This macro contains " ..
            (#sequence.Versions > 1 and #sequence.Versions .. " macro templates. " or "1 macro template. ") ..
                string.format(L["This Sequence was exported from GSE %s."], GSE.VersionString) .. "\n\n"
    if (#sequence.Versions > 1) then
        for k, _ in pairs(sequence.Versions) do
            if not GSE.isEmpty(sequence["MetaData"].Default) then
                if sequence["MetaData"].Default == k then
                    returnstring = returnstring .. "- The Default macro template is " .. k .. "\n"
                end
            end
            if not GSE.isEmpty(sequence["MetaData"].Raid) then
                if sequence["MetaData"].Raid == k then
                    returnstring = returnstring .. "- Raids use template " .. k .. "\n"
                end
            end
            if not GSE.isEmpty(sequence["MetaData"].PVP) then
                if sequence["MetaData"].PVP == k then
                    returnstring = returnstring .. "- PVP uses template " .. k .. "\n"
                end
            end
            if not GSE.isEmpty(sequence["MetaData"].Dungeon) then
                if sequence["MetaData"].Dungeon == k then
                    returnstring = returnstring .. "- Normal Dungeons use template " .. k .. "\n"
                end
            end
            if not GSE.isEmpty(sequence["MetaData"].Heroic) then
                if sequence["MetaData"].Heroic == k then
                    returnstring = returnstring .. "- Heroic Dungeons use template " .. k .. "\n"
                end
            end
            if not GSE.isEmpty(sequence["MetaData"].Mythic) then
                if sequence["MetaData"].Mythic == k then
                    returnstring = returnstring .. "- Mythic Dungeons use template " .. k .. "\n"
                end
            end
            if not GSE.isEmpty(sequence["MetaData"].Arena) then
                if sequence["MetaData"].Arena == k then
                    returnstring = returnstring .. "- Arenas use template " .. k .. "\n"
                end
            end
            if not GSE.isEmpty(sequence["MetaData"].Timewalking) then
                if sequence["MetaData"].Timewalking == k then
                    returnstring = returnstring .. "- Timewalking Dungeons use template " .. k .. "\n"
                end
            end
            if not GSE.isEmpty(sequence["MetaData"].MythicPlus) then
                if sequence["MetaData"].MythicPlus == k then
                    returnstring = returnstring .. "- Mythic+ Dungeons use template " .. k .. "\n"
                end
            end
            if not GSE.isEmpty(sequence["MetaData"].Party) then
                if sequence["MetaData"].Party == k then
                    returnstring = returnstring .. "- Open World Parties use template " .. k .. "\n"
                end
            end
            if not GSE.isEmpty(sequence["MetaData"].Scenario) then
                if sequence["MetaData"].Scenario == k then
                    returnstring = returnstring .. "- Delves and Scenarios use template " .. k .. "\n"
                end
            end
        end
    end

    return returnstring
end

--- Creates a string representation of the a Sequence that can be shared as a string.
--      Accepts a <code>sequence table</code> and a <code>SequenceName</code>
function GSE.ExportSequence(sequence, sequenceName, verbose)
    local returnVal
    if verbose then
        GSE.PrintDebugMessage("ExportSequence Sequence Name: " .. sequenceName, "Storage")
        returnVal = GSE.Dump(GSE.UnEscapeTable(GSE.TranslateSequence(sequence, Statics.TranslatorMode.Current))) .. "\n"
    else
        returnVal =
            GSE.EncodeMessage(
            {sequenceName, GSE.UnEscapeTable(GSE.TranslateSequence(sequence, Statics.TranslatorMode.ID))}
        )
    end

    return returnVal
end

function GSE.PrintGnomeHelp()
    GSE.Print(L["GnomeSequencer was originally written by semlar of wowinterface.com."], GNOME)
    GSE.Print(
        L[
            "GSE is a complete rewrite of that addon that allows you create a sequence of macros to be executed at the push of a button."
        ],
        GNOME
    )
    GSE.Print(
        L[
            "Like a /castsequence macro, it cycles through a series of commands when the button is pushed. However, unlike castsequence, it uses macro text for the commands instead of spells, and it advances every time the button is pushed instead of stopping when it can't cast something."
        ],
        GNOME
    )
    GSE.Print(
        L[
            "This version has been modified by TimothyLuke to make the power of GnomeSequencer avaialble to people who are not comfortable with lua programming."
        ],
        GNOME
    )
    GSE.Print(
        L["To get started "] ..
            GSEOptions.CommandColour ..
                L[
                    "/gse|r will list any macros available to your spec.  This will also add any macros available for your current spec to the macro interface."
                ],
        GNOME
    )
    GSE.Print(
        L["The command "] ..
            GSEOptions.CommandColour ..
                L[
                    "/gse showspec|r will show your current Specialisation and the SPECID needed to tag any existing macros."
                ],
        GNOME
    )
    GSE.Print(
        L["The command "] ..
            GSEOptions.CommandColour ..
                L[
                    "/gse cleanorphans|r will loop through your macros and delete any left over GSE macros that no longer have a sequence to match them."
                ],
        GNOME
    )
    GSE.Print(
        L["The command "] ..
            GSEOptions.CommandColour ..
                L[
                    "/gse checksequencesforerrors|r will loop through your macros and check for corrupt macro versions.  This will then show how to correct these issues."
                ],
        GNOME
    )
    GSE.Print(
        L["The command "] ..
            GSEOptions.CommandColour ..
                L[
                    "/gse clearincoming|r will abort any pending GSE Companion updates without importing them, and tell the Companion to prune them."
                ],
        GNOME
    )
end

GSE:RegisterChatCommand("gse", "GSSlash")

-- Functions

--- This function finds a macro by name.  It checks current class first then global
function GSE.FindSequence(sequenceName)
    local returnVal
    if not GSE.isEmpty(GSE.Library[GSE.GetCurrentClassID()][sequenceName]) then
        returnVal = GSE.Library[GSE.GetCurrentClassID()][sequenceName]
    elseif not GSE.isEmpty(GSE.Library[0][sequenceName]) then
        returnVal = GSE.Library[0][sequenceName]
    end
    if GSE.isEmpty(returnVal) then
        -- Thius is a sequence for another class
        for i = 1, 14, 1 do
            if GSESequences[i] and GSESequences[i][sequenceName] then
                local localsuccess, uncompressedVersion = GSE.DecodeMessage(GSESequences[i][sequenceName])
                if localsuccess then
                    returnVal = uncompressedVersion
                end
            end
        end
    end
    return returnVal
end

--- Handle slash commands
function GSE:GSSlash(input)
    local _, _, currentclassId = UnitClass("player")
    local params = GSE.split(input, " ")
    if #params > 1 then
        input = params[1]
    end
    local command = string.lower(input)
    if command == "showspec" then
        if GSE.GameMode < 7 then
            GSE.Print(L["Your ClassID is "] .. currentclassId .. " " .. Statics.SpecIDList[currentclassId], GNOME)
        else
            local currentSpec = GetSpecialization()
            local currentSpecID = currentSpec and select(1, GetSpecializationInfo(currentSpec)) or "None"
            local _, specname, _, _, _, _, _ = GetSpecializationInfoByID(currentSpecID)
            GSE.Print(
                L["Your current Specialisation is "] ..
                    currentSpecID .. ":" .. specname .. L["  The Alternative ClassID is "] .. currentclassId,
                GNOME
            )
        end
    elseif command == "help" then
        GSE.PrintGnomeHelp()
    elseif command == "cleanorphans" or command == "clean" then
        GSE.CleanOrphanSequences()
    elseif command == "forceclean" then
        GSE.CleanOrphanSequences()
        GSE.CleanMacroLibrary(true)
        if not InCombatLockdown() then
            if not GSE.isEmpty(GSE_C["KeyBindings"]) then
                for _, specData in pairs(GSE_C["KeyBindings"]) do
                    for key, _ in pairs(specData) do
                        if key ~= "LoadOuts" then SetBinding(key) end
                    end
                    if not GSE.isEmpty(specData["LoadOuts"]) then
                        for _, loadoutData in pairs(specData["LoadOuts"]) do
                            for key, _ in pairs(loadoutData) do SetBinding(key) end
                        end
                    end
                end
            end
            GSE_C["KeyBindings"] = {}
            GSE_C["ActionBarBinds"] = {}
            GSE.ReloadOverrides()
        end
    elseif command == "export" then
        if GSE.Patron then
            GSE.CheckGUI()
            if GSE.UnsavedOptions["GUI"] and GSE.GUIAdvancedExport then
                GSE.GUIAdvancedExport(GSE.GUIExportframe)
                GSE.GUIExportframe:Show()
            end
        end
    elseif command == "showdebugoutput" then
        StaticPopup_Show("GS-DebugOutput")
    elseif command == "record" then
        GSE.CheckGUI()
        if GSE.UnsavedOptions["GUI"] then
            GSE.GUIRecordFrame:Show()
        end
    elseif command == "debug" then
        GSE.CheckGUI()
        if GSE.UnsavedOptions["GUI"] then
            GSE.GUIShowDebugWindow()
        end
    elseif command == "variables" then
        GSE.CheckGUI()
        if GSE.UnsavedOptions["GUI"] then
            GSE.ShowVariables()
        end
    elseif command == "resetoptions" then
        GSE.SetDefaultOptions()
        GSE.Print(L["Options have been reset to defaults."])
        StaticPopup_Show("GSE_ConfirmReloadUIDialog")
    elseif command == "movelostmacros" then
        GSE.MoveMacroToClassFromGlobal()
    elseif command == "checksequencesforerrors" then
        GSE.ScanMacrosForErrors()
    elseif command == "compressstring" then
        GSE.CheckGUI()
        if GSE.UnsavedOptions["GUI"] then
            GSE.GUICompressFrame:Show()
        end
    elseif command == "recompilesequences" then
        GSE.ReloadSequences()
    elseif string.lower(command) == "clearoocqueue" then
        GSE.OOCQueue = {}
    elseif string.lower(command) == "clearincoming" then
        local pending = GSE.IncomingQueue or {}
        local count = #pending
        if GSE.CompanionMarkImported then
            for _, item in ipairs(pending) do
                GSE.CompanionMarkImported(item)
            end
        end
        GSE.IncomingQueue = {}
        if GSE.GUIImportFrame then GSE.GUIImportFrame:Hide() end
        GSE.Print(
            "|cff00ccffGSE Companion:|r Cleared " .. count ..
            " pending update(s) from the incoming queue."
        )
    elseif string.lower(command) == "import" then
        GSE.CheckGUI()
        if GSE.UnsavedOptions["GUI"] then
            GSE.ShowImport()
        end
    elseif string.lower(command) == "bind" then
        -- /gse bind spec sequence key
        local spec = tostring(params[2])
        local sequence = tostring(params[3])
        local physicalkey = tostring(params[4])
        if spec and sequence and physicalkey then
            GSE_C["KeyBindings"][tostring(spec)][physicalkey] = sequence
            GSE.ReloadKeyBindings()
        else
           GSE.Print("Invalid Bind - /gse bind spec sequence key")
        end
    else
        GSE.CheckGUI()
        if GSE.UnsavedOptions["GUI"] then
            GSE.ShowMenu()
        end
    end
end

local colorTable = {}

local tokens = IndentationLib.tokens

colorTable[tokens.TOKEN_SPECIAL] = GSEOptions.WOWSHORTCUTS
colorTable[tokens.TOKEN_KEYWORD] = GSEOptions.KEYWORD
colorTable[tokens.TOKEN_UNKNOWN] = GSEOptions.UNKNOWN
colorTable[tokens.TOKEN_COMMENT_SHORT] = GSEOptions.COMMENT
colorTable[tokens.TOKEN_COMMENT_LONG] = GSEOptions.COMMENT

local stringColor = GSEOptions.NormalColour
colorTable[tokens.TOKEN_STRING] = stringColor
colorTable[".."] = stringColor

local tableColor = GSEOptions.CONCAT
colorTable["..."] = tableColor
colorTable["{"] = tableColor
colorTable["}"] = tableColor
colorTable["["] = GSEOptions.STRING
colorTable["]"] = GSEOptions.STRING

local arithmeticColor = GSEOptions.NUMBER
colorTable[tokens.TOKEN_NUMBER] = arithmeticColor
colorTable["+"] = arithmeticColor
colorTable["-"] = arithmeticColor
colorTable["/"] = arithmeticColor
colorTable["*"] = arithmeticColor

local logicColor1 = GSEOptions.EQUALS
colorTable["=="] = logicColor1
colorTable["<"] = logicColor1
colorTable["<="] = logicColor1
colorTable[">"] = logicColor1
colorTable[">="] = logicColor1
colorTable["~="] = logicColor1

local logicColor2 = GSEOptions.EQUALS
colorTable["and"] = logicColor2
colorTable["or"] = logicColor2
colorTable["not"] = logicColor2

local castColor = GSEOptions.UNKNOWN
colorTable["/cast"] = castColor

colorTable[0] = "|r"

Statics.IndentationColorTable = colorTable

do
    -- Shared handler: right-click on a GSE-overridden button shows change/clear options;
    -- right-click on an empty action button shows the sequence picker to assign one.
    local function gseEmptyButtonHandler(self, mousebutton, down)
        if not GSEOptions.actionBarOverridePopup then return end
        if InCombatLockdown() then return end
        if not down then return end
        if mousebutton ~= "RightButton" then return end

        local existingSequence = self:GetAttribute("gse-button")

        if not existingSequence then
            -- Only show the picker on genuinely empty slots.
            -- CPB_ (ConsolePort) buttons are controller-mapped, not slot-based; skip the action check.
            if string.sub(self:GetName() or "", 1, 4) ~= "CPB_" then
                local action = self.action or self:GetAttribute("action")
                if not action or action == 0 then return end
                if HasAction(action) then return end
            end
        end

        local classIconText = ""
        local classInfo = C_CreatureInfo and C_CreatureInfo.GetClassInfo(GSE.GetCurrentClassID())
        if classInfo and classInfo.classFile then
            classIconText = "|A:classicon-" .. classInfo.classFile:lower() .. ":16:16|a "
        end

        local names = {}
        local function addSequences(classID)
            for k, seq in pairs(GSE.Library[classID] or {}) do
                local specID = seq and seq.MetaData and seq.MetaData.SpecID
                local disabled = seq and seq.MetaData and seq.MetaData.Disabled
                table.insert(names, { name = k, specID = specID, disabled = disabled })
            end
        end
        addSequences(GSE.GetCurrentClassID())
        addSequences(0)

        table.sort(names, function(a, b) return a.name < b.name end)

        local buttonName = self:GetName()
        MenuUtil.CreateContextMenu(self, function(ownerRegion, rootDescription)
            if existingSequence then
                rootDescription:CreateTitle(L["GSE"] .. ": " .. existingSequence)
                rootDescription:CreateButton(L["Clear Override"], function()
                    GSE.RemoveActionBarOverride(buttonName)
                end)
                if #names > 0 then
                    rootDescription:CreateDivider()
                    rootDescription:CreateTitle(L["Change Sequence"])
                end
            else
                rootDescription:CreateTitle(L["Assign GSE Sequence"])
            end
            for _, entry in ipairs(names) do
                local iconText = classIconText
                local specID = entry.specID
                if specID and specID >= 15 and GetSpecializationInfoByID then
                    local _, _, _, specIconID = GetSpecializationInfoByID(specID)
                    if specIconID then
                        iconText = "|T" .. specIconID .. ":16:16|t "
                    end
                end
                local label = iconText .. entry.name
                if entry.disabled then
                    local element = rootDescription:CreateButton("|cFF808080" .. label .. "|r", function() end)
                    element:SetTooltip(function(tooltip, elementDescription)
                        GameTooltip_SetTitle(tooltip, L["Sequence Disabled"])
                    end)
                else
                    rootDescription:CreateButton(label, function()
                        GSE.CreateActionBarOverride(buttonName, entry.name)
                    end)
                end
            end
        end)
    end

    -- Standard Blizzard action bars hook via the global function
    --
    -- Hook the method on the ActionButton metatable instead
    local buttonPrefixes = {
        "ActionButton",
        "MultiBarBottomLeftButton",
        "MultiBarBottomRightButton",
        "MultiBar5Button",
        "MultiBar6Button",
        "MultiBar7Button",
        "MultiBarRightButton",
        "MultiBarLeftButton",
    }
    for _, prefix in ipairs(buttonPrefixes) do
        for i = 1, 12 do
            local button = _G[prefix..i]
            if button then
                button:HookScript("OnClick", gseEmptyButtonHandler)
            end
        end
    end

    -- Third-party action bar addons use their own OnClick handlers, so we install
    -- HookScript directly on each button after PLAYER_ENTERING_WORLD, by which
    -- point all addon frames are guaranteed to exist.
    if not CreateFrame then return end
    local gseBarHookFrame = CreateFrame("Frame")
    gseBarHookFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
    gseBarHookFrame:SetScript("OnEvent", function(self)
        self:UnregisterEvent("PLAYER_ENTERING_WORLD")

        if Bartender4 then
            for i = 1, 180 do
                local btn = _G["BT4Button" .. i]
                if btn then btn:HookScript("OnClick", gseEmptyButtonHandler) end
            end
        end

        if ElvUI then
            for bar = 1, 15 do
                for slot = 1, 12 do
                    local btn = _G["ElvUI_Bar" .. bar .. "Button" .. slot]
                    if btn then btn:HookScript("OnClick", gseEmptyButtonHandler) end
                end
            end
        end

        if NDui then
            for bar = 1, 15 do
                for slot = 1, 12 do
                    local btn = _G["NDui_ActionBar" .. bar .. "Button" .. slot]
                    if btn then btn:HookScript("OnClick", gseEmptyButtonHandler) end
                end
            end
        end

        if Dominos then
            -- IDs 1-24 and 73-132 are Dominos-owned frames; the rest reuse Blizzard names
            -- already covered by the buttonPrefixes loop above.
            for i = 1, 24 do
                local btn = _G["DominosActionButton" .. i]
                if btn then btn:HookScript("OnClick", gseEmptyButtonHandler) end
            end
            for i = 73, 132 do
                local btn = _G["DominosActionButton" .. i]
                if btn then btn:HookScript("OnClick", gseEmptyButtonHandler) end
            end
        end

        if ConsolePort then
            local cpbButtons = {
                "CPB_PADDUP", "CPB_PADDLEFT", "CPB_PADDDOWN", "CPB_PADDRIGHT",
                "CPB_PADLSHOULDER", "CPB_PADRSHOULDER",
                "CPB_PADRTRIGGER", "CPB_PADLTRIGGER",
                "CPB_PAD1", "CPB_PAD2", "CPB_PAD3", "CPB_PAD4",
            }
            for _, name in ipairs(cpbButtons) do
                local btn = _G[name]
                if btn then btn:HookScript("OnClick", gseEmptyButtonHandler) end
            end
        end
    end)
end

GSE.Utils = true
