-- Spammer.lua

local addonName = ...
local addonTable = select(2, ...)

local AceGUI = LibStub("AceGUI-3.0")

------------------------------------------------------------
-- STATE / REFS / CONSTANTS
------------------------------------------------------------

local STATE = {
    isSpamming = false,
    selectedPresetName = nil,
    saveFeedbackResetAt = 0,
    uiUpdateAccumulator = 0,
}

local UI = {
    msgEditBox = nil,
    charCountText = nil,
    startBtn = nil,
    msgLabel = nil,

    presetDropdown = nil,
    renamePresetBtn = nil,
    deletePresetBtn = nil,
    savePresetBtn = nil,

    channelRows = {},
}

local CONST = {
    CREATE_NEW_PRESET_LABEL = "Create New",
    CREATE_NEW_PRESET_BASE_NAME = "New Message",
    DEFAULT_PRESET_NAME = "RealNibba Default",
    DEFAULT_PRESET_MESSAGE = "Looking for BiS guides, WeakAuras & UI setups for Warmane? Join RealNibba's community! YouTube: https://www.youtube.com/@RealNibbaWarmane | Discord: https://discord.com/invite/uZTTqejway",
    PRESET_NAME_MAX_LENGTH = 21,
    MAX_MESSAGE_LENGTH = 255,
    UI_UPDATE_INTERVAL = 0.1,
    DEFAULT_INTERVALS = {
        ["General"] = "31",
        ["Trade"] = "23",
        ["World"] = "61",
        ["YELL"] = "41",
    },
    DEFAULT_ENABLED_CHANNELS = {
        ["General"] = true,
        ["Trade"] = true,
        ["World"] = true,
        ["YELL"] = true,
    },
    CHANNEL_POOL = {
        { name = "General", chatType = "CHANNEL" },
        { name = "Trade", chatType = "CHANNEL" },
        { name = "World", chatType = "CHANNEL" },
        { name = "YELL", chatType = "YELL" },
    },
}

CONST.DEFAULT_PRESET_READONLY_TEXT = CONST.DEFAULT_PRESET_MESSAGE .. "\n(Default preset is read-only)"
CONST.SPAMMER_PRESET_DELETE_POPUP = addonName .. "SpammerPresetDeletePopup"
CONST.SPAMMER_PRESET_RENAME_POPUP = addonName .. "SpammerPresetRenamePopup"

local eventFrame = CreateFrame("Frame")
local loader = CreateFrame("Frame")

local GetSelectedPresetName

------------------------------------------------------------
-- PUBLIC STATUS HELPERS
------------------------------------------------------------

function RT_IsSpammerEnabled()
    return STATE.isSpamming
end

------------------------------------------------------------
-- TEXT / TABLE HELPERS
------------------------------------------------------------

local function TrimText(text)
    local result = text or ""
    result = string.gsub(result, "^%s+", "")
    result = string.gsub(result, "%s+$", "")
    return result
end

local function LimitTextLength(text, maxLength)
    local result = text or ""

    if string.len(result) > maxLength then
        result = string.sub(result, 1, maxLength)
    end

    return result
end

local function CapitalizeWords(text)
    local result = tostring(text or "")
    result = string.lower(result)
    result = string.gsub(result, "(%a)([%w']*)", function(firstChar, restChars)
        return string.upper(firstChar) .. restChars
    end)
    return result
end

local function NormalizeChannelName(channelName)
    local result = string.lower(channelName or "")
    result = string.gsub(result, "%s+", "")
    return result
end

local function CopyIntervals(source)
    local result = {}

    for channelName, defaultValue in pairs(CONST.DEFAULT_INTERVALS) do
        local value = defaultValue

        if type(source) == "table" and source[channelName] ~= nil then
            value = tostring(source[channelName])
        end

        result[channelName] = value
    end

    return result
end

local function CopyEnabledChannels(source)
    local result = {}

    for channelName, defaultValue in pairs(CONST.DEFAULT_ENABLED_CHANNELS) do
        local value = defaultValue

        if type(source) == "table" and source[channelName] ~= nil then
            value = source[channelName] and true or false
        end

        result[channelName] = value
    end

    return result
end

local function CopyPresetData(source)
    local result = {
        message = "",
        intervals = CopyIntervals(nil),
        enabledChannels = CopyEnabledChannels(nil),
        order = nil,
        isDefault = false,
        isReadOnly = false,
    }

    if type(source) == "table" then
        result.message = tostring(source.message or "")
        result.intervals = CopyIntervals(source.intervals)
        result.enabledChannels = CopyEnabledChannels(source.enabledChannels)

        if type(source.order) == "number" then
            result.order = source.order
        end

        if source.isDefault == true then
            result.isDefault = true
        end

        if source.isReadOnly == true then
            result.isReadOnly = true
        end
    end

    return result
end

local function BuildDefaultPresetData(source)
    local result = {
        message = CONST.DEFAULT_PRESET_MESSAGE,
        intervals = CopyIntervals(nil),
        enabledChannels = CopyEnabledChannels(nil),
        order = -1,
        isDefault = true,
        isReadOnly = true,
    }

    if type(source) == "table" then
        result.intervals = CopyIntervals(source.intervals)
        result.enabledChannels = CopyEnabledChannels(source.enabledChannels)
    end

    return result
end

------------------------------------------------------------
-- SAVED VARIABLE HELPERS
------------------------------------------------------------

local function GetCharacterKey()
    local characterName = UnitName("player") or "Unknown"
    local realmName = GetRealmName() or "UnknownRealm"
    return tostring(realmName) .. " - " .. tostring(characterName)
end

local function EnsureRootSavedVariables()
    if type(RTSpammerSave) ~= "table" then
        RTSpammerSave = {}
    end

    if type(RTSpammerSave.presets) ~= "table" then
        RTSpammerSave.presets = {}
    end

    if type(RTSpammerSave.characterSettings) ~= "table" then
        RTSpammerSave.characterSettings = {}
    end

    if type(RTSpammerSave.presetOrderCounter) ~= "number" then
        RTSpammerSave.presetOrderCounter = 0
    end
end

local function EnsureCharacterSettings()
    local characterKey = GetCharacterKey()

    if type(RTSpammerSave.characterSettings[characterKey]) ~= "table" then
        RTSpammerSave.characterSettings[characterKey] = {}
    end

    if type(RTSpammerSave.characterSettings[characterKey].activePresetName) ~= "string" then
        RTSpammerSave.characterSettings[characterKey].activePresetName = nil
    end
end

local function NormalizePresets()
    local migratedPresets = {}
    local existingDefaultPreset = nil

    if type(RTSpammerSave.presets[CONST.DEFAULT_PRESET_NAME]) == "table" then
        existingDefaultPreset = RTSpammerSave.presets[CONST.DEFAULT_PRESET_NAME]
    elseif type(RTSpammerSave.current) == "table" then
        existingDefaultPreset = {
            intervals = RTSpammerSave.current.intervals,
            enabledChannels = RTSpammerSave.current.enabledChannels,
        }
    end

    for presetName, presetData in pairs(RTSpammerSave.presets) do
        if type(presetName) == "string" and presetName ~= "" and presetName ~= CONST.DEFAULT_PRESET_NAME then
            local normalizedPresetData = CopyPresetData(presetData)

            normalizedPresetData.isDefault = false
            normalizedPresetData.isReadOnly = false

            if type(normalizedPresetData.order) ~= "number" then
                RTSpammerSave.presetOrderCounter = RTSpammerSave.presetOrderCounter + 1
                normalizedPresetData.order = RTSpammerSave.presetOrderCounter
            end

            if type(presetData) == "table" and type(presetData.intervals) ~= "table" and type(RTSpammerSave.current) == "table" then
                normalizedPresetData.intervals = CopyIntervals(RTSpammerSave.current.intervals)
                normalizedPresetData.enabledChannels = CopyEnabledChannels(RTSpammerSave.current.enabledChannels)
            end

            migratedPresets[presetName] = normalizedPresetData
        end
    end

    migratedPresets[CONST.DEFAULT_PRESET_NAME] = BuildDefaultPresetData(existingDefaultPreset)
    RTSpammerSave.presets = migratedPresets
    RTSpammerSave.current = nil
end

local function EnsureDefaultActivePreset()
    local characterKey = GetCharacterKey()
    local characterData = RTSpammerSave.characterSettings[characterKey]
    local activePresetName = characterData.activePresetName

    if activePresetName == nil
        and type(RTSpammerSave.activePresetName) == "string"
        and RTSpammerSave.activePresetName ~= ""
        and RTSpammerSave.presets[RTSpammerSave.activePresetName] then
        characterData.activePresetName = RTSpammerSave.activePresetName
        activePresetName = characterData.activePresetName
    end

    if type(activePresetName) ~= "string"
        or activePresetName == ""
        or not RTSpammerSave.presets[activePresetName] then
        characterData.activePresetName = CONST.DEFAULT_PRESET_NAME
    end

    RTSpammerSave.activePresetName = nil
end

local function EnsureSavedVariables()
    EnsureRootSavedVariables()
    EnsureCharacterSettings()
    NormalizePresets()
    EnsureDefaultActivePreset()
end

local function GetPresetData(presetName)
    EnsureSavedVariables()

    if type(presetName) ~= "string" or presetName == "" then
        return nil
    end

    return RTSpammerSave.presets[presetName]
end

local function IsDefaultPreset(presetName)
    local presetData = GetPresetData(presetName)
    return presetData ~= nil and presetData.isDefault == true
end

local function IsPresetReadOnly(presetName)
    local presetData = GetPresetData(presetName)
    return presetData ~= nil and presetData.isReadOnly == true
end

local function CanRenamePreset(presetName)
    if type(presetName) ~= "string" or presetName == "" or presetName == CONST.CREATE_NEW_PRESET_LABEL then
        return false
    end

    return GetPresetData(presetName) ~= nil and not IsPresetReadOnly(presetName)
end

local function CanDeletePreset(presetName)
    if type(presetName) ~= "string" or presetName == "" or presetName == CONST.CREATE_NEW_PRESET_LABEL then
        return false
    end

    return GetPresetData(presetName) ~= nil and not IsDefaultPreset(presetName)
end

local function CanSavePreset(presetName)
    if type(presetName) ~= "string" or presetName == "" or presetName == CONST.CREATE_NEW_PRESET_LABEL then
        return false
    end

    return GetPresetData(presetName) ~= nil and not IsPresetReadOnly(presetName)
end

local function GetCharacterActivePresetName()
    EnsureSavedVariables()

    local characterKey = GetCharacterKey()
    local presetName = RTSpammerSave.characterSettings[characterKey].activePresetName

    if type(presetName) ~= "string"
        or presetName == ""
        or not RTSpammerSave.presets[presetName] then
        return CONST.DEFAULT_PRESET_NAME
    end

    return presetName
end

local function SetCharacterActivePresetName(presetName)
    EnsureSavedVariables()

    local characterKey = GetCharacterKey()

    if type(presetName) == "string" and presetName ~= "" and RTSpammerSave.presets[presetName] then
        RTSpammerSave.characterSettings[characterKey].activePresetName = presetName
    else
        RTSpammerSave.characterSettings[characterKey].activePresetName = CONST.DEFAULT_PRESET_NAME
    end
end

------------------------------------------------------------
-- SPAM STATE HELPERS
------------------------------------------------------------

local function SetCooldownText(row, text)
    if row and row.cdWidget then
        if row.lastCooldownText ~= text then
            row.cdWidget:SetText(text)
            row.lastCooldownText = text
        end
    end
end

local function StopSpamming(resetCooldowns)
    STATE.isSpamming = false

    if RefreshStatusOverlay then
        RefreshStatusOverlay()
    end

    if UI.startBtn then
        UI.startBtn:SetText("Start Spamming")
    end

    if resetCooldowns then
        for _, row in ipairs(UI.channelRows) do
            row.nextSend = 0
            SetCooldownText(row, "|cff888888Ready|r")
        end
    end
end

local function GetSavedMessageForCurrentSelection()
    local presetName = GetSelectedPresetName()
    local presetData = GetPresetData(presetName)

    if not presetData then
        return ""
    end

    if IsPresetReadOnly(presetName) then
        return CONST.DEFAULT_PRESET_READONLY_TEXT
    end

    return presetData.message or ""
end

-- Escape stray "|" characters so they don't get misread as chat escape
-- codes (e.g. "|c", "|H", "|r"), which causes SendChatMessage to error
-- with "Invalid escape code in chat message". Valid escape sequences
-- (colors, links, textures, etc.) are left untouched.
local function SanitizeChatMessage(text)
    if not text or text == "" then
        return text
    end

    text = text:gsub("|([^cCrRhHtTnNaAkK|])", "||%1")
    text = text:gsub("|$", "||")

    return text
end

-- Strip |Hachievement:...|h[NAME]|h links to plain ASCII [Name].
-- Used when SendChatMessage rejects the achievement hyperlink (Warmane rejects
-- color codes in plain text, so the only safe fallback is bare ASCII).
local function StripAchievementLinks(text)
    return (text:gsub("|cffffff00|Hachievement:[^|]+|h%[([^%]]+)%]|h|r", "[%1]"))
end

-- pcall-wrapped SendChatMessage. If the message is rejected (e.g. due to an
-- unearned achievement link that Warmane's client won't validate), strips the
-- hyperlink and retries with plain colored text so the message always sends.
local function SafeSendChatMessage(text, chatType, language, channelId)
    local ok
    if chatType == "CHANNEL" then
        ok = pcall(SendChatMessage, text, chatType, language, channelId)
    else
        ok = pcall(SendChatMessage, text, chatType)
    end
    if not ok then
        local stripped = StripAchievementLinks(text)
        if stripped ~= text then
            if chatType == "CHANNEL" then
                pcall(SendChatMessage, stripped, chatType, language, channelId)
            else
                pcall(SendChatMessage, stripped, chatType)
            end
        end
    end
end

local function GetMessageToSend()
    local presetName = GetCharacterActivePresetName()
    local presetData = GetPresetData(presetName)

    if not presetData then
        return ""
    end

    return SanitizeChatMessage(presetData.message or "")
end

------------------------------------------------------------
-- LINK HELPERS
------------------------------------------------------------

local isLinkHookInstalled = false
local originalChatEdit_InsertLink = nil
local lastActiveLinkTarget = nil

local function ClearMessageBoxAsLinkTarget()
    if lastActiveLinkTarget == UI.msgEditBox then
        lastActiveLinkTarget = nil
    end
end

local function SetMessageBoxAsLinkTarget()
    local presetName = GetSelectedPresetName()

    if not UI.msgEditBox or IsPresetReadOnly(presetName) then
        lastActiveLinkTarget = nil
        return
    end

    lastActiveLinkTarget = UI.msgEditBox
end

local function IsMessageBoxActive()
    if not UI.msgEditBox then
        return false
    end

    if not UI.msgEditBox:IsShown() then
        return false
    end

    return lastActiveLinkTarget == UI.msgEditBox or UI.msgEditBox:HasFocus()
end

local function InsertLinkIntoMessageBox(link)
    local presetName = GetSelectedPresetName()

    if IsPresetReadOnly(presetName) or not link or link == "" then
        return false
    end

    if not UI.msgEditBox or not UI.msgEditBox:IsShown() then
        return false
    end

    if not IsMessageBoxActive() then
        return false
    end

    local currentText = UI.msgEditBox:GetText() or ""
    local currentLength = string.len(currentText)
    local linkLength = string.len(link)
    local maxLength = CONST.MAX_MESSAGE_LENGTH

    if currentLength >= maxLength then
        UI.msgEditBox:SetFocus()
        return true
    end

    if currentLength + linkLength > maxLength then
        UI.msgEditBox:SetFocus()
        return true
    end

    UI.msgEditBox:SetFocus()
    UI.msgEditBox:Insert(link)

    return true
end

local function HookLinkInsertion()
    if isLinkHookInstalled then
        return
    end

    isLinkHookInstalled = true
    originalChatEdit_InsertLink = ChatEdit_InsertLink

    ChatEdit_InsertLink = function(link)
        if InsertLinkIntoMessageBox(link) then
            return true
        end

        if originalChatEdit_InsertLink then
            return originalChatEdit_InsertLink(link)
        end

        return false
    end
end

HookLinkInsertion()

------------------------------------------------------------
-- UI HELPERS
------------------------------------------------------------

local function SetSaveButtonVisual(textValue, redValue, greenValue, blueValue)
    if not UI.savePresetBtn then
        return
    end

    local hexColor = string.format("%02x%02x%02x",
        math.floor((redValue or 1) * 255 + 0.5),
        math.floor((greenValue or 1) * 255 + 0.5),
        math.floor((blueValue or 1) * 255 + 0.5))

    UI.savePresetBtn:SetText("|cff" .. hexColor .. textValue .. "|r")
end

local function ShowSavedFeedback()
    SetSaveButtonVisual("Saved", 0, 1, 0)
    STATE.saveFeedbackResetAt = GetTime() + 3
end

local function SetMessageEditBoxReadOnly(isReadOnly)
    if not UI.msgEditBox then
        return
    end

    if isReadOnly then
        ClearMessageBoxAsLinkTarget()
        UI.msgEditBox:ClearFocus()
        UI.msgEditBox:EnableMouse(false)
        if UI.msgEditBox.EnableKeyboard then
            UI.msgEditBox:EnableKeyboard(false)
        end
        UI.msgEditBox:SetTextColor(0.6, 0.6, 0.6)
    else
        UI.msgEditBox:EnableMouse(true)
        if UI.msgEditBox.EnableKeyboard then
            UI.msgEditBox:EnableKeyboard(true)
        end
        UI.msgEditBox:SetTextColor(1, 1, 1)
    end
end

local function GetRawMessageLength(text)
    local safeText = tostring(text or "")
    return string.len(safeText)
end

local function UpdateCharacterCount()
    if not UI.charCountText or not UI.msgEditBox then
        return
    end

    local messageText = UI.msgEditBox:GetText() or ""
    local rawLength = GetRawMessageLength(messageText)

    UI.charCountText:SetText(string.format("Chars: %d/%d", rawLength, CONST.MAX_MESSAGE_LENGTH))
end

local function UpdateMessageDirtyState()
    if not UI.msgLabel then
        return
    end

    local presetName = GetSelectedPresetName()

    if IsPresetReadOnly(presetName) then
        UI.msgLabel:SetText("Message to Spam:")
        return
    end

    local currentMessage = ""
    local savedMessage = GetSavedMessageForCurrentSelection()

    if UI.msgEditBox then
        currentMessage = UI.msgEditBox:GetText() or ""
    end

    if currentMessage ~= savedMessage then
        UI.msgLabel:SetText("Message to Spam |cffffcc00(Not Saved)|r")
        if UI.msgEditBox then
            UI.msgEditBox:SetTextColor(0, 1, 0)
        end
    else
        UI.msgLabel:SetText("Message to Spam:")
        if UI.msgEditBox then
            UI.msgEditBox:SetTextColor(1, 1, 1)
        end
    end
end

------------------------------------------------------------
-- CHANNEL HELPERS
------------------------------------------------------------

local function FindJoinedChannelByMatcher(matcher)
    local index = 1

    while true do
        local channelId, channelName = GetChannelName(index)

        if not channelId or channelId <= 0 then
            break
        end

        if type(channelName) == "string" and channelName ~= "" and matcher(channelName, channelId) then
            return channelId, channelName
        end

        index = index + 1
    end

    return nil, nil
end

local function GetFixedChannelInfo(channelKey)
    local normalizedKey = string.lower(tostring(channelKey or ""))

    local function IsMatch(name)
        local normalizedName = string.lower(tostring(name or "")):gsub("%s+", "")

        if normalizedKey == "world" then
            -- Only match General/Global channel; exclude World Defense and LFG
            if string.find(normalizedName, "defense", 1, true) ~= nil then
                return false
            end
            return string.find(normalizedName, "world", 1, true) ~= nil
                or string.find(normalizedName, "global", 1, true) ~= nil
        end

        if normalizedKey == "trade" then
            return string.find(normalizedName, "trade", 1, true) ~= nil
        end

        if normalizedKey == "lfg" then
            return string.find(normalizedName, "lookingforgroup", 1, true) ~= nil
                or normalizedName == "lfg"
        end

        return string.find(normalizedName, normalizedKey, 1, true) ~= nil
    end

    local channelList = { GetChannelList() }

    for index = 1, #channelList, 2 do
        local channelId = channelList[index]
        local channelName = channelList[index + 1]

        if type(channelId) == "number" and type(channelName) == "string" and IsMatch(channelName) then
            return channelId, channelName
        end
    end

    return nil, nil
end

local function RefreshAvailableChannels()
    for _, row in ipairs(UI.channelRows) do
        local displayName = row.config.name

        if row.config.chatType == "YELL" then
            displayName = CapitalizeWords(row.config.name)
        else
            local channelIndex, channelName = GetFixedChannelInfo(row.config.name)

            if channelIndex and channelIndex > 0 then
                displayName = CapitalizeWords(channelName) .. " (" .. tostring(channelIndex) .. ")"
            else
                displayName = CapitalizeWords(row.config.name) .. " (not joined)"
            end
        end

        if row.checkWidget then
            row.checkWidget:SetLabel(displayName)
        end
    end
end

local RefreshPresetActionButtons

local function RefreshChannelsAndPreserveUI()
    if not UI.channelRows or #UI.channelRows == 0 then
        return
    end

    RefreshAvailableChannels()

    if UI.startBtn and RefreshStatusOverlay then
        RefreshStatusOverlay()
    end

    if UI.savePresetBtn then
        RefreshPresetActionButtons()
    end

    if UI.msgEditBox then
        UpdateMessageDirtyState()
    end
end

------------------------------------------------------------
-- PRESET <-> UI HELPERS
------------------------------------------------------------

GetSelectedPresetName = function()
    local presetName = STATE.selectedPresetName

    if type(presetName) == "string"
        and presetName ~= ""
        and presetName ~= CONST.CREATE_NEW_PRESET_LABEL
        and GetPresetData(presetName) then
        return presetName
    end

    return GetCharacterActivePresetName()
end

local function GetSortedPresetNames()
    EnsureSavedVariables()

    local presetNames = {}

    for presetName in pairs(RTSpammerSave.presets) do
        presetNames[#presetNames + 1] = presetName
    end

    table.sort(presetNames, function(leftValue, rightValue)
        local leftPreset = GetPresetData(leftValue)
        local rightPreset = GetPresetData(rightValue)
        local leftOrder = 0
        local rightOrder = 0

        if leftPreset and type(leftPreset.order) == "number" then
            leftOrder = leftPreset.order
        end

        if rightPreset and type(rightPreset.order) == "number" then
            rightOrder = rightPreset.order
        end

        if leftOrder ~= rightOrder then
            return leftOrder > rightOrder
        end

        return tostring(leftValue) < tostring(rightValue)
    end)

    local result = { CONST.CREATE_NEW_PRESET_LABEL }

    for _, presetName in ipairs(presetNames) do
        result[#result + 1] = presetName
    end

    return result
end

local function RefreshPresetDropdownList()
    if not UI.presetDropdown then
        return
    end

    local order = GetSortedPresetNames()
    local list = {}

    for _, presetName in ipairs(order) do
        if presetName == CONST.CREATE_NEW_PRESET_LABEL then
            list[presetName] = "|cff00ff00" .. presetName .. "|r"
        else
            list[presetName] = presetName
        end
    end

    UI.presetDropdown:SetList(list, order)
end

local function GetUniquePresetName(baseName)
    EnsureSavedVariables()

    local trimmedBaseName = LimitTextLength(TrimText(baseName or ""), CONST.PRESET_NAME_MAX_LENGTH)

    if trimmedBaseName == "" then
        return ""
    end

    if not RTSpammerSave.presets[trimmedBaseName] then
        return trimmedBaseName
    end

    local copySuffix = " (copy)"
    local indexedPrefix = " (copy "

    local candidateName = LimitTextLength(trimmedBaseName, CONST.PRESET_NAME_MAX_LENGTH - string.len(copySuffix)) .. copySuffix
    if not RTSpammerSave.presets[candidateName] then
        return candidateName
    end

    local copyIndex = 2
    while true do
        local suffix = indexedPrefix .. tostring(copyIndex) .. ")"
        local maxBaseLength = CONST.PRESET_NAME_MAX_LENGTH - string.len(suffix)
        local basePart = trimmedBaseName

        if maxBaseLength < 1 then
            maxBaseLength = 1
        end

        basePart = LimitTextLength(basePart, maxBaseLength)
        candidateName = basePart .. suffix

        if not RTSpammerSave.presets[candidateName] then
            return candidateName
        end

        copyIndex = copyIndex + 1
    end
end

local function GetNextNewPresetName()
    EnsureSavedVariables()

    local newIndex = 1

    while true do
        local candidateName = CONST.CREATE_NEW_PRESET_BASE_NAME .. " (" .. tostring(newIndex) .. ")"

        if string.len(candidateName) > CONST.PRESET_NAME_MAX_LENGTH then
            candidateName = LimitTextLength(candidateName, CONST.PRESET_NAME_MAX_LENGTH)
        end

        if not RTSpammerSave.presets[candidateName] then
            return candidateName
        end

        newIndex = newIndex + 1
    end
end

local function ApplyPresetToUI(presetData)
    if not presetData then
        return
    end

    for _, row in ipairs(UI.channelRows) do
        local channelName = row.config.name
        row.timerWidget:SetText(tostring(presetData.intervals[channelName] or CONST.DEFAULT_INTERVALS[channelName] or "30"))
        row.checkWidget:SetValue(presetData.enabledChannels[channelName] and true or false)
    end

    if UI.msgEditBox then
        if presetData.isReadOnly == true then
            UI.msgEditBox:SetText(CONST.DEFAULT_PRESET_READONLY_TEXT)
        else
            UI.msgEditBox:SetText(presetData.message or "")
        end
    end

    SetMessageEditBoxReadOnly(presetData.isReadOnly == true)
    UpdateCharacterCount()
    UpdateMessageDirtyState()
end

local function ReadPresetDataFromUI(basePresetData)
    local result = CopyPresetData(basePresetData)

    if UI.msgEditBox and not result.isReadOnly then
        result.message = UI.msgEditBox:GetText() or ""
    end

    for _, row in ipairs(UI.channelRows) do
        result.intervals[row.config.name] = row.timerWidget:GetText() or CONST.DEFAULT_INTERVALS[row.config.name]
        result.enabledChannels[row.config.name] = row.checkWidget:GetValue() and true or false
    end

    return result
end

local function IsPresetDirty()
    local presetName = GetSelectedPresetName()
    local presetData = GetPresetData(presetName)

    if not presetData or not CanSavePreset(presetName) then
        return false
    end

    local currentPresetData = ReadPresetDataFromUI(presetData)

    local savedMessage = tostring(presetData.message or "")
    local currentMessage = tostring(currentPresetData.message or "")

    if currentMessage ~= savedMessage then
        return true
    end

    for channelName, savedInterval in pairs(presetData.intervals or {}) do
        local currentInterval = tostring((currentPresetData.intervals or {})[channelName] or "")
        if currentInterval ~= tostring(savedInterval or "") then
            return true
        end
    end

    for channelName, currentInterval in pairs((currentPresetData.intervals or {})) do
        local savedInterval = tostring((presetData.intervals or {})[channelName] or "")
        if tostring(currentInterval or "") ~= savedInterval then
            return true
        end
    end

    for channelName, savedEnabled in pairs(presetData.enabledChannels or {}) do
        local currentEnabled = ((currentPresetData.enabledChannels or {})[channelName] == true)
        if currentEnabled ~= (savedEnabled == true) then
            return true
        end
    end

    for channelName, currentEnabled in pairs((currentPresetData.enabledChannels or {})) do
        local savedEnabled = ((presetData.enabledChannels or {})[channelName] == true)
        if (currentEnabled == true) ~= savedEnabled then
            return true
        end
    end

    return false
end

RefreshPresetActionButtons = function()
    local presetName = STATE.selectedPresetName
    local canRename = CanRenamePreset(presetName)
    local canDelete = CanDeletePreset(presetName)
    local canSave = CanSavePreset(presetName)
    local isDirty = false

    if canSave then
        isDirty = IsPresetDirty()
    end

    if UI.savePresetBtn then
        if canSave and isDirty then
            SetSaveButtonVisual("Save", 1, 0.82, 0)
            UI.savePresetBtn:SetDisabled(false)
        else
            SetSaveButtonVisual("Save", 0.5, 0.5, 0.5)
            UI.savePresetBtn:SetDisabled(true)
        end
    end

    if UI.renamePresetBtn then
        UI.renamePresetBtn:SetDisabled(not canRename)
    end

    if UI.deletePresetBtn then
        UI.deletePresetBtn:SetDisabled(not canDelete)
    end
end

local function SetSelectedPresetName(presetName)
    STATE.selectedPresetName = presetName

    if presetName and presetName ~= "" and presetName ~= CONST.CREATE_NEW_PRESET_LABEL and GetPresetData(presetName) then
        SetCharacterActivePresetName(presetName)
    end

    if UI.presetDropdown then
        if presetName and presetName ~= "" then
            UI.presetDropdown:SetValue(presetName)
            UI.presetDropdown:SetText(presetName)
        else
            UI.presetDropdown:SetText("Select Preset")
        end
    end

    RefreshPresetActionButtons()
    UpdateMessageDirtyState()
end

local function SaveActivePreset()
    EnsureSavedVariables()

    local presetName = GetSelectedPresetName()
    local presetData = GetPresetData(presetName)

    if not CanSavePreset(presetName) or not presetData then
        return
    end

    local updatedPresetData = ReadPresetDataFromUI(presetData)
    updatedPresetData.order = presetData.order
    updatedPresetData.isDefault = false
    updatedPresetData.isReadOnly = false

    RTSpammerSave.presets[presetName] = updatedPresetData
    SetCharacterActivePresetName(presetName)

    UpdateMessageDirtyState()
    RefreshPresetActionButtons()
end

local function SaveSelectedPresetSettingsFromUI()
    EnsureSavedVariables()

    local presetName = GetSelectedPresetName()
    local presetData = GetPresetData(presetName)

    if not presetData then
        return
    end

    if IsDefaultPreset(presetName) then
        local updatedPresetData = CopyPresetData(presetData)

        for _, row in ipairs(UI.channelRows) do
            updatedPresetData.intervals[row.config.name] = row.timerWidget:GetText() or CONST.DEFAULT_INTERVALS[row.config.name]
            updatedPresetData.enabledChannels[row.config.name] = row.checkWidget:GetValue() and true or false
        end

        updatedPresetData.message = CONST.DEFAULT_PRESET_MESSAGE
        updatedPresetData.order = -1
        updatedPresetData.isDefault = true
        updatedPresetData.isReadOnly = true

        RTSpammerSave.presets[presetName] = updatedPresetData
        return
    end

    if not CanSavePreset(presetName) then
        return
    end

    SaveActivePreset()
end

------------------------------------------------------------
-- RENAME FLOW
------------------------------------------------------------

local function StopRenameMode()
    StaticPopup_Hide(CONST.SPAMMER_PRESET_RENAME_POPUP)
end

local function GetMostRecentPresetNameExcluding(excludedPresetName)
    EnsureSavedVariables()

    local selectedPresetName = nil
    local selectedOrder = -math.huge

    for presetName, presetData in pairs(RTSpammerSave.presets or {}) do
        if presetName ~= excludedPresetName
            and presetName ~= CONST.DEFAULT_PRESET_NAME
            and type(presetData) == "table"
            and type(presetData.order) == "number" then
            if presetData.order > selectedOrder then
                selectedOrder = presetData.order
                selectedPresetName = presetName
            end
        end
    end

    if selectedPresetName and RTSpammerSave.presets[selectedPresetName] then
        return selectedPresetName
    end

    return CONST.DEFAULT_PRESET_NAME
end

local function CreateNewPreset()
    EnsureSavedVariables()
    StopSpamming(false)

    local presetName = GetNextNewPresetName()

    RTSpammerSave.presetOrderCounter = (RTSpammerSave.presetOrderCounter or 0) + 1

    local newPresetData = {
        message = "",
        intervals = CopyIntervals(nil),
        enabledChannels = CopyEnabledChannels(nil),
        order = RTSpammerSave.presetOrderCounter,
        isDefault = false,
        isReadOnly = false,
    }

    RTSpammerSave.presets[presetName] = newPresetData

    RefreshPresetDropdownList()
    SetCharacterActivePresetName(presetName)
    SetSelectedPresetName(presetName)
    StopRenameMode()
    ApplyPresetToUI(RTSpammerSave.presets[presetName])
    RefreshPresetActionButtons()
end

local function LoadPreset(presetName)
    if not presetName or presetName == "" then
        return
    end

    if presetName == CONST.CREATE_NEW_PRESET_LABEL then
        CreateNewPreset()
        return
    end

    EnsureSavedVariables()

    local presetData = GetPresetData(presetName)
    if not presetData then
        return
    end

    StopSpamming(false)
    SetCharacterActivePresetName(presetName)
    SetSelectedPresetName(presetName)
    StopRenameMode()
    ApplyPresetToUI(presetData)
end

local function DeletePreset(presetName)
    if not CanDeletePreset(presetName) then
        return
    end

    EnsureSavedVariables()
    StopSpamming(false)

    RTSpammerSave.presets[presetName] = nil

    local fallbackPresetName = GetMostRecentPresetNameExcluding(presetName)
    local characterKey = GetCharacterKey()

    if RTSpammerSave.characterSettings
        and RTSpammerSave.characterSettings[characterKey] then
        RTSpammerSave.characterSettings[characterKey].activePresetName = fallbackPresetName
    end

    RefreshPresetDropdownList()
    SetCharacterActivePresetName(fallbackPresetName)
    SetSelectedPresetName(fallbackPresetName)
    StopRenameMode()
    ApplyPresetToUI(GetPresetData(fallbackPresetName))
    RefreshPresetActionButtons()
end

StaticPopupDialogs[CONST.SPAMMER_PRESET_DELETE_POPUP] = {
    text = 'Are you sure you want to delete "%s"?',
    button1 = "Yes",
    button2 = "No",
    OnAccept = function(self)
        local presetName = self.data
        DeletePreset(presetName)
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

local RenameSelectedPreset  -- forward declaration; defined below

StaticPopupDialogs[CONST.SPAMMER_PRESET_RENAME_POPUP] = {
    text = "Rename preset:",
    button1 = "Accept",
    button2 = "Cancel",
    hasEditBox = true,
    maxLetters = CONST.PRESET_NAME_MAX_LENGTH,
    OnShow = function(self)
        self.editBox:SetText(self.data or "")
        self.editBox:HighlightText()
        self.editBox:SetFocus()
    end,
    OnAccept = function(self)
        RenameSelectedPreset(self.editBox:GetText() or "")
    end,
    EditBoxOnEnterPressed = function(self)
        RenameSelectedPreset(self:GetText() or "")
        self:GetParent():Hide()
    end,
    EditBoxOnEscapePressed = function(self)
        self:GetParent():Hide()
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

RenameSelectedPreset = function(newName)
    EnsureSavedVariables()

    local oldName = GetSelectedPresetName()
    if not CanRenamePreset(oldName) then
        StopRenameMode()
        return
    end

    local finalName = LimitTextLength(TrimText(newName or ""), CONST.PRESET_NAME_MAX_LENGTH)
    if finalName == "" then
        return
    end

    if finalName == oldName then
        StopRenameMode()
        return
    end

    finalName = GetUniquePresetName(finalName)

    RTSpammerSave.presets[finalName] = CopyPresetData(RTSpammerSave.presets[oldName])

    if type(RTSpammerSave.presets[oldName]) == "table" and type(RTSpammerSave.presets[oldName].order) == "number" then
        RTSpammerSave.presets[finalName].order = RTSpammerSave.presets[oldName].order
    end

    RTSpammerSave.presets[finalName].isDefault = false
    RTSpammerSave.presets[finalName].isReadOnly = false

    RTSpammerSave.presets[oldName] = nil
    RefreshPresetDropdownList()
    SetCharacterActivePresetName(finalName)
    SetSelectedPresetName(finalName)
    StopRenameMode()
end

------------------------------------------------------------
-- UI BUILDERS
------------------------------------------------------------

local SP_C = {
    bg = { 0.08, 0.08, 0.08, 0.00 },
}

local function SPSetBG(frame, r, g, b, a)
    if not frame._bgTex then
        frame._bgTex = frame:CreateTexture(nil, "BACKGROUND")
        frame._bgTex:SetAllPoints(frame)
    end
    frame._bgTex:SetTexture(r, g, b, a)
end

local function SPHeading(text)
    local heading = AceGUI:Create("Heading")
    heading:SetText(text)
    return heading
end

------------------------------------------------------------
-- PUBLIC UI ENTRY
------------------------------------------------------------

function CreateSpammerTabContent(parent, onClose)
    EnsureSavedVariables()

    local f = CreateFrame("Frame", nil, parent)
    f:SetAllPoints()
    f:Hide()
    SPSetBG(f, unpack(SP_C.bg))

    -- Raw Blizzard ScrollFrame (UIPanelScrollFrameTemplate) + a fixed-width
    -- content frame, with each widget manually pinned via a running yOffset
    -- cursor (PlaceWidget below). AceGUI's "ScrollFrame" container + "List"
    -- layout was tried repeatedly (see CLAUDE.md "Fix part 1-4") and remained
    -- fragile: its FixScroll/LayoutFinished cycle re-measures and re-anchors
    -- children using anchor-derived sizes that read 0 while the tab is
    -- hidden (during construction), and re-running DoLayout later races with
    -- FixScroll's own automatic relayout, leaving different "groups" of
    -- children anchored to stale intermediate positions (confirmed via
    -- RNTools_SpammerDebugDump - children formed 3 internally-consistent but
    -- mutually-offset groups). Manual yOffset anchoring sidesteps all of
    -- that entirely and matches RealNibba.Pack's raw UIPanelScrollFrameTemplate
    -- usage for scrolling areas.
    local CONTENT_W = 404
    -- UIPanelScrollFrameTemplate's XML OnLoad/OnScrollRangeChanged/etc.
    -- scripts use "$parent..." child-name substitution (e.g. for the
    -- scrollbar), which requires this frame to have a real name - passing
    -- nil here causes "attempt to concatenate a nil value" in those scripts.
    local scrollFrame = CreateFrame("ScrollFrame", "RNToolsSpammerScrollFrame", f, "UIPanelScrollFrameTemplate")
    scrollFrame:ClearAllPoints()
    scrollFrame:SetPoint("TOPLEFT", f, "TOPLEFT", 12, -10)
    scrollFrame:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 12, 36)
    scrollFrame:SetWidth(CONTENT_W)
    -- All Spammer content fits within the fixed left-column height, so the
    -- scrollbar is never actually needed - hide it (the template shows its
    -- track/thumb unconditionally otherwise) and use the full 404px width
    -- that the scrollbar would otherwise have reserved space for.
    if RNToolsSpammerScrollFrameScrollBar then
        RNToolsSpammerScrollFrameScrollBar:Hide()
    end

    local content = CreateFrame("Frame", nil, scrollFrame)
    content:SetWidth(CONTENT_W)
    content:SetHeight(1)
    scrollFrame:SetScrollChild(content)

    local yOffset = 0

    -- Anchors widget.frame to content at the current yOffset, spanning the
    -- full content width, with a fixed height - then advances yOffset by
    -- height + spacing for the next widget. AceGUI widgets created this way
    -- are never added to any AceGUI layout container, so their position is
    -- never recomputed/overridden after this call.
    local function PlaceWidget(widget, height, spacing)
        widget.frame:SetParent(content)
        widget.frame:ClearAllPoints()
        widget.frame:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -yOffset)
        widget.frame:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -yOffset)
        widget.frame:SetHeight(height)
        widget.frame:Show()
        yOffset = yOffset + height + (spacing or 0)
        return widget
    end

    PlaceWidget(SPHeading("Spammer"), 18, 4)

    local subLbl = AceGUI:Create("Label")
    subLbl:SetJustifyH("CENTER")
    subLbl:SetColor(0.60, 0.60, 0.60)
    subLbl:SetText("Send a recurring message to multiple chat channels")
    -- AceGUI Label only wraps/centers against the width passed to its own
    -- SetWidth (AceGUIWidget-Label OnWidthSet -> UpdateImageAnchor), not the
    -- TOPLEFT/TOPRIGHT anchors PlaceWidget uses - call it here so the text
    -- wraps to CONTENT_W and frame.height reflects the wrapped line count.
    subLbl:SetWidth(CONTENT_W)
    PlaceWidget(subLbl, subLbl.frame.height, 6)

    -- ── Preset ───────────────────────────────────────────────────────
    PlaceWidget(SPHeading("Preset"), 18, 4)

    local presetRow = AceGUI:Create("SimpleGroup")
    presetRow:SetLayout("Flow")
    -- noAutoHeight + SetWidth(CONTENT_W) (AceGUI method, triggers
    -- OnWidthSet -> content.width = CONTENT_W) so the Flow-laid-out
    -- Dropdown/Buttons below are sized against the real content width on
    -- their first layout pass, and so LayoutFinished doesn't override our
    -- fixed 32px row height.
    presetRow.noAutoHeight = true
    presetRow:SetWidth(CONTENT_W)
    PlaceWidget(presetRow, 32, 8)

    UI.presetDropdown = AceGUI:Create("Dropdown")
    UI.presetDropdown:SetRelativeWidth(0.46)
    UI.presetDropdown:SetCallback("OnValueChanged", function(widget, event, value)
        LoadPreset(value)
    end)
    presetRow:AddChild(UI.presetDropdown)

    UI.renamePresetBtn = AceGUI:Create("Button")
    UI.renamePresetBtn:SetText("Rename")
    UI.renamePresetBtn:SetRelativeWidth(0.18)
    UI.renamePresetBtn:SetCallback("OnClick", function()
        local presetName = GetSelectedPresetName()

        if not CanRenamePreset(presetName) then
            return
        end

        StaticPopup_Show(CONST.SPAMMER_PRESET_RENAME_POPUP, nil, nil, presetName)
    end)
    presetRow:AddChild(UI.renamePresetBtn)

    UI.deletePresetBtn = AceGUI:Create("Button")
    UI.deletePresetBtn:SetText("Delete")
    UI.deletePresetBtn:SetRelativeWidth(0.18)
    UI.deletePresetBtn:SetCallback("OnClick", function()
        local presetName = GetSelectedPresetName()

        if not CanDeletePreset(presetName) then
            return
        end

        StaticPopup_Show(CONST.SPAMMER_PRESET_DELETE_POPUP, presetName, nil, presetName)
    end)
    presetRow:AddChild(UI.deletePresetBtn)

    UI.savePresetBtn = AceGUI:Create("Button")
    UI.savePresetBtn:SetText("Save")
    UI.savePresetBtn:SetRelativeWidth(0.18)
    UI.savePresetBtn:SetCallback("OnClick", function()
        SaveActivePreset()
        UpdateMessageDirtyState()
        RefreshPresetActionButtons()
        ShowSavedFeedback()
    end)
    presetRow:AddChild(UI.savePresetBtn)

    -- ── Message ──────────────────────────────────────────────────────
    UI.msgLabel = AceGUI:Create("Label")
    UI.msgLabel:SetText("Message to Spam:")
    PlaceWidget(UI.msgLabel, 16, 2)

    local msgSlot = AceGUI:Create("SimpleGroup")
    msgSlot:SetLayout("List")
    msgSlot.noAutoHeight = true
    msgSlot:SetWidth(CONTENT_W)
    PlaceWidget(msgSlot, 152, 8)

    local msgBG = CreateFrame("Frame", nil, msgSlot.content)
    msgBG:SetPoint("TOPLEFT", msgSlot.content, "TOPLEFT", 0, 0)
    msgBG:SetPoint("TOPRIGHT", msgSlot.content, "TOPRIGHT", 0, 0)
    msgBG:SetHeight(130)
    msgBG:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        edgeSize = 1,
    })
    msgBG:SetBackdropColor(0.10, 0.10, 0.10, 0.97)
    msgBG:SetBackdropBorderColor(0.55, 0.50, 0.42, 0.8)

    UI.msgEditBox = CreateFrame("EditBox", nil, msgBG)
    UI.msgEditBox:SetMultiLine(true)
    UI.msgEditBox:SetMaxLetters(CONST.MAX_MESSAGE_LENGTH)
    UI.msgEditBox:SetFontObject(ChatFontNormal)
    UI.msgEditBox:SetPoint("TOPLEFT", 8, -8)
    UI.msgEditBox:SetPoint("BOTTOMRIGHT", -8, 8)
    UI.msgEditBox:SetAutoFocus(false)
    UI.msgEditBox:EnableMouse(true)
    if UI.msgEditBox.SetHyperlinksEnabled then
        UI.msgEditBox:SetHyperlinksEnabled(false)
    end

    UI.msgEditBox:SetScript("OnTextChanged", function(self)
        local text = self:GetText() or ""

        if string.len(text) > CONST.MAX_MESSAGE_LENGTH then
            self:SetText(string.sub(text, 1, CONST.MAX_MESSAGE_LENGTH))
            self:SetCursorPosition(CONST.MAX_MESSAGE_LENGTH)
        end

        SetMessageBoxAsLinkTarget()
        UpdateCharacterCount()
        UpdateMessageDirtyState()
        RefreshPresetActionButtons()
    end)

    UI.msgEditBox:SetScript("OnEscapePressed", function(self)
        ClearMessageBoxAsLinkTarget()
        self:ClearFocus()
    end)

    UI.msgEditBox:SetScript("OnMouseDown", function(self)
        local presetName = GetSelectedPresetName()

        if IsPresetReadOnly(presetName) then
            ClearMessageBoxAsLinkTarget()
            self:ClearFocus()
            return
        end

        SetMessageBoxAsLinkTarget()
        self:SetFocus()
    end)

    UI.msgEditBox:SetScript("OnEditFocusGained", function(self)
        local presetName = GetSelectedPresetName()

        if IsPresetReadOnly(presetName) then
            ClearMessageBoxAsLinkTarget()
            self:ClearFocus()
            return
        end

        SetMessageBoxAsLinkTarget()
    end)

    UI.msgEditBox:SetScript("OnEditFocusLost", function()
        ClearMessageBoxAsLinkTarget()
    end)

    UI.msgEditBox:SetScript("OnHide", function()
        ClearMessageBoxAsLinkTarget()
    end)

    UI.charCountText = msgSlot.content:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    UI.charCountText:SetPoint("TOPRIGHT", msgBG, "BOTTOMRIGHT", 0, -2)
    UI.charCountText:SetText("Characters: 0/255")

    -- ── Channels ─────────────────────────────────────────────────────
    PlaceWidget(SPHeading("Channels"), 18, 4)

    for i, config in ipairs(CONST.CHANNEL_POOL) do
        local row = AceGUI:Create("SimpleGroup")
        row:SetLayout("Flow")
        row.noAutoHeight = true
        row:SetWidth(CONTENT_W)
        PlaceWidget(row, 24, 2)

        local checkWidget = AceGUI:Create("CheckBox")
        checkWidget:SetLabel(config.name)
        checkWidget:SetRelativeWidth(0.46)
        row:AddChild(checkWidget)

        local intervalLbl = AceGUI:Create("Label")
        intervalLbl:SetText("Interval (s):")
        intervalLbl:SetRelativeWidth(0.24)
        row:AddChild(intervalLbl)

        local timerWidget = AceGUI:Create("EditBox")
        timerWidget:SetRelativeWidth(0.15)
        timerWidget:DisableButton(true)
        timerWidget.editbox:SetNumeric(true)
        row:AddChild(timerWidget)

        local cdWidget = AceGUI:Create("Label")
        cdWidget:SetRelativeWidth(0.15)
        cdWidget:SetText("|cff888888Ready|r")
        row:AddChild(cdWidget)

        UI.channelRows[i] = {
            config = config,
            nextSend = 0,
            lastCooldownText = nil,
            checkWidget = checkWidget,
            timerWidget = timerWidget,
            cdWidget = cdWidget,
        }

        checkWidget:SetCallback("OnValueChanged", function()
            UpdateMessageDirtyState()
            RefreshPresetActionButtons()
            SaveSelectedPresetSettingsFromUI()
        end)

        timerWidget:SetCallback("OnEnterPressed", function(widget)
            widget.editbox:ClearFocus()
        end)

        timerWidget.editbox:HookScript("OnEditFocusLost", function()
            UpdateMessageDirtyState()
            RefreshPresetActionButtons()
            SaveSelectedPresetSettingsFromUI()
        end)
    end

    content:SetHeight(yOffset)

    -- ── Events ───────────────────────────────────────────────────────
    eventFrame:UnregisterAllEvents()
    eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
    eventFrame:RegisterEvent("CHANNEL_UI_UPDATE")
    eventFrame:RegisterEvent("CHAT_MSG_CHANNEL_NOTICE")
    eventFrame:RegisterEvent("CHAT_MSG_CHANNEL_NOTICE_USER")

    eventFrame:SetScript("OnEvent", function(self, event)
        if event == "PLAYER_ENTERING_WORLD"
            or event == "CHANNEL_UI_UPDATE"
            or event == "CHAT_MSG_CHANNEL_NOTICE"
            or event == "CHAT_MSG_CHANNEL_NOTICE_USER" then
            RefreshChannelsAndPreserveUI()
        end
    end)

    eventFrame:SetScript("OnUpdate", function(self, elapsed)
        STATE.uiUpdateAccumulator = STATE.uiUpdateAccumulator + elapsed

        if UI.savePresetBtn and STATE.saveFeedbackResetAt > 0 and GetTime() >= STATE.saveFeedbackResetAt then
            SetSaveButtonVisual("Save", 1, 0.82, 0)
            STATE.saveFeedbackResetAt = 0
        end

        if STATE.uiUpdateAccumulator < CONST.UI_UPDATE_INTERVAL then
            return
        end

        STATE.uiUpdateAccumulator = 0

        local now = GetTime()

        for _, row in ipairs(UI.channelRows) do
            local remaining = row.nextSend - now

            if remaining > 0 then
                local colorCode = "|cff888888"

                if STATE.isSpamming and row.checkWidget:GetValue() then
                    colorCode = "|cff00ff00"
                end

                SetCooldownText(row, string.format("%s%.1fs|r", colorCode, remaining))
            elseif STATE.isSpamming and row.checkWidget:GetValue() then
                SetCooldownText(row, "|cffffcc00SENDING|r")

                local textValue = GetMessageToSend()
                local intervalValue = tonumber(row.timerWidget:GetText()) or 30
                row.nextSend = now + intervalValue

                if textValue ~= "" then
                    if row.config.chatType == "CHANNEL" then
                        local channelId, channelName = GetFixedChannelInfo(row.config.name)

                        if channelId and channelId > 0 then
                            SafeSendChatMessage(textValue, "CHANNEL", nil, channelId)
                        end
                    else
                        SafeSendChatMessage(textValue, row.config.chatType)
                    end
                end
            else
                SetCooldownText(row, "|cff888888Ready|r")
            end
        end
    end)

    f:SetScript("OnShow", function()
        EnsureSavedVariables()
        RefreshAvailableChannels()
        RefreshPresetDropdownList()

        local presetName = GetCharacterActivePresetName()

        if presetName and GetPresetData(presetName) then
            SetSelectedPresetName(presetName)
            ApplyPresetToUI(GetPresetData(presetName))
        else
            SetSelectedPresetName(CONST.DEFAULT_PRESET_NAME)
            ApplyPresetToUI(GetPresetData(CONST.DEFAULT_PRESET_NAME))
        end

        StopRenameMode()
        RefreshPresetActionButtons()
        UpdateMessageDirtyState()
    end)

    -- ── Start ────────────────────────────────────────────────────────
    -- Button sits under the 404px content column (left of the Raiding
    -- Presets panel that gets bolted on at x=430 via the CreateSpammerTabContent
    -- hook below), so anchor to f's BOTTOMLEFT, not BOTTOMRIGHT.
    UI.startBtn = AceGUI:Create("Button")
    UI.startBtn:SetText("Start Spamming")
    UI.startBtn:SetWidth(170)
    UI.startBtn.frame:SetParent(f)
    UI.startBtn.frame:ClearAllPoints()
    UI.startBtn.frame:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 140, 4)
    UI.startBtn.frame:Show()
    UI.startBtn:SetCallback("OnClick", function()
        if not STATE.isSpamming then
            local textValue = GetMessageToSend()

            if textValue == "" then
                return
            end

            STATE.isSpamming = true
            if RefreshStatusOverlay then
                RefreshStatusOverlay()
            end
            UI.startBtn:SetText("|cff00ff00Enabled|r")

            local now = GetTime()

            for _, row in ipairs(UI.channelRows) do
                local intervalValue = tonumber(row.timerWidget:GetText()) or 30

                if not row.nextSend or row.nextSend <= 0 then
                    if row.config.name == "World" then
                        row.nextSend = now
                    else
                        row.nextSend = now + intervalValue
                    end
                end
            end
        else
            StopSpamming(false)
        end
    end)

    RefreshPresetDropdownList()
    RefreshAvailableChannels()
    ApplyPresetToUI(GetPresetData(GetCharacterActivePresetName()))
    SetSelectedPresetName(GetCharacterActivePresetName())
    RefreshPresetActionButtons()
    StopRenameMode()

    return f
end

------------------------------------------------------------
-- INITIALIZATION
------------------------------------------------------------

loader:RegisterEvent("ADDON_LOADED")
loader:SetScript("OnEvent", function(self, event, arg1)
    if arg1 == addonName then
        EnsureSavedVariables()
        self:UnregisterEvent("ADDON_LOADED")
    end
end)

-- Global toggle for Quick Access window
function RNTools_ToggleSpammer()
    if STATE.isSpamming then
        StopSpamming(false)
    else
        STATE.isSpamming = true
        if UI.startBtn then UI.startBtn:SetText("Stop Spamming") end
    end
    if RefreshStatusOverlay then RefreshStatusOverlay() end
end
function RNTools_IsSpammerOn() return STATE.isSpamming end

------------------------------------------------------------
-- RAIDING PRESETS MODULE v3
------------------------------------------------------------

-- RNTools-style button (neutral Ace3 tan/grey)
local RN_BD = {bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",
               tile=false,tileSize=1,edgeSize=1,insets={left=1,right=1,top=1,bottom=1}}
local function RNSetBG(f,r,g,b,a)
    if not f._rnbd then f:SetBackdrop(RN_BD); f._rnbd=true end
    f:SetBackdropColor(r,g,b,a or 1)
end
local function RNBorder(f,r,g,b,a)
    if not f._rnbd then f:SetBackdrop(RN_BD); f._rnbd=true end
    f:SetBackdropBorderColor(r,g,b,a or 1)
end
local function RNBtn(parent,label,w,h,r,g,b)
    local btn=CreateFrame("Button",nil,parent)
    btn:SetSize(w,h or 22)
    RNSetBG(btn,r or 0.16,g or 0.14,b or 0.11,1)
    RNBorder(btn,0.55,0.50,0.42,0.8)
    local fs=btn:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
    fs:SetAllPoints(btn); fs:SetJustifyH("CENTER"); fs:SetText(label)
    fs:SetTextColor(0.90,0.90,0.90,1)
    btn._fs=fs
    btn:SetScript("OnEnter",function() RNSetBG(btn,0.28,0.24,0.18,1) end)
    btn:SetScript("OnLeave",function() RNSetBG(btn,r or 0.16,g or 0.14,b or 0.11,1) end)
    btn:SetScript("OnMouseDown",function() RNSetBG(btn,0.08,0.08,0.08,1) end)
    btn:SetScript("OnMouseUp",  function() RNSetBG(btn,0.28,0.24,0.18,1) end)
    return btn
end

-- Raid size → raid type → difficulties
local RP_RAIDS = {
    ["10"] = {
        ICC    = {"NM","HC","8/12HC","LK RUN","BANE","ALT RUN"},
        RS     = {"NM","HC"},
        TOGC   = {"EoT RUN","SCROLL RUN","INSANITY RUN","MOUNT RUN"},
        TOC    = {"SCROLL","EOT RUN","FAST RUN"},
        ULDUAR = {"NM","GLORY","CHAMPION"},
        VOA    = {"FROST ONLY","F+F"},
    },
    ["25"] = {
        ICC    = {"NM","HC","8/12HC","LK RUN","LOD","ALT RUN"},
        RS     = {"NM","HC"},
        TOGC   = {"EoT RUN","SCROLL RUN","INSANITY RUN","MOUNT RUN"},
        TOC    = {"SCROLL","EOT RUN","FAST RUN"},
        ULDUAR = {"NM","GLORY","CONQUEROR"},
        VOA    = {"FROST ONLY","F+F"},
    },
}

-- Achievement table [raid][size][diff] = {id, name}
local RP_ACH = {
    ICC = {
        ["10"] = {
            NM          = {4532,  "Fall of the Lich King (10 player)"},
            HC          = {4636,  "Heroic: Fall of the Lich King (10 player)"},
            ["LK RUN"]  = {4530,  "The Frozen Throne (10 player)"},
            ["8/12HC"]  = {4630,  "Heroic: The Crimson Hall (10 player)"},
            BANE        = {4583,  "Bane of the Fallen King"},
        },
        ["25"] = {
            NM          = {4608,  "Fall of the Lich King (25 player)"},
            HC          = {4637,  "Heroic: Fall of the Lich King (25 player)"},
            ["LK RUN"]  = {4597,  "The Frozen Throne (25 player)"},
            ["8/12HC"]  = {4634,  "Heroic: The Crimson Hall (25 player)"},
            LOD         = {4584,  "The Light of Dawn"},
        },
    },
    RS = {
        ["10"] = {NM={4817,"The Twilight Destroyer (10 player)"},HC={4818,"Heroic: The Twilight Destroyer (10 player)"}},
        ["25"] = {NM={4815,"The Twilight Destroyer (25 player)"},HC={4816,"Heroic: The Twilight Destroyer (25 player)"}},
    },
    TOC = {
        ["10"] = {
            SCROLL       = {3917, "Call of the Crusade (10 player)"},
            ["EOT RUN"]  = {3917, "Call of the Crusade (10 player)"},
            ["FAST RUN"] = {3917, "Call of the Crusade (10 player)"},
        },
        ["25"] = {
            SCROLL       = {3916, "Call of the Crusade (25 player)"},
            ["EOT RUN"]  = {3916, "Call of the Crusade (25 player)"},
            ["FAST RUN"] = {3916, "Call of the Crusade (25 player)"},
        },
    },
    TOGC = {
        ["10"] = {
            ["EoT RUN"]     = {3810,"A Tribute to Insanity (10 player)"},
            ["SCROLL RUN"]  = {3810,"A Tribute to Insanity (10 player)"},
            ["INSANITY RUN"]= {3810,"A Tribute to Insanity (10 player)"},
            ["MOUNT RUN"]   = {3810,"A Tribute to Insanity (10 player)"},
        },
        ["25"] = {
            ["EoT RUN"]     = {3819,"A Tribute to Insanity (25 player)"},
            ["SCROLL RUN"]  = {3819,"A Tribute to Insanity (25 player)"},
            ["INSANITY RUN"]= {3819,"A Tribute to Insanity (25 player)"},
            ["MOUNT RUN"]   = {3819,"A Tribute to Insanity (25 player)"},
        },
    },
    ULDUAR = {
        ["10"] = {
            NM          = {2894, "The Secrets of Ulduar (10 player)"},
            GLORY       = {2957, "Glory of the Ulduar Raider (10 player)"},
            CHAMPION    = {2903, "Champion of Ulduar"},
        },
        ["25"] = {
            NM          = {2895, "The Secrets of Ulduar (25 player)"},
            GLORY       = {2958, "Glory of the Ulduar Raider (25 player)"},
            CONQUEROR   = {2904, "Conqueror of Ulduar"},
        },
    },
}

local RP_ROLES = {
    {label="Tanks",      key="tanks",  generic="Tanks",   classes={"DK TANK","PROT PALA","BEAR","PROT WAR"}},
    {label="Healers",    key="heal",   generic="Healers", classes={"RSHAMAN","HOLY PALA","DISCO","HPRIEST","RDRU"}},
    {label="Melee DPS",  key="melee",  generic="MELEE DPS",  classes={"FWAR","RET PALA","CROGUE","FERAL CAT","UNH DK","ENHA"}},
    {label="Ranged DPS", key="ranged", generic="RANGE DPS",  classes={"HUNTER","DEMO","AFFLI","MAGE","BOOMIE","SPRIEST","ELE SHAM"}},
}

local RP = {size=nil,raid=nil,diffs={},allMaster=false,gsInput="",ressInput="",achEnabled=false}
-- RP.diffs is a table of selected difficulties; RP.diff = first selected (for ach lookup)
local RP_SEL = {}   -- [key][cls]=true
local RP_NUM = {}   -- [key]=str
for _,r in ipairs(RP_ROLES) do RP_SEL[r.key]={} RP_NUM[r.key]="" end

local RP_UI = {sizeBtns={},raidBtns={},diffBtns={},diffRow=nil,allChk=nil,
               dropBtns={},numBoxes={},gsFld=nil,ressFld=nil,achChk=nil,preview=nil}

local function RP_FormatGS(raw)
    local s=tostring(raw or ""):gsub("%s","")
    if s=="" then return "" end
    local n=tonumber(s)
    if not n then return s end
    if n>=1000 then n=n/1000 end
    return string.format("%.1f+",n)
end

local function RP_GetAch()
    if not RP.achEnabled or not RP.raid or not RP.size or #RP.diffs==0 then return "" end
    local t=RP_ACH[RP.raid] if not t then return "" end
    local st=t[RP.size]     if not st then return "" end
    -- Priority: pick highest achievement among selected diffs
    -- Order defined per raid (highest first)
    local RP_ACH_PRIORITY = {
        ICC  = {"LOD","BANE","8/12HC","LK RUN","HC","NM","ALT RUN"},
        RS   = {"HC","NM"},
        TOGC = {"INSANITY RUN","EoT RUN","SCROLL RUN","MOUNT RUN"},
        TOC  = {"FAST RUN","EOT RUN","SCROLL"},
        ULDUAR = {"CONQUEROR","CHAMPION","GLORY","NM"},
        VOA  = {"F+F","FROST ONLY"},
    }
    local priority = RP_ACH_PRIORITY[RP.raid] or {}
    local bestDiff = RP.diffs[1]  -- fallback to first
    for _,pd in ipairs(priority) do
        for _,sd in ipairs(RP.diffs) do
            if sd == pd then bestDiff = pd; break end
        end
        if bestDiff == pd then break end
    end
    local e=st[bestDiff] if not e then return "" end
    local id,name=e[1],e[2]
    -- Try GetAchievementLink first (works if cached)
    local link=GetAchievementLink and GetAchievementLink(id)
    if link and link~="" then return link end
    -- Force cache by calling GetAchievementInfo, then try again
    if GetAchievementInfo then GetAchievementInfo(id) end
    link=GetAchievementLink and GetAchievementLink(id)
    if link and link~="" then return link end
    -- Fallback: construct the hyperlink using the player's own GUID.
    -- WotLK 3.3.5a format: ID:GUID:completed:year:month:day:h1:h2:h3:h4 (10 fields).
    -- completed=0 / all dates=0 marks it as unearned; using the real GUID avoids
    -- Warmane's link-validation rejecting a literal "0" in the GUID slot.
    local guid = (UnitGUID and UnitGUID("player")) or "0x0000000000000000"
    return string.format("|cffffff00|Hachievement:%d:%s:0:0:0:0:0:0:0:0|h[%s]|h|r", id, guid, name)
end

local function RP_Build()
    if not RP.size or not RP.raid then return "" end
    local diffStr=""
    if #RP.diffs>0 then diffStr=" "..table.concat(RP.diffs,"+") end
    local head="LFM "..RP.raid..RP.size..diffStr
    local parts={head.." /W ME NEED"}
    if RP.allMaster then
        parts[#parts+1]="ALL"
    else
        local rp={}
        for _,role in ipairs(RP_ROLES) do
            local k=role.key
            local num=RP_NUM[k] or ""
            local sel={}
            for _,cls in ipairs(role.classes) do
                if RP_SEL[k][cls] then sel[#sel+1]=cls end
            end
            local str
            if num~="" then
                str=role.generic  -- number typed: ignore class picks, show generic label
            elseif #sel>0 then
                str=table.concat(sel,"+")
            end
            if str then
                rp[#rp+1]=(num~="" and (num.."x ") or "")..str
            end
        end
        if #rp>0 then parts[#parts+1]=table.concat(rp," / ") end
    end
    local gs=RP_FormatGS(RP.gsInput)
    if gs~="" then parts[#parts+1]=gs end
    if RP.ressInput~="" then parts[#parts+1]="["..RP.ressInput.."]" end
    local ach=RP_GetAch()
    if ach~="" then parts[#parts+1]=ach end
    return table.concat(parts," ")
end

local function RP_Refresh()
    if RP_UI.preview then RP_UI.preview:SetText(RP_Build()) end
    for _,role in ipairs(RP_ROLES) do
        local btn=RP_UI.dropBtns[role.key]
        if btn then
            local sel={}
            for _,cls in ipairs(role.classes) do
                if RP_SEL[role.key][cls] then sel[#sel+1]=cls end
            end
            if #sel==0 then
                btn:SetText("|cff888888(not selected)|r")
            else
                local t=table.concat(sel,"+")
                if string.len(t)>22 then t=string.sub(t,1,20)..".." end
                btn:SetText("|cff20ff60"..t.."|r")
            end
        end
    end
end

local function RP_Active(btn,on)
    if not btn then return end
    -- AceGUI Button: indicate active state via text color only (no prefix/suffix)
    if btn.SetText and btn._baseLabel then
        if on then
            btn:SetText("|cff20ff60"..btn._baseLabel.."|r")
        else
            btn:SetText("|cffFFD100"..btn._baseLabel.."|r")
        end
        return
    end
    -- Raw RNBtn fallback
    local fs=btn._fs
    if not fs and btn.GetFontString then fs=btn:GetFontString() end
    if on then
        if fs then fs:SetTextColor(0,1,0.3) end
        RNSetBG(btn,0.04,0.20,0.08,1)
        RNBorder(btn,0.10,0.80,0.30,1)
        local hl=btn.GetHighlightTexture and btn:GetHighlightTexture()
        if hl then hl:SetVertexColor(0.10,0.80,0.30,0.25) end
    else
        if fs then fs:SetTextColor(1,0.82,0) end
        RNSetBG(btn,0.16,0.14,0.11,1)
        RNBorder(btn,0.55,0.50,0.42,0.8)
        local hl=btn.GetHighlightTexture and btn:GetHighlightTexture()
        if hl then hl:SetVertexColor(0.55,0.50,0.42,0.15) end
    end
end

local function RP_RefreshRaidBtns()
    for _,b in ipairs(RP_UI.raidBtns) do b.frame:Hide() end
    RP_UI.raidBtns={}
    if not RP_UI.raidRow or not RP.size then return end
    local row=RP_UI.raidRow
    local raids=RP_RAIDS[RP.size]
    if not raids then return end
    local order={"ICC","RS","TOGC","TOC","ULDUAR","VOA"}
    local x=0
    for _,r in ipairs(order) do
        if raids[r] then
            local btn=AceGUI:Create("Button")
            btn._baseLabel=r
            local bw = math.max(68, string.len(r)*9+30)
            btn:SetWidth(bw)
            btn.frame:SetParent(row); btn.frame:ClearAllPoints()
            btn.frame:SetPoint("LEFT",row,"LEFT",x,0)
            btn.frame:SetHeight(22); btn.frame:Show()
            x=x+bw+4
            RP_Active(btn,RP.raid==r)
            local capR=r
            btn:SetCallback("OnClick",function()
                RP.raid=capR; RP.diffs={}
                for _,b in ipairs(RP_UI.raidBtns) do RP_Active(b,b._baseLabel==capR) end
                RP_RefreshDiffBtns(); RP_Refresh()
            end)
            RP_UI.raidBtns[#RP_UI.raidBtns+1]=btn
        end
    end
end

function RP_RefreshDiffBtns()
    for _,b in ipairs(RP_UI.diffBtns) do b.frame:Hide() end
    RP_UI.diffBtns={}
    if not RP_UI.diffRow or not RP.raid or not RP.size then return end
    local diffs=RP_RAIDS[RP.size] and RP_RAIDS[RP.size][RP.raid]
    if not diffs then return end
    local x=0
    for _,d in ipairs(diffs) do
        local w=math.max(68,string.len(d)*9+30)
        local btn=AceGUI:Create("Button")
        btn._baseLabel=d
        btn:SetWidth(w)
        btn.frame:SetParent(RP_UI.diffRow); btn.frame:ClearAllPoints()
        btn.frame:SetPoint("LEFT",RP_UI.diffRow,"LEFT",x,0)
        btn.frame:SetHeight(22); btn.frame:Show()
        x=x+w+4
        RP_Active(btn,RP.diff==d)
        local capD=d
        btn:SetCallback("OnClick",function()
            local found=false
            for i,dv in ipairs(RP.diffs) do
                if dv==capD then table.remove(RP.diffs,i); found=true; break end
            end
            if not found then RP.diffs[#RP.diffs+1]=capD end
            for _,b2 in ipairs(RP_UI.diffBtns) do
                local active=false
                for _,dv in ipairs(RP.diffs) do if dv==b2._baseLabel then active=true; break end end
                RP_Active(b2,active)
            end
            RP_Refresh()
        end)
        RP_UI.diffBtns[#RP_UI.diffBtns+1]=btn
    end
end

function RP_BuildPanel(parent,divX)
    local PX=divX+12
    local DDW=CreateFrame("Frame","RPDDMenuFrame",UIParent,"UIDropDownMenuTemplate")

    local function Lbl(text,ox,y)
        local fs=parent:CreateFontString(nil,"OVERLAY","GameFontNormal")
        fs:SetPoint("TOPLEFT",parent,"TOPLEFT",PX+ox,y)
        fs:SetText(text); fs:SetTextColor(1,0.82,0)
        return fs
    end

    -- ── RESET button (AceGUI) top-right ──────────────────
    local resetBtn=AceGUI:Create("Button")
    resetBtn:SetText("Reset")
    resetBtn:SetWidth(72)
    resetBtn.frame:SetParent(parent)
    resetBtn.frame:ClearAllPoints()
    resetBtn.frame:SetPoint("TOPRIGHT",parent,"TOPRIGHT",-18,-14)
    resetBtn.frame:SetHeight(22)
    resetBtn.frame:Show()

    -- ── RAID SIZE ────────────────────────────────────────
    Lbl("Raid Size:",0,-18)
    local sizeHintFS=parent:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
    sizeHintFS:SetPoint("TOPLEFT",parent,"TOPLEFT",PX+144,-21)
    sizeHintFS:SetText("|cff555555Select raid size to start building|r")
    for i,s in ipairs({"10","25"}) do
        local btn=AceGUI:Create("Button")
        btn._baseLabel=s
        btn:SetWidth(64)
        btn.frame:SetParent(parent); btn.frame:ClearAllPoints()
        btn.frame:SetPoint("TOPLEFT",parent,"TOPLEFT",PX+(i-1)*68,-38)
        btn.frame:SetHeight(22); btn.frame:Show()
        RP_Active(btn,false)
        local capS=s
        btn:SetCallback("OnClick",function()
            RP.size=capS; RP.raid=nil; RP.diffs={}
            for _,b in ipairs(RP_UI.sizeBtns) do RP_Active(b,b._baseLabel==capS) end
            RP_RefreshRaidBtns(); RP_RefreshDiffBtns(); RP_Refresh()
        end)
        RP_UI.sizeBtns[#RP_UI.sizeBtns+1]=btn
    end

    -- ── RAID TYPE ────────────────────────────────────────
    Lbl("Raid Type:",0,-66)
    RP_UI.raidRow=CreateFrame("Frame",nil,parent)
    RP_UI.raidRow:SetPoint("TOPLEFT",parent,"TOPLEFT",PX,-86)
    RP_UI.raidRow:SetSize(520,22)

    -- ── DIFFICULTY ───────────────────────────────────────
    Lbl("Difficulty:",0,-114)
    RP_UI.diffRow=CreateFrame("Frame",nil,parent)
    RP_UI.diffRow:SetPoint("TOPLEFT",parent,"TOPLEFT",PX,-134)
    RP_UI.diffRow:SetSize(520,22)

    -- ── MASTER ALL (AceGUI CheckBox) ─────────────────────
    local allChk=AceGUI:Create("CheckBox")
    allChk:SetLabel("ALL classes (overrides all selections)")
    allChk:SetValue(false)
    allChk:SetCallback("OnValueChanged",function(widget,event,value)
        RP.allMaster=value; RP_Refresh()
    end)
    allChk.frame:SetParent(parent)
    allChk.frame:ClearAllPoints()
    allChk.frame:SetPoint("TOPLEFT",parent,"TOPLEFT",PX,-160)
    allChk.frame:SetHeight(22)
    allChk.frame:Show()
    RP_UI.allChk=allChk

    -- ── ROLE ROWS: 2x2 grid (Tanks+Melee | Healers+Ranged) ──
    local roleY = -188

    local function MakeRoleWidget(role, ox, y)
        local key=role.key
        local roleLbl=parent:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
        roleLbl:SetPoint("TOPLEFT",parent,"TOPLEFT",PX+ox,y)
        roleLbl:SetText(role.label..":"); roleLbl:SetTextColor(1,0.82,0)

        local ddBtn=AceGUI:Create("Button")
        ddBtn:SetText("(not selected)")
        ddBtn:SetWidth(190)
        ddBtn.frame:SetParent(parent); ddBtn.frame:ClearAllPoints()
        ddBtn.frame:SetPoint("TOPLEFT",parent,"TOPLEFT",PX+ox,y-18)
        ddBtn.frame:SetHeight(20); ddBtn.frame:Show()
        RP_UI.dropBtns[key]=ddBtn

        local ddOpen=false
        local capKey=key; local capRole=role
        ddBtn:SetCallback("OnClick",function(widget)
            if ddOpen and DropDownList1 and DropDownList1:IsShown() then
                CloseDropDownMenus(); ddOpen=false; return
            end
            ddOpen=true
            local menu={{text="|cffff6666Clear all|r",notCheckable=true,
                func=function() RP_SEL[capKey]={} ddOpen=false RP_Refresh() end}}
            for _,cls in ipairs(capRole.classes) do
                local capCls=cls
                menu[#menu+1]={text=cls,checked=RP_SEL[capKey][capCls] and true or false,
                    keepShownOnClick=true,
                    func=function()
                        RP_SEL[capKey][capCls]=not RP_SEL[capKey][capCls]
                        if RP_SEL[capKey][capCls] then
                            RP_NUM[capKey]=""
                            local nb=RP_UI.numBoxes[capKey]
                            if nb then nb:SetText("") end
                            if RP.allMaster then
                                RP.allMaster=false
                                if RP_UI.allChk then RP_UI.allChk:SetValue(false) end
                            end
                        end
                        RP_Refresh()
                    end}
            end
            EasyMenu(menu,DDW,widget.frame,0,0,"MENU")
        end)
        ddBtn.frame:HookScript("OnHide",function() ddOpen=false end)

        local numBG=CreateFrame("Frame",nil,parent)
        numBG:SetSize(34,20)
        numBG:SetPoint("LEFT",ddBtn.frame,"RIGHT",3,0)
        numBG:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=1})
        numBG:SetBackdropColor(0.10,0.10,0.10,0.97)
        numBG:SetBackdropBorderColor(0.55,0.50,0.42,0.8)
        local numEB=CreateFrame("EditBox",nil,numBG)
        numEB:SetAllPoints(); numEB:SetAutoFocus(false)
        numEB:SetFontObject(ChatFontNormal); numEB:SetJustifyH("CENTER")
        numEB:SetNumeric(true); numEB:SetMaxLetters(2)
        numEB:SetScript("OnTextChanged",function(self)
            local txt=self:GetText() or ""
            RP_NUM[capKey]=txt
            if txt~="" then RP_SEL[capKey]={} end  -- number clears class picks
            RP_Refresh()
        end)
        numEB:SetScript("OnEnterPressed",function(self) self:ClearFocus() end)
        RP_UI.numBoxes[key]=numEB

        local hashLbl=parent:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
        hashLbl:SetPoint("LEFT",numBG,"RIGHT",2,0)
        hashLbl:SetText("#"); hashLbl:SetTextColor(0.60,0.60,0.60)
    end

    -- Row 1: Tanks (left) + Melee DPS (right)
    MakeRoleWidget(RP_ROLES[1], 0,   roleY)
    MakeRoleWidget(RP_ROLES[3], 240, roleY)
    roleY = roleY - 42
    -- Row 2: Healers (left) + Ranged DPS (right)
    MakeRoleWidget(RP_ROLES[2], 0,   roleY)
    MakeRoleWidget(RP_ROLES[4], 240, roleY)
    roleY = roleY - 42
    -- ── GS + RESS on same row ────────────────────────────
    local extraY = roleY - 4
    local gsLbl=parent:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
    gsLbl:SetPoint("TOPLEFT",parent,"TOPLEFT",PX,extraY)
    gsLbl:SetText("GS:"); gsLbl:SetTextColor(1,0.82,0)

    local gsBG=CreateFrame("Frame",nil,parent)
    gsBG:SetSize(64,22)
    gsBG:SetPoint("TOPLEFT",parent,"TOPLEFT",PX+24,extraY-2)
    gsBG:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=1})
    gsBG:SetBackdropColor(0.10,0.10,0.10,0.97)
    gsBG:SetBackdropBorderColor(0.55,0.50,0.42,0.8)
    local gsEB=CreateFrame("EditBox",nil,gsBG)
    gsEB:SetAllPoints(); gsEB:SetAutoFocus(false)
    gsEB:SetFontObject(ChatFontNormal); gsEB:SetJustifyH("CENTER")
    gsEB:SetMaxLetters(6)
    gsEB:SetScript("OnTextChanged",function(self) RP.gsInput=self:GetText() or ""; RP_Refresh() end)
    gsEB:SetScript("OnEnterPressed",function(self) self:ClearFocus() end)
    RP_UI.gsFld=gsEB

    -- Ress label + box
    local resLbl=parent:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
    resLbl:SetPoint("LEFT",gsBG,"RIGHT",8,0)
    resLbl:SetText("Ress:"); resLbl:SetTextColor(1,0.82,0)

    local resBG=CreateFrame("Frame",nil,parent)
    resBG:SetSize(170,22)
    resBG:SetPoint("LEFT",resLbl,"RIGHT",4,0)
    resBG:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=1})
    resBG:SetBackdropColor(0.10,0.10,0.10,0.97)
    resBG:SetBackdropBorderColor(0.55,0.50,0.42,0.8)
    local resEB=CreateFrame("EditBox",nil,resBG)
    resEB:SetAllPoints(); resEB:SetAutoFocus(false)
    resEB:SetFontObject(ChatFontNormal); resEB:SetJustifyH("LEFT")
    resEB:SetTextInsets(4,4,2,2); resEB:SetMaxLetters(40)
    resEB:SetScript("OnTextChanged",function(self) RP.ressInput=self:GetText() or ""; RP_Refresh() end)
    resEB:SetScript("OnEnterPressed",function(self) self:ClearFocus() end)
    RP_UI.ressFld=resEB

    -- ── ACHIEVEMENT (AceGUI CheckBox) ────────────────────
    local achY=extraY-30
    local achChk=AceGUI:Create("CheckBox")
    achChk:SetLabel("Include Achievement link")
    achChk:SetValue(false)
    achChk:SetCallback("OnValueChanged",function(widget,event,value)
        RP.achEnabled=value; RP_Refresh()
    end)
    achChk.frame:SetParent(parent)
    achChk.frame:ClearAllPoints()
    achChk.frame:SetPoint("TOPLEFT",parent,"TOPLEFT",PX,achY-2)
    achChk.frame:SetHeight(22)
    achChk.frame:Show()
    RP_UI.achChk=achChk

    -- ── PREVIEW ──────────────────────────────────────────
    local prevY=achY-36
    Lbl("Preview:",0,prevY)
    local prevBG=CreateFrame("Frame",nil,parent)
    prevBG:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=1})
    prevBG:SetBackdropColor(0.10,0.10,0.10,0.97)
    prevBG:SetBackdropBorderColor(0.55,0.50,0.42,0.8)
    prevBG:SetPoint("TOPLEFT",parent,"TOPLEFT",PX,prevY-18)
    prevBG:SetPoint("TOPRIGHT",parent,"TOPRIGHT",-18,prevY-18)
    prevBG:SetHeight(80)
    -- MultiLine EditBox so text wraps across full width
    local prevEB=CreateFrame("EditBox",nil,prevBG)
    prevEB:SetPoint("TOPLEFT",prevBG,"TOPLEFT",6,-6)
    prevEB:SetPoint("BOTTOMRIGHT",prevBG,"BOTTOMRIGHT",-6,18)
    prevEB:SetMultiLine(true); prevEB:SetMaxLetters(0)
    prevEB:SetFontObject(ChatFontNormal)
    prevEB:SetTextColor(0.90,0.90,0.90,1)
    prevEB:SetAutoFocus(false); prevEB:EnableMouse(false)
    prevEB:SetText("")
    RP_UI.preview=prevEB

    -- Char count
    local rpCharFS=prevBG:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
    rpCharFS:SetPoint("BOTTOMRIGHT",prevBG,"BOTTOMRIGHT",-4,4)
    rpCharFS:SetTextColor(0.6,0.6,0.6); rpCharFS:SetText("Chars: 0/255")
    local _origRPR=RP_Refresh
    RP_Refresh=function()
        _origRPR()
        local msg=RP_Build()
        rpCharFS:SetText("Chars: "..string.len(msg).."/255")
    end

    -- ── SEND TO SPAMMER (AceGUI Button) ──────────────────
    local sendBtn=AceGUI:Create("Button")
    sendBtn:SetText("Send to Spammer")
    sendBtn:SetWidth(160)
    sendBtn.frame:SetParent(parent)
    sendBtn.frame:ClearAllPoints()
    sendBtn.frame:SetPoint("TOPLEFT",parent,"TOPLEFT",PX,prevY-98)
    sendBtn.frame:SetHeight(26)
    sendBtn.frame:Show()
    sendBtn:SetCallback("OnClick",function()
        local msg=RP_Build()
        if msg=="" then return end
        local pn=GetSelectedPresetName()
        if UI.msgEditBox and UI.msgEditBox:IsShown() and not IsPresetReadOnly(pn) then
            UI.msgEditBox:SetText(msg)
            local pd=GetPresetData(pn)
            if pd and not pd.isReadOnly then pd.message=msg end
            SaveActivePreset()
            SetCharacterActivePresetName(pn)
            UpdateMessageDirtyState()
            RefreshPresetActionButtons()
        end
    end)

    -- Reset callback (after all widgets are created)
    resetBtn:SetCallback("OnClick",function()
        RP.size=nil; RP.raid=nil; RP.diffs={}; RP.allMaster=false
        RP.gsInput=""; RP.ressInput=""; RP.achEnabled=false
        for _,r in ipairs(RP_ROLES) do RP_SEL[r.key]={}; RP_NUM[r.key]="" end
        for _,b in ipairs(RP_UI.sizeBtns) do RP_Active(b,false) end
        for _,b in ipairs(RP_UI.raidBtns) do b.frame:Hide() end
        RP_UI.raidBtns={}
        for _,b in ipairs(RP_UI.diffBtns) do b.frame:Hide() end
        RP_UI.diffBtns={}
        if RP_UI.allChk then RP_UI.allChk:SetValue(false) end
        if RP_UI.gsFld then RP_UI.gsFld:SetText("") end
        if RP_UI.ressFld then RP_UI.ressFld:SetText("") end
        if RP_UI.achChk then RP_UI.achChk:SetValue(false) end
        for _,role in ipairs(RP_ROLES) do
            local nb=RP_UI.numBoxes[role.key]; if nb then nb:SetText("") end
        end
        if sizeHintFS then sizeHintFS:Show() end
        RP_Refresh()
    end)

    RP_Refresh()
end

-- Hook into CreateSpammerTabContent
local _origCreateSpammer=CreateSpammerTabContent
function CreateSpammerTabContent(parent,onClose)
    local f=_origCreateSpammer(parent,onClose)
    local divX=430
    local div=f:CreateTexture(nil,"ARTWORK")
    div:SetWidth(1)
    div:SetPoint("TOPLEFT",f,"TOPLEFT",divX,-8)
    div:SetPoint("BOTTOMLEFT",f,"BOTTOMLEFT",divX,8)
    div:SetTexture("Interface\\Buttons\\WHITE8X8")
    div:SetVertexColor(0.55,0.50,0.42,0.5)
    RP_BuildPanel(f,divX)
    return f
end
