-- TOG Tools — Gratz (engine)
--
-- Profile manager for auto-congratulations on events. Each profile names
-- a single TRIGGER TYPE plus its criteria, message template, target
-- channels, and cooldown. When an event fires that matches an active
-- profile, the engine expands the message template and sends it to
-- every selected channel — combat-safe (queued + flushed on
-- PLAYER_REGEN_ENABLED).
--
-- Phase 1 trigger types:
--   "achievement"      CHAT_MSG_ACHIEVEMENT / ACHIEVEMENT_EARNED.
--                      Effectively Retail-only (Classic Era / TBC have
--                      no achievement system). Criteria: scope (guild
--                      or any visible) + min points.
--   "levelup-party"    UNIT_LEVEL on party members + GROUP_ROSTER_UPDATE
--                      bootstrap. Works on every flavour. Criteria:
--                      min / max level band.
--   "levelup-guild"    GUILD_ROSTER_UPDATE polling with a level-diff
--                      cache. Criteria: min / max level + "fire only
--                      every Nth level" interval for milestone-only
--                      gratzing.
--
-- Skip-self is hardcoded — the engine never congratulates the player
-- themselves. Per-(profile, target, eventKey) cooldown prevents
-- spamming the same gratz when an event fires multiple times in quick
-- succession (especially relevant for CHAT_MSG_ACHIEVEMENT which can
-- duplicate around loading screens).
--
-- AutoGrats (MIT, Copyright 2024 elromanov) is the pattern reference
-- for the level-up tracking: UNIT_LEVEL on the party side, periodic
-- GUILD_ROSTER_UPDATE polling on the guild side, and the deferred /
-- combat-safe send queue. The level-up event-handling shape here is
-- inspired by that addon — credit and thanks. The achievement
-- triggering is original; AutoGrats does not support achievements.

local _, addon = ...

local Gratz = {}
addon.modules["gratz"] = Gratz

local function expandMessage(template, ctx)
    if not template or template == "" then return "" end
    -- Auto-fill [guild] from the player's current guild when the fire
    -- helper hasn't set it explicitly. Lets users put [guild] in any
    -- trigger's template (individual achievements, level-ups) without
    -- the engine needing to populate ctx.guild per-helper.
    local guildName = ctx.guild
    if (not guildName or guildName == "") and GetGuildInfo then
        guildName = GetGuildInfo("player")
    end
    local out = template
    out = out:gsub("%[player%]",      ctx.player      or "")
    out = out:gsub("%[guild%]",       guildName       or "")
    out = out:gsub("%[achievement%]", ctx.achievement or "")
    out = out:gsub("%[link%]",        ctx.link        or "")
    out = out:gsub("%[points%]",      tostring(ctx.points or ""))
    out = out:gsub("%[category%]",    ctx.category    or "")
    out = out:gsub("%[level%]",       tostring(ctx.level  or ""))
    return out
end

-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
local function GratzDB()
    return addon.DB and addon.DB.global and addon.DB.global.gratz
end

local function isModuleEnabled()
    return addon:IsModuleEnabled("gratz")
end

local function playerSelfName()
    return UnitName("player") or ""
end

-- Normalise an event-supplied player name into a "Name" form (drop the
-- "-Realm" suffix when present). CHAT_MSG_ACHIEVEMENT delivers names
-- with realm on cross-realm clusters; UNIT_LEVEL gives a bare name.
-- Always-bare name is what we substitute into [player] templates so
-- chat output reads naturally.
local function shortName(name)
    if not name then return nil end
    local short = name:match("^([^%-]+)") or name
    return short
end

local function isSelf(name)
    if not name then return false end
    return shortName(name) == playerSelfName()
end

-- ---------------------------------------------------------------------------
-- Cooldown state (in-memory; not persisted)
-- ---------------------------------------------------------------------------
-- Key shape: profileName .. "|" .. target .. "|" .. eventKey
local _cooldownLastFired = {}

local function cooldownKey(profileName, target, eventKey)
    return string.format("%s|%s|%s",
        tostring(profileName or ""),
        tostring(target      or ""),
        tostring(eventKey    or ""))
end

local function onCooldown(profile, target, eventKey)
    local cd = profile.cooldown or 0
    if cd <= 0 then return false end
    local k = cooldownKey(profile.name, target, eventKey)
    local last = _cooldownLastFired[k]
    if not last then return false end
    return (GetTime() - last) < cd
end

local function markFired(profile, target, eventKey)
    local k = cooldownKey(profile.name, target, eventKey)
    _cooldownLastFired[k] = GetTime()
end

-- ---------------------------------------------------------------------------
-- Combat-safe send queue
-- ---------------------------------------------------------------------------
-- Sends from inside combat lockdown can fail for some channel types in
-- some flavours. Queue messages and drain on PLAYER_REGEN_ENABLED.
-- The queue is intentionally not persisted: messages stale-in-combat-
-- across-/reload are lower-value than the unbounded SV bloat risk.
local _combatQueue = {}

local function queuedOrSend(msg, channel, target)
    if not msg or msg == "" then return end
    if InCombatLockdown and InCombatLockdown() then
        tinsert(_combatQueue, { msg = msg, channel = channel, target = target })
    else
        SendChatMessage(msg, channel, nil, target)
    end
end

local function flushCombatQueue()
    if #_combatQueue == 0 then return end
    for _, m in ipairs(_combatQueue) do
        SendChatMessage(m.msg, m.channel, nil, m.target)
    end
    wipe(_combatQueue)
end

-- ---------------------------------------------------------------------------
-- Channel dispatch
-- ---------------------------------------------------------------------------
-- profile.channels is a map of channel-key → bool. Map keys to the
-- SendChatMessage channel-type strings; iterate enabled ones and emit.
local CHANNEL_MAP = {
    guild    = "GUILD",
    officer  = "OFFICER",
    party    = "PARTY",
    raid     = "RAID",
    say      = "SAY",
    yell     = "YELL",
    instance = "INSTANCE_CHAT",
}

-- Ordered for stable iteration / UI display.
Gratz.CHANNEL_ORDER = { "guild", "officer", "party", "raid", "say", "yell", "instance" }

local function sendToChannels(profile, msg)
    if not profile.channels then return end
    -- Pre-filter empty messages (template might evaluate to "" if
    -- placeholders aren't resolved). Avoids whispering an empty line.
    if not msg or msg == "" then return end
    for _, key in ipairs(Gratz.CHANNEL_ORDER) do
        if profile.channels[key] then
            local chanType = CHANNEL_MAP[key]
            if chanType == "GUILD" or chanType == "OFFICER" then
                if IsInGuild and IsInGuild() then
                    queuedOrSend(msg, chanType)
                end
            elseif chanType == "PARTY" or chanType == "RAID" or chanType == "INSTANCE_CHAT" then
                if IsInGroup and IsInGroup() then
                    queuedOrSend(msg, chanType)
                end
            else
                queuedOrSend(msg, chanType)
            end
        end
    end
end

-- ---------------------------------------------------------------------------
-- Profile iteration
-- ---------------------------------------------------------------------------
local function eachActiveProfile(triggerType, fn)
    if not isModuleEnabled() then return end
    local db = GratzDB()
    if not db or not db.profiles then return end
    for _, p in pairs(db.profiles) do
        if p.enabled and p.trigger == triggerType then
            fn(p)
        end
    end
end

local function tryFire(profile, ctx, target, eventKey)
    if onCooldown(profile, target, eventKey) then return end
    local msg = expandMessage(profile.message, ctx)
    if not msg or msg == "" then return end
    sendToChannels(profile, msg)
    markFired(profile, target, eventKey)
    profile.count = (profile.count or 0) + 1
end

-- ---------------------------------------------------------------------------
-- Achievement trigger
-- ---------------------------------------------------------------------------
-- ACHIEVEMENT_EARNED fires when YOU earn one (provides the achievement
-- ID — useful for points lookup). CHAT_MSG_ACHIEVEMENT fires for any
-- visible achievement (own + guild + nearby on Retail); the message
-- text contains the localised achievement name in [Brackets] and the
-- sender field has the player name. We cache the most-recent achievement
-- ID from ACHIEVEMENT_EARNED to enrich CHAT_MSG_ACHIEVEMENT's text-only
-- payload with points / category data when it's the player's own
-- achievement (the two events fire back-to-back).
local _lastOwnAchievementID = nil

-- Parse the achievement name out of a CHAT_MSG_ACHIEVEMENT message.
-- The text is a localised string like "%s has earned the achievement
-- %s!" — but %s for the player is a |Hplayer:...|h[Name]|h link AND
-- %s for the achievement is a |Hachievement:...|h[AchName]|h link.
-- The first [bracketed] token is the player name, the SECOND is the
-- achievement name we want. The greedy `.*` consumes everything up
-- to the last [ so the captured group is the LAST bracketed token.
local function extractAchievementName(text)
    if not text then return nil end
    local name = text:match(".*%[(.-)%]")
    return name or text
end

-- Guild-as-entity achievement (the guild itself earning something like
-- "Classy Undead" / "Working as a Team"). CHAT_MSG_GUILD_ACHIEVEMENT
-- delivers these with arg2 = the guild name (no realm suffix). The
-- per-member scope check from the individual path makes no sense here
-- (the "sender" isn't a player), so this has its own fire helper that
-- maps [player] / [guild] to the guild name for templating naturally.
local function fireGuildEntityAchievement(guildName, achievementName, achievementID)
    addon:Debug(string.format(
        "[gratz] fireGuildEntityAchievement guild=%s ach=%s id=%s",
        tostring(guildName), tostring(achievementName), tostring(achievementID)))

    local points, category, link
    if achievementID and GetAchievementInfo then
        -- (ok, id, name, points). Same destructure as fireAchievement.
        local ok, _, name, p = pcall(GetAchievementInfo, achievementID)
        if ok and name then
            achievementName = name
            points = p
        end
        if GetCategoryInfo and GetAchievementCategory then
            local ok2, catID = pcall(GetAchievementCategory, achievementID)
            if ok2 and catID then
                local ok3, catName = pcall(GetCategoryInfo, catID)
                if ok3 then category = catName end
            end
        end
        if GetAchievementLink then
            local ok4, l = pcall(GetAchievementLink, achievementID)
            if ok4 then link = l end
        end
    end

    local ctx = {
        player      = guildName or "the guild",
        guild       = guildName or "the guild",
        achievement = achievementName or "?",
        link        = link or ("[" .. (achievementName or "") .. "]"),
        points      = points or 0,
        category    = category or "",
    }

    local profileCount, fireCount = 0, 0
    eachActiveProfile("achievement-guild", function(profile)
        profileCount = profileCount + 1
        addon:Debug(string.format(
            "[gratz]   considering profile '%s' minPts=%d",
            tostring(profile.name), profile.minPoints or 0))
        if (profile.minPoints or 0) > 0 and (ctx.points or 0) < profile.minPoints then
            addon:Debug(string.format(
                "[gratz]   skip: ctx.points=%d < profile.minPoints=%d",
                ctx.points or 0, profile.minPoints))
            return
        end
        local before = profile.count or 0
        tryFire(profile, ctx, guildName or "guild",
            "guildach:" .. tostring(achievementID or achievementName or ""))
        local after = profile.count or 0
        if after > before then
            fireCount = fireCount + 1
            addon:Debug(string.format("[gratz]   FIRED profile '%s'", tostring(profile.name)))
        else
            addon:Debug("[gratz]   skip: tryFire didn't fire (cooldown or empty message)")
        end
    end)
    addon:Debug(string.format(
        "[gratz] result: active profiles=%d, fired=%d", profileCount, fireCount))
end

--- `fromGuildChannel`  true when the event came from CHAT_MSG_GUILD_ACHIEVEMENT
--- (Blizzard guarantees server-side that those messages only ship to guild
--- members about guild members, including cross-realm ones — so the local
--- roster lookup below is redundant and must be skipped, otherwise cross-
--- realm guildies like Name-OtherRealm whose roster representation differs
--- from arg2's representation get filtered out incorrectly).
local function fireAchievement(senderName, achievementName, achievementID, isSelfEvent, fromGuildChannel)
    addon:Debug(string.format(
        "[gratz] fireAchievement sender=%s ach=%s id=%s self=%s guildChan=%s",
        tostring(senderName), tostring(achievementName),
        tostring(achievementID), tostring(isSelfEvent), tostring(fromGuildChannel)))
    if isSelfEvent then
        addon:Debug("[gratz]   skipping: self event")
        return
    end

    local points, category, link
    if achievementID and GetAchievementInfo then
        -- GetAchievementInfo returns (id, name, points, completed, ...).
        -- pcall prepends a success bool, so the destructure is
        -- (ok, id, name, points). Earlier version had an off-by-one
        -- here and was assigning `points` (number) into achievementName
        -- — Gratzes rendered "Gratz Spedo on 10!" instead of the real
        -- achievement name.
        local ok, _, name, p = pcall(GetAchievementInfo, achievementID)
        if ok and name then
            achievementName = name
            points = p
        end
        if GetCategoryInfo and GetAchievementCategory then
            local ok2, catID = pcall(GetAchievementCategory, achievementID)
            if ok2 and catID then
                local ok3, catName = pcall(GetCategoryInfo, catID)
                if ok3 then category = catName end
            end
        end
        if GetAchievementLink then
            local ok4, l = pcall(GetAchievementLink, achievementID)
            if ok4 then link = l end
        end
    end

    local short = shortName(senderName) or "?"
    local ctx = {
        player      = short,
        achievement = achievementName or "?",
        link        = link or ("[" .. (achievementName or "") .. "]"),
        points      = points or 0,
        category    = category or "",
    }

    local profileCount, fireCount = 0, 0
    eachActiveProfile("achievement", function(profile)
        profileCount = profileCount + 1
        addon:Debug(string.format(
            "[gratz]   considering profile '%s' scope=%s minPts=%d",
            tostring(profile.name), tostring(profile.scope), profile.minPoints or 0))

        if profile.scope == "guild" then
            if not (IsInGuild and IsInGuild()) then
                addon:Debug("[gratz]   skip: not in a guild")
                return
            end
            -- Trust CHAT_MSG_GUILD_ACHIEVEMENT — Blizzard only ships
            -- those to guild members about guild members. Cross-realm
            -- guildies arrive as "Name-Realm" in arg2 while the roster
            -- may store them differently (no realm suffix on some
            -- flavours, "Name-Realm" on others, async-loading entries
            -- briefly missing), so the local roster lookup is both
            -- redundant and unreliable. Only run the roster check for
            -- CHAT_MSG_ACHIEVEMENT, which fires for nearby non-guildies
            -- on Retail and genuinely needs the filter.
            if not fromGuildChannel and GetNumGuildMembers then
                local n = GetNumGuildMembers() or 0
                local inGuild = false
                for i = 1, n do
                    local memberName = GetGuildRosterInfo and GetGuildRosterInfo(i) or nil
                    if memberName and shortName(memberName) == short then
                        inGuild = true
                        break
                    end
                end
                if not inGuild then
                    addon:Debug(string.format(
                        "[gratz]   skip: sender '%s' not found in guild roster (n=%d)",
                        short, n))
                    return
                end
            end
        end
        if (profile.minPoints or 0) > 0 and (ctx.points or 0) < profile.minPoints then
            addon:Debug(string.format(
                "[gratz]   skip: ctx.points=%d < profile.minPoints=%d",
                ctx.points or 0, profile.minPoints))
            return
        end
        local before = profile.count or 0
        tryFire(profile, ctx, short, "ach:" .. tostring(achievementID or achievementName or ""))
        local after = profile.count or 0
        if after > before then
            fireCount = fireCount + 1
            addon:Debug(string.format("[gratz]   FIRED profile '%s'", tostring(profile.name)))
        else
            addon:Debug(string.format("[gratz]   skip: tryFire didn't fire (cooldown or empty message)"))
        end
    end)
    addon:Debug(string.format(
        "[gratz] result: active profiles=%d, fired=%d", profileCount, fireCount))
end

-- ---------------------------------------------------------------------------
-- Level-up triggers (party + guild)
-- ---------------------------------------------------------------------------
-- Pattern adapted from AutoGrats (MIT). Tracker maps store
-- last-known levels per player so we can detect a transition. Party
-- tracker keys by short name; guild tracker keys by short name.
local _partyTracker  = {}   -- [shortName] = level
local _guildTracker  = {}   -- [shortName] = level

local LEVEL_DELAY_SEC = 1.0   -- short delay before reading UnitLevel after UNIT_LEVEL so the new value has settled

local function firePartyLevelUp(unitName, newLevel)
    if not newLevel or newLevel <= 0 then return end
    if isSelf(unitName) then return end
    local short = shortName(unitName) or "?"
    local ctx = {
        player = short,
        level  = newLevel,
    }
    eachActiveProfile("levelup-party", function(profile)
        if (profile.minLevel or 0) > 0 and newLevel < profile.minLevel then return end
        if (profile.maxLevel or 0) > 0 and newLevel > profile.maxLevel then return end
        tryFire(profile, ctx, short, "lvl:" .. newLevel)
    end)
end

local function fireGuildLevelUp(short, newLevel)
    if not newLevel or newLevel <= 0 then return end
    if isSelf(short) then return end
    local ctx = {
        player = short,
        level  = newLevel,
    }
    eachActiveProfile("levelup-guild", function(profile)
        if (profile.minLevel or 0) > 0 and newLevel < profile.minLevel then return end
        if (profile.maxLevel or 0) > 0 and newLevel > profile.maxLevel then return end
        local interval = profile.guildInterval or 1
        if interval > 1 and (newLevel % interval) ~= 0 then return end
        tryFire(profile, ctx, short, "lvl:" .. newLevel)
    end)
end

local function refreshPartyTracker()
    -- Rebuild the party tracker from the current roster. Fire-on-up if
    -- a player we already knew about is now higher than we recorded.
    if not GetNumGroupMembers then return end
    local n = GetNumGroupMembers() or 0
    if n <= 1 then
        wipe(_partyTracker)
        return
    end
    -- The "player" unit is the current player; include them only for
    -- self-detection (isSelf check above handles skip).
    for i = 1, n do
        local unit = "party" .. i
        local name = UnitName and UnitName(unit) or nil
        if name then
            local level = UnitLevel and UnitLevel(unit) or 0
            if level > 0 then
                local short = shortName(name)
                local prev = _partyTracker[short]
                if prev and level > prev then
                    firePartyLevelUp(short, level)
                end
                _partyTracker[short] = level
            end
        end
    end
end

local function refreshGuildTracker()
    if not (IsInGuild and IsInGuild()) then
        wipe(_guildTracker)
        return
    end
    if not GetNumGuildMembers then return end
    -- The 60s polling we register below requests a fresh roster pull
    -- before this function reads it. On the first refresh after login
    -- we initialise without firing (we don't know prior levels).
    local firstSync = next(_guildTracker) == nil
    local n = GetNumGuildMembers() or 0
    for i = 1, n do
        local name, _, _, level = GetGuildRosterInfo and GetGuildRosterInfo(i) or nil, nil, nil, 0
        if name and level and level > 0 then
            local short = shortName(name)
            local prev = _guildTracker[short]
            if (not firstSync) and prev and level > prev then
                fireGuildLevelUp(short, level)
            end
            _guildTracker[short] = level
        end
    end
end

-- ---------------------------------------------------------------------------
-- Event wiring
-- ---------------------------------------------------------------------------
local _evtFrame = CreateFrame("Frame")
_evtFrame:RegisterEvent("PLAYER_LOGIN")
_evtFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
_evtFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
_evtFrame:RegisterEvent("UNIT_LEVEL")
_evtFrame:RegisterEvent("GUILD_ROSTER_UPDATE")
-- CHAT_MSG_ACHIEVEMENT fires for the player's own achievements + nearby
-- same-realm players. CHAT_MSG_GUILD_ACHIEVEMENT (despite its name
-- suggesting guild-as-entity achievements) is the event that fires for
-- INDIVIDUAL GUILD MEMBER achievements in guild chat — including
-- cross-realm members, which CHAT_MSG_ACHIEVEMENT silently drops on
-- Retail. Register both; pcall in case either is missing on older
-- Classic flavours (Era / TBC have no achievement system at all).
pcall(function() _evtFrame:RegisterEvent("CHAT_MSG_ACHIEVEMENT") end)
pcall(function() _evtFrame:RegisterEvent("CHAT_MSG_GUILD_ACHIEVEMENT") end)
-- ACHIEVEMENT_EARNED exists only on flavours with the achievement
-- system; pcall-register to avoid an error on Classic Era / TBC.
pcall(function() _evtFrame:RegisterEvent("ACHIEVEMENT_EARNED") end)

_evtFrame:SetScript("OnEvent", function(_, event, arg1, arg2)
    if event == "PLAYER_LOGIN" then
        -- Bootstrap trackers after the world settles.
        C_Timer.After(3, function()
            refreshPartyTracker()
            if IsInGuild and IsInGuild() then
                if C_GuildInfo and C_GuildInfo.GuildRoster then
                    C_GuildInfo.GuildRoster()
                elseif GuildRoster then
                    GuildRoster()
                end
            end
        end)

    elseif event == "PLAYER_REGEN_ENABLED" then
        flushCombatQueue()

    elseif event == "GROUP_ROSTER_UPDATE" then
        refreshPartyTracker()

    elseif event == "UNIT_LEVEL" then
        local unit = arg1
        if unit and UnitInParty and UnitInParty(unit) and not isSelf(UnitName(unit)) then
            -- Defer so UnitLevel has time to update on this unit.
            C_Timer.After(LEVEL_DELAY_SEC, function()
                local name  = UnitName(unit)
                local level = UnitLevel and UnitLevel(unit) or 0
                if name and level > 0 then
                    local short = shortName(name)
                    local prev  = _partyTracker[short]
                    if prev and level > prev then
                        firePartyLevelUp(short, level)
                    end
                    _partyTracker[short] = level
                end
            end)
        end

    elseif event == "GUILD_ROSTER_UPDATE" then
        refreshGuildTracker()

    elseif event == "CHAT_MSG_ACHIEVEMENT" or event == "CHAT_MSG_GUILD_ACHIEVEMENT" then
        addon:Debug(string.format(
            "[gratz] %s arg1=%s arg2=%s",
            event, tostring(arg1), tostring(arg2)))
        local achievementName = extractAchievementName(arg1)
        local senderName = arg2 or ""
        local selfEvent = isSelf(senderName)
        -- Achievement link is embedded in arg1 as
        -- |Hachievement:ID:PlayerGUID:1:Y:M:D:Flags|h[Name]|h. print()
        -- renders it as just "[Name]" so it looks like plain text in
        -- debug output, but the string still carries the markup.
        local parsedID = tonumber(arg1 and arg1:match("|Hachievement:(%d+)") or "")
        local achievementID = parsedID or (selfEvent and _lastOwnAchievementID or nil)
        addon:Debug(string.format(
            "[gratz]   parsedID=%s lastOwnID=%s finalID=%s",
            tostring(parsedID), tostring(_lastOwnAchievementID), tostring(achievementID)))

        -- Discriminate between an INDIVIDUAL guildmate's achievement and
        -- a GUILD-AS-ENTITY achievement. Blizzard delivers the second
        -- form via the same event but with arg2 = the guild's name
        -- (no realm suffix, no character link). The per-member scope
        -- check on the individual path would fail to find "The Straw
        -- Hats" in the guild roster, so we route those to a dedicated
        -- fire helper that maps [player] / [guild] to the guild name.
        local playerGuild = GetGuildInfo and GetGuildInfo("player") or nil
        if playerGuild and senderName == playerGuild then
            fireGuildEntityAchievement(playerGuild, achievementName, achievementID)
        else
            local fromGuildChannel = (event == "CHAT_MSG_GUILD_ACHIEVEMENT")
            fireAchievement(senderName, achievementName, achievementID, selfEvent, fromGuildChannel)
        end
        if selfEvent then _lastOwnAchievementID = nil end

    elseif event == "ACHIEVEMENT_EARNED" then
        -- ACHIEVEMENT_EARNED fires for YOUR own achievements just
        -- before the corresponding CHAT_MSG_ACHIEVEMENT. Cache the ID
        -- so the chat handler can enrich [points] / [link] / [category]
        -- via GetAchievementInfo lookups.
        _lastOwnAchievementID = arg1
    end
end)

-- Guild-level-up polling: AutoGrats uses a 60-second ticker because
-- GUILD_ROSTER_UPDATE only fires when the roster CHANGES, and a
-- level-up alone may not trigger a roster update on every client.
-- C_Timer keeps the ticker reference internally, no local needed.
C_Timer.NewTicker(60, function()
    if not isModuleEnabled() then return end
    if not (IsInGuild and IsInGuild()) then return end
    if C_GuildInfo and C_GuildInfo.GuildRoster then
        C_GuildInfo.GuildRoster()
    elseif GuildRoster then
        GuildRoster()
    end
    -- GuildRoster() fires GUILD_ROSTER_UPDATE which calls
    -- refreshGuildTracker — no need to call it directly here.
end)

-- ---------------------------------------------------------------------------
-- Public API consumed by GUI/GratzTab.lua
-- ---------------------------------------------------------------------------
function Gratz:GetProfiles()
    local db = GratzDB()
    if not db or not db.profiles then return {} end
    local list = {}
    for _, p in pairs(db.profiles) do tinsert(list, p) end
    table.sort(list, function(a, b) return (a.name or "") < (b.name or "") end)
    return list
end

function Gratz:GetProfile(name)
    local db = GratzDB()
    return db and db.profiles and db.profiles[name] or nil
end

function Gratz:SaveProfile(profile)
    local db = GratzDB()
    if not db then return end
    db.profiles = db.profiles or {}
    db.profiles[profile.name] = profile
end

function Gratz:DeleteProfile(name)
    local db = GratzDB()
    if not db or not db.profiles then return end
    db.profiles[name] = nil
end

-- Default-shape factory for a fresh profile. Sub-tab uses this to
-- populate the form for "new" state.
function Gratz:NewProfileDefaults()
    return {
        name          = "",
        enabled       = true,
        trigger       = "achievement",
        scope         = "guild",
        minPoints     = 0,
        minLevel      = 1,
        maxLevel      = 999,
        guildInterval = 1,
        message       = "Gratz [player] on [achievement]! [link]",
        channels      = { guild = true },
        cooldown      = 300,
        count         = 0,
    }
end

-- Engine entry for the per-module toggle framework (called by AceAddon
-- after DB init). No work to do — event hooks are registered at file-
-- load time and the eachActiveProfile gates check the enabled flag.
function Gratz:OnEnable() end
