-- 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"    Our OWN throttled read of the guild roster. It was
--                      LibGuildRoster-1.0's OnMemberLevelChanged until that
--                      callback stopped existing; the tracker at the bottom
--                      of this file carries the whole story.
--                      Criteria: min / max level + "fire only every Nth
--                      level" interval for milestone-only gratzing.
--                      Only fires for a member seen ONLINE at both ends of
--                      the change: the roster carries a last-known level for
--                      offline members, so a returning player is not a live
--                      level-up.
--
-- 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).
--
-- The guild side READS THE ROSTER ITSELF, and that is a reversal of what
-- this file used to say. LibGuildRoster-1.0 still owns presence, guild
-- membership and every other piece of roster state, and nothing here
-- duplicates that -- but it no longer reports level changes at all, so
-- levels are the one thing this module has to see for itself. The full
-- account, including why the polling belongs here rather than in the
-- library, is at the tracker near the bottom of this file.
--
-- AutoGrats (MIT, Copyright 2024 elromanov) is the pattern reference for
-- the party side: UNIT_LEVEL tracking and the deferred / combat-safe
-- send queue — credit and thanks. Its guild side polls the roster API on
-- a timer and reads no online flag; this deliberately does neither. The
-- achievement triggering is original; AutoGrats does not support
-- achievements.

local _, addon = ...

-- Hard dependency (## Dependencies: GuildRoster). The `true` silent flag
-- keeps a missing lib from erroring at file scope on a broken install;
-- the achievement scope filter below simply stops narrowing.
--
-- THE `MINOR >= 10` GATE THAT USED TO BE HERE IS GONE, DELIBERATELY. It
-- guarded OnMemberLevelChanged, which no copy of the library fires any more
-- (see the guild level tracker below), so keeping it would have been a
-- version check on a feature nobody supplies -- and a refusal message that
-- named the wrong cause. What is left of the dependency is IsReady and
-- IsInGuild for the achievement path, and both have existed since MINOR 5,
-- which is older than any copy in this tree including the vendored one
-- FastGuildInvite ships.
local LGR
if LibStub then
    LGR = LibStub("LibGuildRoster-1.0", true)
end

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
    -- Every PRODUCER is gated, so the queue only ever fills while enabled -- but the gate covered
    -- the decision to speak and not the speaking. A user who reacts to an unwanted gratz by
    -- switching the module off DURING the fight that queued it would still have it sent when combat
    -- ended, because nothing re-checked here and nothing wiped the queue.
    --
    -- Discard rather than hold: a queued congratulation is worthless once the moment has passed, and
    -- holding it means re-enabling the module weeks later replays a message about a level-up nobody
    -- remembers. Wiping is the behaviour a user who switched it off is asking for.
    if not isModuleEnabled() then
        wipe(_combatQueue)
        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" }

-- Membership per channel lives in addon.Chat.ChannelAvailable (Compat.lua), NOT
-- here. Audit finding tog-AQ was a bare `IsInGroup()` standing in for three
-- different tests -- and tog-AT found the SAME defect in Smack's copy of this
-- function, so the predicate was moved rather than fixed twice. Compat.lua carries
-- Blizzard's own four-way table and the reasoning.
--
-- Resolved at CALL time, not aliased at file scope: that keeps this module free of
-- any load-order dependency on Compat.lua, which is the hazard finding tog-AC
-- created when it made the file-scope alias the house idiom.

--- Returns true when at least one channel actually received the message (or had it
--- queued for the end of combat, which is the same promise deferred). tryFire needs
--- the answer: counting a fire that reached nobody made the tab's Count column --
--- "Total times this profile has fired since creation" -- untrue. Finding tog-AR.
local function sendToChannels(profile, msg)
    if not profile.channels then return false 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 false end
    local sent = false
    for _, key in ipairs(Gratz.CHANNEL_ORDER) do
        if profile.channels[key] then
            local chanType = CHANNEL_MAP[key]
            if chanType and addon.Chat.ChannelAvailable(chanType) then
                queuedOrSend(msg, chanType)
                sent = true
            end
        end
    end
    return sent
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
    -- Sorted, not pairs(). Two profiles on the same trigger each emit their OWN chat
    -- line, so hash order decided which of the two messages appeared first and the
    -- pair could swap between two identical events. Sorting by name matches
    -- Gratz:GetProfiles and the tab's own default sort, and it is a TOTAL order
    -- because db.profiles is keyed by name. Audit finding tog-AS.
    local matched = {}
    for _, p in pairs(db.profiles) do
        if p.enabled and p.trigger == triggerType then
            tinsert(matched, p)
        end
    end
    table.sort(matched, function(a, b) return (a.name or "") < (b.name or "") end)
    for _, p in ipairs(matched) do
        fn(p)
    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
    -- Only a message that actually reached a channel counts as a fire. A profile
    -- whose only ticked channel is unavailable (guild-only while guildless, raid-only
    -- while in a 5-man) used to increment the tab's Count column and arm the cooldown
    -- having said nothing at all. Finding tog-AR.
    if not sendToChannels(profile, msg) then return end
    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, empty message, or no channel available)")
        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, so the
            -- membership check is redundant there. Only run it for
            -- CHAT_MSG_ACHIEVEMENT, which fires for nearby non-guildies
            -- on Retail and genuinely needs the filter.
            --
            -- LGR:IsInGuild normalizes both sides, which is what the old
            -- hand-rolled roster scan kept getting wrong: arg2 arrives as
            -- "Name-Realm" for cross-realm guildies while the roster may
            -- store a bare name, and a member still streaming in was
            -- simply absent. Skipped entirely until the lib is ready,
            -- rather than filtering against a roster we know is partial.
            if not fromGuildChannel and LGR and LGR:IsReady() then
                if not LGR:IsInGuild(senderName) then
                    addon:Debug(string.format(
                        "[gratz]   skip: sender '%s' is not a guild member", short))
                    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("[gratz]   skip: tryFire didn't fire (cooldown, empty message, or no channel available)")
        end
    end)
    addon:Debug(string.format(
        "[gratz] result: active profiles=%d, fired=%d", profileCount, fireCount))
end

-- ---------------------------------------------------------------------------
-- Level-up triggers (party + guild)
-- ---------------------------------------------------------------------------
-- Party tracker keys by short name and is fed from UNIT_LEVEL (pattern
-- adapted from AutoGrats, MIT). The guild tracker is a separate table
-- further down, keyed by the roster's own name string.
local _partyTracker  = {}   -- [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

-- A level increase only counts as a gratz when the member was online at
-- BOTH ends of the change. The scan below sees any change, decreases
-- included, and deliberately makes no judgement -- this is where the policy
-- lives. Requiring only "online now" would still let a stale roster level
-- that corrects itself on a later read announce a ding that happened while
-- the member was offline, days ago.
local function guildLevelUpQualifies(oldLevel, newLevel, wasOnline, isOnline)
    if not (oldLevel and newLevel) then return false end
    if newLevel <= oldLevel then return false end
    return (wasOnline and isOnline) and true or false
end

-- ---------------------------------------------------------------------------
-- Guild level tracker
-- ---------------------------------------------------------------------------
-- THIS USED TO BE `LGR.RegisterCallback(Gratz, "OnMemberLevelChanged", ...)`,
-- AND THAT CALLBACK CANNOT FIRE ANY MORE. LibGuildRoster-1.0 MINOR 14 replaced
-- the post-login rebuild with build-once: the roster is built exactly once and
-- never rebuilt, so the diff that produced OnMemberLevelChanged was deleted with
-- it. The library still ACCEPTS a registration against the name -- registration
-- is harmless, it simply never fires -- which is why this module looked correctly
-- wired while the guild trigger was dead. See LibGuildRoster-1.0.lua:121-136 and
-- the note at its old diff site, :1713-1741.
--
-- There is nothing to move the trigger to. Nothing in Classic Era announces a
-- guildmate levelling: there is no CHAT_MSG_SYSTEM message for it, and
-- GUILD_NEWS_FORMAT6 ("%s has reached level %d!") belongs to Cataclysm's Guild
-- News feed, which this flavour does not have. Reading the roster is the only
-- way to see a level change, and that is exactly the cost build-once exists to
-- remove FROM A LIBRARY TWENTY ADDONS SHARE. So the read lives here, in the one
-- feature that needs it, paid for only by users who have switched it on.
--
-- Three things keep that honest:
--   * ARMED ONLY WHILE THE FEATURE IS IN USE. No enabled levelup-guild profile
--     means no request, no scan and no table. Everyone else pays nothing.
--   * THROTTLED AT THE SCAN, NOT THE REQUEST. GUILD_ROSTER_UPDATE is fired in a
--     loop by the client's own UI and by every other addon that asks for a
--     roster, so the event is not a rate limit and treating it as one is what
--     makes an O(N) scan expensive.
--   * NEVER TOUCHES SetGuildRosterShowOffline. Writing that flag from inside a
--     GUILD_ROSTER_UPDATE handler fires another GUILD_ROSTER_UPDATE; the library
--     shipped that loop once and it read as an FPS decay, not a crash. Iteration
--     is unfiltered on both flavours anyway (measured, 997 of 997 rows with the
--     box unticked), so there is nothing to gain by writing it.
local GUILD_SCAN_THROTTLE_SEC = 5    -- floor between scans, however often the event arrives
local GUILD_POLL_SEC          = 60   -- how often we ask the server for a fresh roster while armed
local GUILD_POLL_FIRST_SEC    = 10   -- first request after login, once the client has settled

local _guildLevels   = {}    -- [rosterName] = { level = n, online = bool }
local _guildBaseline = nil   -- guild the table above describes; a transfer invalidates it
-- nil, NOT 0, and the difference is the very first scan. GetTime() is seconds
-- since the CLIENT started, so it is only near zero when the addon loads on the
-- login screen -- and `0 - 0 >= 5` is false, which would silently skip the
-- baseline scan and hand the throttle a five-second head start it never earned.
local _lastGuildScan = nil

-- The bare global GuildRoster() is a deprecation fallback on every current
-- flavour and gone outright on some, so the namespaced form is what a client
-- actually prefers. Feature-detect rather than assume: calling a nil global here
-- would raise inside a timer, where the traceback names nothing useful.
local function requestGuildRoster()
    if C_GuildInfo and C_GuildInfo.GuildRoster then
        C_GuildInfo.GuildRoster()
    elseif _G.GuildRoster then
        _G.GuildRoster()
    end
end

--- Is any of this worth doing right now? Answered fresh each time rather than
--- latched at load, because a profile can be created, enabled or deleted mid-
--- session and nothing notifies this module when it happens.
local function guildTrackerArmed()
    if not isModuleEnabled() then return false end
    local db = GratzDB()
    if not db or not db.profiles then return false end
    for _, p in pairs(db.profiles) do
        if p.enabled and p.trigger == "levelup-guild" then return true end
    end
    return false
end

local function scanGuildLevels()
    if not guildTrackerArmed() then return end
    if not (IsInGuild and IsInGuild()) then
        wipe(_guildLevels)
        _guildBaseline = nil
        return
    end
    if not (GetNumGuildMembers and GetGuildRosterInfo) then return end

    -- A guild transfer would leave every recorded level attached to a roster it
    -- did not come from. Drop the lot and re-baseline against the new guild.
    local guildName = GetGuildInfo and GetGuildInfo("player") or nil
    if guildName ~= _guildBaseline then
        wipe(_guildLevels)
        _guildBaseline = guildName
    end

    local n = GetNumGuildMembers() or 0
    for i = 1, n do
        local name, _, _, level, _, _, _, _, online = GetGuildRosterInfo(i)
        -- A row can arrive carrying a name and NO level while the roster streams
        -- in. Record nothing for it. Writing a placeholder is what manufactures a
        -- phantom ding: the member goes down at 1, the next scan reads their real
        -- level, and (1 -> 60) is indistinguishable from the real thing. The
        -- library shipped exactly that bug at MINOR 9 and Gratz announced it.
        if name and level and level > 0 then
            local isOn = online and true or false
            local prev = _guildLevels[name]
            if prev then
                if guildLevelUpQualifies(prev.level, level, prev.online, isOn) then
                    fireGuildLevelUp(shortName(name) or name, level)
                end
                prev.level, prev.online = level, isOn
            else
                -- First sighting is a baseline and never a fire -- that covers
                -- both the login scan and a member who joined since.
                _guildLevels[name] = { level = level, online = isOn }
            end
        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")
-- Registered unconditionally, acted on only while the guild tracker is armed.
-- The handler's own throttle is what makes that safe: this event is fired in a
-- loop by the client and by every other addon that requests a roster.
_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
        C_Timer.After(3, refreshPartyTracker)
        -- The guild poll. Delayed once, then on an interval, and it only ever
        -- REQUESTS -- the scan happens on the GUILD_ROSTER_UPDATE that comes
        -- back, which is also how we pick up rosters other addons asked for.
        -- Nothing here is a bootstrap: the first scan is a baseline that fires
        -- nothing, which is what makes a login look silent rather than like
        -- everyone in the guild dinging at once.
        C_Timer.After(GUILD_POLL_FIRST_SEC, function()
            if guildTrackerArmed() then requestGuildRoster() end
            C_Timer.NewTicker(GUILD_POLL_SEC, function()
                if guildTrackerArmed() then requestGuildRoster() end
            end)
        end)

    elseif event == "PLAYER_REGEN_ENABLED" then
        flushCombatQueue()

    elseif event == "GROUP_ROSTER_UPDATE" then
        refreshPartyTracker()

    elseif event == "GUILD_ROSTER_UPDATE" then
        -- Throttled HERE rather than inside the scan, so an unarmed session
        -- costs one subtraction per event instead of a walk of the profile
        -- table on each of them.
        local t = (GetTime and GetTime()) or 0
        if not _lastGuildScan or (t - _lastGuildScan) >= GUILD_SCAN_THROTTLE_SEC then
            _lastGuildScan = t
            scanGuildLevels()
        end

    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 == "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)

-- ---------------------------------------------------------------------------
-- 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
