-- TOG Tools — Guild Log (engine)
--
-- Snapshots Blizzard's in-game guild event log and persists entries
-- beyond the ~100-entry cap Blizzard keeps. Per-guild buckets so an
-- account with alts in multiple guilds keeps independent histories.
--
-- Recipe:
--   * On PLAYER_LOGIN + on a periodic ticker (and on the first
--     GUILD_ROSTER_UPDATE after login), call GuildEventLog_QueryGuild().
--   * Wait for GUILD_EVENT_LOG_UPDATE to fire — Blizzard's signal that
--     the buffered log is ready.
--   * Walk it via GetNumGuildEvents() + GetGuildEventInfo(i), dedupe
--     against persisted entries on the (y,m,d,h,type,target) tuple,
--     and append anything new.
--
-- Timestamp precision: GetGuildEventInfo returns (year, month, day,
-- hour) only — no minute or second. We store the tuple verbatim and
-- synthesise a coarse `ts` from it for sorting/filtering. Two events
-- in the same hour with the same type+target collapse in dedupe; that's
-- a known precision limit, not a bug.
--
-- Permissions: not every guild member can read the event log. The
-- query silently returns zero entries when the player lacks the
-- privilege — module just stays empty until they get the rank or log
-- into an alt that does.
--
-- Sub-category registration mirrors Mail / Trade: addon.logCategories
-- ["guild"], NOT addon.modules. Sub-tab Draw merged on by
-- GUI/GuildLogSubTab.lua.

local _, addon = ...

local GuildLog = {}
addon.logCategories = addon.logCategories or {}
addon.logCategories["guild"] = GuildLog

GuildLog.subKey    = "guild"
GuildLog.label     = "Guild"
GuildLog.configKey = "guildLog"
GuildLog.help = {
    title = "Logs > Guild",
    lines = {
        "Snapshots Blizzard's in-game guild event log and persists entries beyond the ~100-entry cap Blizzard keeps. Per-guild buckets so alts in different guilds keep independent histories.",
        "|cffffd700Auto-capture:|r runs 3 s after every PLAYER_LOGIN, on every GUILD_ROSTER_UPDATE, and on a 2-minute safety ticker. New events from your guild's history land here automatically — no manual refresh needed.",
        "|cffffd700Filters:|r Guild (defaults to your current guild; switch to All for cross-guild view), Type (Joined / Left / Kicked / Promoted / Demoted / Invited / Quit), Date range.",
        "|cffffd700Type column|r is colour-coded: |cff66ff66green|r = join, |cffffcc66yellow|r = leave, |cffff5555red|r = kicked, |cff66ccffblue|r = promote, |cffffaa55orange|r = demote.",
        "|cffffd700Time precision:|r Blizzard's API only exposes hour-coarse timestamps for guild events (no minute / second), so the time column rounds to the hour. Older entries show as \"MM/DD HH:00\".",
        "|cffffd700Permission gated:|r if your character doesn't have View Event Log rights, Blizzard returns no events and the log stays empty for that alt — log into an alt with access to populate.",
        "|cffffd700Diagnostic:|r |cffffd700/togt glcheck|r prints API state + Blizzard buffer contents to chat. Useful if the log unexpectedly stops capturing.",
    },
}

-- How often to re-query Blizzard's event log. GUILD_ROSTER_UPDATE is the
-- primary discovery path — it fires on every join / leave / kick /
-- promote / demote, which is every event type the log captures EXCEPT
-- "invite sent but not yet accepted". The periodic ticker is the
-- safety net for the invite case (an officer invites someone and
-- nobody triggers a roster update before the player notices).
-- GuildEventLog_QueryGuild is a local-buffer read, not a server call,
-- so a tight 2-minute cadence is cheap.
local QUERY_INTERVAL_SEC = 2 * 60

-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
local function GuildLogDB()
    return addon.DB and addon.DB.global and addon.DB.global.guildLog
end

local function isEnabled()
    return addon:IsLogCategoryEnabled("guild")
end

-- The "guild key" we partition entries under. Guilds are realm-bound,
-- so the player's realm doubles as the guild's realm — sufficient to
-- disambiguate same-name guilds on different realms within the same
-- account.
local function GuildKey()
    if not IsInGuild or not IsInGuild() then return nil end
    local name = (GetGuildInfo and GetGuildInfo("player")) or nil
    if not name or name == "" then return nil end
    local realm = (GetRealmName() or ""):gsub("%s+", "")
    return name .. "-" .. realm
end

local function guildBucket(key)
    local db = GuildLogDB()
    if not db then return nil end
    db.guilds = db.guilds or {}
    db.guilds[key] = db.guilds[key] or { entries = {} }
    db.guilds[key].entries = db.guilds[key].entries or {}
    return db.guilds[key]
end

local function refreshSubTab()
    local mw = addon.MainWindow
    if not (mw and mw.activeTab == "logs") then return end
    local frames = addon.DB and addon.DB.char and addon.DB.char.frames
    if frames and frames.logsActiveSubTab == "guild" and mw.Refresh then
        mw:Refresh()
    end
end

-- Coarse unix timestamp from Blizzard's (year, month, day, hour) tuple.
-- These fields are "X ago" relative offsets, NOT absolute calendar
-- values — confirmed empirically on Retail Midnight by re-running our
-- diagnostic an hour apart and watching ymdh shift from 0/0/0/3 to
-- 0/0/0/4 for the same three events. Subtract from "now" to get an
-- approximate absolute ts. Months use 30 days, years use 365 — exact
-- calendar arithmetic isn't worth it given Blizzard's underlying
-- precision is hour-coarse and we only use this for sort + the
-- "Nh ago" / "Nd ago" relative display.
local function tupleToTs(y, m, d, h)
    y = tonumber(y) or 0
    m = tonumber(m) or 0
    d = tonumber(d) or 0
    h = tonumber(h) or 0
    local now = (GetServerTime and GetServerTime()) or time()
    return now - y * 365 * 86400 - m * 30 * 86400 - d * 86400 - h * 3600
end

-- ---------------------------------------------------------------------------
-- Capture
-- ---------------------------------------------------------------------------
-- Build a dedupe key for an entry. The ymdh tuple is intentionally
-- excluded — those fields are "X ago" relative offsets that drift
-- with every re-query (a 3-hours-ago event becomes 4-hours-ago an
-- hour later). Including them would cause every event to be re-
-- appended on every query as a "new" event. The (type, target,
-- actor, rank) tuple is stable across queries.
--
-- Edge case: someone joining / leaving / being-re-promoted to the
-- same rank repeatedly will collapse to one entry. Acceptable
-- because Blizzard's buffer caps at ~100 entries; by the time
-- someone re-joins, the original event has rolled off the buffer
-- anyway and the new join appears as a fresh entry to us.
local function dedupeKey(entry)
    return string.format("%s|%s|%s|%s",
        tostring(entry.type   or ""),
        tostring(entry.target or ""),
        tostring(entry.actor  or ""),
        tostring(entry.rank   or "")
    )
end

-- Stable cross-addon row identifier. Composed from the guild bucket key
-- + the dedupe-key fields, so two addons reading the same Blizzard
-- event buffer for the same guild compute the same id for the same
-- logical event. The "glog|" prefix lets future code distinguish this
-- log type from sibling logs (e.g. "mlog|...", "tlog|...") if we
-- add ids to Mail / Trade Log later. Used as the row identity for any
-- future sync / cross-reference between addons; not surfaced in the
-- default UI.
local function rowId(guildKey, entry)
    return string.format("glog|%s|%s|%s|%s|%s",
        tostring(guildKey     or ""),
        tostring(entry.type   or ""),
        tostring(entry.target or ""),
        tostring(entry.actor  or ""),
        tostring(entry.rank   or "")
    )
end

local function readEvent(i)
    if not GetGuildEventInfo then return nil end
    local etype, player1, player2, rank, year, month, day, hour = GetGuildEventInfo(i)
    -- The earlier guard was `if not etype then return nil end` — falsy-
    -- only, which lets empty-string etype slip past. TBC reproductions
    -- showed all-blank rows landing in the SV; the dedupe key collapsed
    -- to "|||" for those, so multiple distinct blank events folded into
    -- one persistent ghost row. Reject empty strings explicitly. Same
    -- treatment for player1: a self-event (join/leave/quit) with no
    -- player name is meaningless data — skip rather than persist a row
    -- with no information.
    if not etype or etype == "" then
        addon:Debug(string.format(
            "[glog]   readEvent(%d) rejected: empty etype (raw='%s')",
            i, tostring(etype)))
        return nil
    end
    if not player1 or player1 == "" then
        addon:Debug(string.format(
            "[glog]   readEvent(%d) rejected: empty player1 for etype=%s",
            i, tostring(etype)))
        return nil
    end
    -- Semantic mapping. The player1/player2 columns swap meaning by
    -- event type: for actor-driven events (promote/demote/remove) the
    -- actor is player1 and the target is player2; for self-events
    -- (join/leave/quit) the player is player1 and player2 is unused.
    -- We normalise to actor/target so the UI doesn't need to know.
    local actor, target
    if etype == "promote" or etype == "demote" or etype == "remove" or etype == "invite" then
        actor  = player1
        target = player2
    else
        actor  = nil
        target = player1
    end
    return {
        y       = year,
        m       = month,
        d       = day,
        h       = hour,
        ts      = tupleToTs(year, month, day, hour),
        type    = etype,
        actor   = actor,
        target  = target,
        rank    = rank,
    }
end

-- One-shot SV cleanup of legacy blank rows. Pre-v0.5.1 readEvent let
-- empty-string etype + empty player1 entries through, and the dedupe
-- collapse meant the SV could carry persistent ghost rows that the new
-- validation alone won't remove (they're already in the table). Walks
-- every guild bucket once per session and drops entries that the
-- current readEvent would reject. Marker on the bucket prevents the
-- pass running again the same session.
local function cleanupBlankEntries(bucket)
    if not bucket or bucket._blanksCleaned then return 0 end
    local kept, removed = {}, 0
    for _, e in ipairs(bucket.entries or {}) do
        local etypeOK  = e.type and e.type ~= ""
        local actorOK  = e.actor  and e.actor  ~= ""
        local targetOK = e.target and e.target ~= ""
        if etypeOK and (actorOK or targetOK) then
            tinsert(kept, e)
        else
            removed = removed + 1
        end
    end
    if removed > 0 then
        bucket.entries = kept
        addon:Debug(string.format(
            "[glog]   cleanupBlankEntries: dropped %d ghost rows (kept %d)",
            removed, #kept))
    end
    bucket._blanksCleaned = true
    return removed
end

local function ingest()
    if not isEnabled() then
        addon:Debug("[glog] ingest skip: disabled")
        return
    end
    local key = GuildKey()
    if not key then
        addon:Debug("[glog] ingest skip: no guild key")
        return
    end
    local bucket = guildBucket(key)
    if not bucket then
        addon:Debug(string.format("[glog] ingest skip: no bucket for %s", key))
        return
    end
    if not GetNumGuildEvents then
        addon:Debug("[glog] ingest skip: GetNumGuildEvents API missing")
        return
    end

    cleanupBlankEntries(bucket)

    local n = GetNumGuildEvents() or 0
    addon:Debug(string.format(
        "[glog] ingest n=%d, bucket-before=%d, key=%s",
        n, #bucket.entries, key))
    if n == 0 then return end

    -- Build a set of dedupe keys we already have so a re-query doesn't
    -- duplicate. O(N+M) where N = persisted, M = freshly returned.
    local seen = {}
    for _, e in ipairs(bucket.entries) do seen[dedupeKey(e)] = true end

    local newCount, readNil, deduped = 0, 0, 0
    for i = 1, n do
        local e = readEvent(i)
        if e then
            local k = dedupeKey(e)
            if not seen[k] then
                seen[k] = true
                e.id = rowId(key, e)
                tinsert(bucket.entries, e)
                newCount = newCount + 1
            else
                deduped = deduped + 1
            end
        else
            readNil = readNil + 1
        end
    end

    addon:Debug(string.format(
        "[glog] ingest result: new=%d, deduped=%d, readNil=%d, bucket-after=%d",
        newCount, deduped, readNil, #bucket.entries))

    if newCount > 0 then
        -- Sort newest-first by ts so the sub-tab's default order is stable.
        table.sort(bucket.entries, function(a, b) return (a.ts or 0) > (b.ts or 0) end)
        local Logs = addon.modules and addon.modules["logs"]
        if Logs and Logs.PruneEntries then Logs:PruneEntries(bucket.entries) end
        refreshSubTab()
    end
end

-- Coalesced handle for the deferred ingest below, and the debounce stamp for
-- the roster-driven trigger. Both exist because of ONE user-observed trace
-- (2026-08-17, Anniversary, ~1200-entry bucket), which is worth quoting because
-- no offline spec could have produced it:
--
--   [glog] ingest n=100, bucket-before=1205 ... new=0, deduped=100
--   [glog] ingest n=100, bucket-before=1205 ... new=0, deduped=100     <- SAME ms
--   ... repeating every 1-3 seconds while guildmates logged out ...
--
-- Two separate defects in that trace:
--
--   1. GUILD_ROSTER_UPDATE was an UNTHROTTLED trigger. Every guildmate going
--      on or offline fired a server query AND scheduled an ingest. In a large
--      guild at peak that is continuous, and each ingest re-hashes all 100
--      buffer entries to discover `new=0`. GuildBankLog already learned this
--      exact lesson -- GUILDBANK_UPDATE_TABS fires ~24 times per bank open and
--      is debounced for it.
--   2. The deferred ingest was `C_Timer.After(1.5, ingest)` with NO handle, so
--      a burst of roster updates stacked a separate timer each, and they landed
--      together -- which is why the trace shows pairs at an IDENTICAL
--      millisecond rather than 1.5s apart. NewTimer gives a cancellable handle
--      so only the most recent request survives.
--
-- The 2-minute catch-up ticker is unchanged and still covers the invite case.
local _pendingIngestTimer = nil
-- nil means "never queried", NOT zero. Seeding this with 0 swallows the FIRST
-- roster update whenever GetTime() is still under the window -- GetTime is
-- seconds since client start, so that is every login for the first 30 seconds,
-- which is exactly when the roster is busiest. Caught by the spec below rather
-- than by reading; the version with 0 looked obviously correct.
local _lastQueryAt        = nil
local ROSTER_DEBOUNCE_SEC = 30

local function cancelPendingIngest()
    if _pendingIngestTimer then
        if _pendingIngestTimer.Cancel then _pendingIngestTimer:Cancel() end
        _pendingIngestTimer = nil
    end
end

local function requestQuery()
    if not isEnabled() then return end
    if not QueryGuildEventLog then return end
    if not IsInGuild or not IsInGuild() then return end
    QueryGuildEventLog()
    -- GUILD_EVENT_LOG_UPDATE doesn't fire on Retail Midnight (the
    -- legacy read API was kept but the legacy notification event
    -- was dropped — likely because Communities uses its own signal).
    -- Without an event fallback the buffer never gets ingested.
    -- Schedule an explicit ingest 1.5 s after the query so we don't
    -- depend on the event firing. The GUILD_EVENT_LOG_UPDATE handler
    -- is still wired (it works on Classic) and ingest is idempotent
    -- via the (type, target, actor, rank) dedupe key, so double-
    -- triggering on Classic is harmless.
    --
    -- "Harmless" was true of CORRECTNESS and false of COST, which is what the
    -- trace above showed: idempotent is not the same as free, because each
    -- redundant pass re-hashes every buffer entry. Coalesced onto one
    -- cancellable timer so a burst of triggers produces ONE ingest.
    cancelPendingIngest()
    if C_Timer and C_Timer.NewTimer then
        _pendingIngestTimer = C_Timer.NewTimer(1.5, function()
            _pendingIngestTimer = nil
            ingest()
        end)
    elseif C_Timer and C_Timer.After then
        -- Same fallback shape as GuildBankLog's (finding tog-P): uncancellable,
        -- so a burst can still stack here, but an uncoalesced ingest beats none.
        C_Timer.After(1.5, ingest)
    end
end

-- Roster-driven entry point. Debounced; the periodic ticker and the login path
-- call requestQuery directly because neither can storm.
local function requestQueryDebounced()
    local now = (GetTime and GetTime()) or 0
    if _lastQueryAt and (now - _lastQueryAt) < ROSTER_DEBOUNCE_SEC then return end
    _lastQueryAt = now
    requestQuery()
end

-- ---------------------------------------------------------------------------
-- Event wiring
-- ---------------------------------------------------------------------------
local _evtFrame = CreateFrame("Frame")
_evtFrame:RegisterEvent("PLAYER_LOGIN")
_evtFrame:RegisterEvent("GUILD_EVENT_LOG_UPDATE")
_evtFrame:RegisterEvent("GUILD_ROSTER_UPDATE")
_evtFrame:SetScript("OnEvent", function(_, event)
    if event == "PLAYER_LOGIN" then
        -- Defer the first query until after the world settles. 3 s
        -- lands after Ace's loaded line and other addons' login spam.
        C_Timer.After(3, requestQuery)

    elseif event == "GUILD_ROSTER_UPDATE" then
        -- Roster updates and event log updates aren't strictly tied
        -- but a roster change often coincides with new log entries
        -- (joins / leaves / kicks). Cheap to opportunistically re-query.
        --
        -- DEBOUNCED: "cheap" was measured wrong. This fires once per guildmate
        -- logging in or out, and a user trace caught it re-ingesting all 100
        -- buffer entries every 1-3 seconds through an ordinary evening's
        -- logouts, finding nothing new every time. A join or leave that lands
        -- inside the debounce window is still picked up by the next roster
        -- update after it, by the 2-minute ticker, or by GUILD_EVENT_LOG_UPDATE
        -- (which is what actually signals new log content on Classic).
        requestQueryDebounced()

    elseif event == "GUILD_EVENT_LOG_UPDATE" then
        ingest()
    end
end)

-- Periodic catch-up tick. GUILD_ROSTER_UPDATE is the responsive path for
-- almost every event type; this ticker exists for the "invite sent but
-- not yet accepted" case where no roster event fires. C_Timer keeps its
-- own reference, so we don't need to hold the ticker handle.
C_Timer.NewTicker(QUERY_INTERVAL_SEC, requestQuery)

-- ---------------------------------------------------------------------------
-- Read API consumed by GUI/GuildLogSubTab.lua
-- ---------------------------------------------------------------------------
function GuildLog:GetGuildKeys()
    local db = GuildLogDB()
    if not db or not db.guilds then return {} end
    local list = {}
    for k in pairs(db.guilds) do tinsert(list, k) end
    table.sort(list)
    return list
end

--- Returns the currently-active guild key for the logged-in character,
--- or nil if the character isn't in a guild. Useful as the default
--- selection for the guild-filter dropdown.
function GuildLog:GetCurrentGuildKey()
    return GuildKey()
end

--- Filtered, newest-first list of entries.
---   guildKey      string|nil    guild bucket; nil = all guilds merged
---   filterType    string|nil    event type filter; nil = all types
---   fromTs, toTs  number|nil    inclusive ts bounds
function GuildLog:GetEntries(guildKey, filterType, fromTs, toTs)
    local db = GuildLogDB()
    if not db or not db.guilds then return {} end

    local out = {}
    local buckets = {}
    if guildKey and db.guilds[guildKey] then
        buckets[1] = { key = guildKey, bucket = db.guilds[guildKey] }
    else
        for k, b in pairs(db.guilds) do
            buckets[#buckets + 1] = { key = k, bucket = b }
        end
    end

    for _, gk in ipairs(buckets) do
        for _, e in ipairs(gk.bucket.entries or {}) do
            local ok = true
            if filterType and e.type ~= filterType then ok = false end
            if ok and fromTs and (e.ts or 0) < fromTs then ok = false end
            if ok and toTs   and (e.ts or 0) > toTs   then ok = false end
            if ok then
                -- The id is computed into the COPY and NOT written back to the
                -- stored entry (audit round 8, finding tog-T -- the same defect as
                -- finding N in GuildBankLog, which this function is a structural
                -- twin of). It used to be a lazy migration here, which made a READ
                -- mutate SavedVariables; and because it does not look like a write
                -- path it was never gated, so a disabled Guild Log still wrote
                -- whenever the tab was opened. Unlike GuildBankLog there is no
                -- `occ` here, so there is no later pass that would change the id --
                -- the ungated write is the whole of it.
                -- Decorate with guildKey so the sub-tab can show which
                -- guild an entry came from in cross-guild views.
                local copy = {
                    y = e.y, m = e.m, d = e.d, h = e.h, ts = e.ts,
                    type = e.type, actor = e.actor, target = e.target,
                    rank = e.rank, guildKey = gk.key, id = e.id or rowId(gk.key, e),
                }
                tinsert(out, copy)
            end
        end
    end
    -- Tie-broken on `id` (audit round 8, finding tog-T -- same as GuildBankLog's
    -- finding O). ts alone is not a total order here: tupleToTs is derived from
    -- Blizzard's "X ago" tuple whose precision is HOUR-COARSE (see the comment on
    -- tupleToTs above), so every event in the same hour ties -- and with no guild
    -- filter `out` is assembled from pairs(db.guilds) in hash order first.
    -- table.sort is not stable in 5.1, so tied rows reshuffled between redraws of
    -- an unchanged log. `id` is unique per row and already computed above.
    table.sort(out, function(a, b)
        if (a.ts or 0) ~= (b.ts or 0) then return (a.ts or 0) > (b.ts or 0) end
        return tostring(a.id or "") < tostring(b.id or "")
    end)
    return out
end

function GuildLog:ClearAll()
    local db = GuildLogDB()
    if db then db.guilds = {} end
    refreshSubTab()
end

--- Manual re-query trigger. Used by Diagnose() below and reserved for
--- future UI surfaces that want to force a refresh.
function GuildLog:Requery()
    requestQuery()
end

-- ---------------------------------------------------------------------------
-- Default-rank lookup for join / invite events.
--
-- Blizzard's `GetGuildEventInfo` doesn't populate `rank` on join events
-- (returns empty string) and returns the INVITER's rank on invite events
-- — neither tells us what rank the new member actually got. In WoW
-- guilds, new invites are auto-assigned to the lowest-privilege rank
-- (highest `rankIndex` from `GetGuildRosterInfo`), and that's what we
-- want to show in the Rank column. We synthesize it from a live roster
-- scan and cache the result per session.
--
-- Limitation: we can only resolve the default rank for the player's
-- CURRENT guild. Entries from a previous session's alt guild fall back
-- to an empty cell because `GetGuildRosterInfo` only ever returns the
-- logged-in character's current guild roster.
-- ---------------------------------------------------------------------------
local _defaultRankCache = { key = nil, rank = nil }

function GuildLog:GetDefaultRankFor(guildKey)
    local currentKey = GuildKey()
    if not currentKey or currentKey ~= guildKey then return nil end

    if _defaultRankCache.key == currentKey and _defaultRankCache.rank then
        return _defaultRankCache.rank
    end

    if not GetNumGuildMembers then return nil end
    local n = GetNumGuildMembers() or 0
    if n == 0 then return nil end

    -- Find the rank with the highest rankIndex. In WoW guilds, rankIndex
    -- 0 = Guild Master and the number grows as privileges shrink; the
    -- maximum rankIndex is the default rank for new invites.
    local maxIdx, maxName = -1, nil
    for i = 1, n do
        local _, mrank, mrankIdx = GetGuildRosterInfo(i)
        if mrankIdx and mrankIdx > maxIdx then
            maxIdx = mrankIdx
            maxName = mrank
        end
    end

    _defaultRankCache.key = currentKey
    _defaultRankCache.rank = maxName
    return maxName
end

-- ---------------------------------------------------------------------------
-- Chat-print helpers shared by Diagnose() and Dump(). Lifted to module
-- scope so both can use them — Dump was added after Diagnose's existing
-- locals were defined and would have shadowed them otherwise.
-- ---------------------------------------------------------------------------
local function dprint(line)
    print(addon.UI.Brand("GuildLog") .. " " .. line)
end

local function yn(v)
    if v == nil then return "|cffff5555nil|r" end
    if v        then return "|cff66ff66yes|r" end
    return "|cffff5555no|r"
end

--- Raw SV dump for triage. Prints the most-recent N entries from the
--- current guild's bucket exactly as stored — every field on its own
--- line per entry, NO formatting of empty fields (so blank actor /
--- target shows as `actor=''` not `actor='—'`). Output is parseable
--- and copyable so the user can paste the chat log back and we can
--- see exactly what's persisted vs what the UI is rendering.
--- Defaults to 100 entries (matches Blizzard's buffer cap).
function GuildLog:Dump(maxCount)
    maxCount = tonumber(maxCount) or 100
    local key = GuildKey()
    if not key then
        dprint("|cffff5555Not in a guild — nothing to dump.|r")
        return
    end
    local bucket = guildBucket(key)
    if not bucket or not bucket.entries then
        dprint(string.format("|cffff5555No bucket for %s.|r", key))
        return
    end
    -- Bucket is already sorted newest-first by ts (ingest sorts after
    -- every append), but sort defensively here so /togt gldump always
    -- reflects "newest N" regardless of any future reordering.
    local sorted = {}
    for _, e in ipairs(bucket.entries) do tinsert(sorted, e) end
    table.sort(sorted, function(a, b) return (a.ts or 0) > (b.ts or 0) end)

    local total  = #sorted
    local dumpN  = math.min(maxCount, total)
    dprint(string.format(
        "|cffffd700Dump|r guild=%s total=%d dumping newest=%d",
        key, total, dumpN))
    for i = 1, dumpN do
        local e = sorted[i]
        dprint(string.format(
            "  [%d] type=%s actor='%s' target='%s' rank='%s' ymdh=%s/%s/%s/%s ts=%s id=%s",
            i,
            tostring(e.type   or ""),
            tostring(e.actor  or ""),
            tostring(e.target or ""),
            tostring(e.rank   or ""),
            tostring(e.y), tostring(e.m), tostring(e.d), tostring(e.h),
            tostring(e.ts),
            tostring(e.id or "")))
    end
end

-- ---------------------------------------------------------------------------
-- Diagnostics — printed to chat by /togt glcheck. Reports API
-- availability, in-guild status, the engine's internal state, and
-- Blizzard's current event buffer. Used to triage "why is the log
-- empty" reports. dprint / yn live at module scope above so Dump()
-- can share them.
-- ---------------------------------------------------------------------------
function GuildLog:Diagnose()
    dprint("--- API availability ---")
    dprint("QueryGuildEventLog : " .. yn(QueryGuildEventLog ~= nil))
    dprint("GetNumGuildEvents  : " .. yn(GetNumGuildEvents  ~= nil))
    dprint("GetGuildEventInfo  : " .. yn(GetGuildEventInfo  ~= nil))
    dprint("IsInGuild          : " .. yn(IsInGuild and IsInGuild() or false))

    local gname = GetGuildInfo and GetGuildInfo("player") or nil
    dprint("GetGuildInfo(player): " .. (gname or "|cffff5555nil|r"))

    local key = GuildKey()
    dprint("GuildKey()         : " .. (key or "|cffff5555nil|r"))

    if key then
        local bucket = guildBucket(key)
        local n = bucket and bucket.entries and #bucket.entries or 0
        dprint(string.format("Persisted entries for this guild: |cffffd700%d|r", n))

        -- Audit existing SV for blank rows that the pre-v0.5.1 readEvent
        -- let through. Shows the count + a sample of up to 3 so the user
        -- can confirm before letting cleanupBlankEntries prune them.
        if bucket and bucket.entries then
            local blanks, sampled = 0, 0
            local samples = {}
            for _, e in ipairs(bucket.entries) do
                local etypeBlank  = (not e.type)   or e.type   == ""
                local actorBlank  = (not e.actor)  or e.actor  == ""
                local targetBlank = (not e.target) or e.target == ""
                if etypeBlank or (actorBlank and targetBlank) then
                    blanks = blanks + 1
                    if sampled < 3 then
                        sampled = sampled + 1
                        tinsert(samples, string.format(
                            "  type='%s' actor='%s' target='%s' rank='%s' ymdh=%s/%s/%s/%s",
                            tostring(e.type   or ""),
                            tostring(e.actor  or ""),
                            tostring(e.target or ""),
                            tostring(e.rank   or ""),
                            tostring(e.y), tostring(e.m), tostring(e.d), tostring(e.h)))
                    end
                end
            end
            if blanks > 0 then
                dprint(string.format(
                    "|cffff5555Blank/ghost entries in SV: %d|r (first %d shown):",
                    blanks, sampled))
                for _, s in ipairs(samples) do dprint(s) end
                dprint("These will be dropped on the next ingest by cleanupBlankEntries.")
            else
                dprint("|cff66ff66No blank entries in SV.|r")
            end

            -- Per-event-type SV audit. The "blanks" check above is a hard
            -- pass/fail (etype empty OR both actor and target empty), but
            -- doesn't surface PARTIAL data — e.g. an "invite" entry with
            -- target but no actor, which is what a missing-officer-name
            -- bug would actually look like. Bucket by type, count
            -- has-actor vs missing-actor and has-target vs missing-target,
            -- and sample up to 3 entries per actor-driven type that have
            -- a missing actor so the user can see the raw shape.
            local byType = {}
            local missingActorSamples = {}
            local actorDriven = {
                invite = true, remove = true, promote = true, demote = true,
            }
            for _, e in ipairs(bucket.entries) do
                local t = e.type or "?"
                byType[t] = byType[t] or { total = 0, hasActor = 0, hasTarget = 0 }
                byType[t].total = byType[t].total + 1
                local hasActor  = e.actor  and e.actor  ~= ""
                local hasTarget = e.target and e.target ~= ""
                if hasActor  then byType[t].hasActor  = byType[t].hasActor  + 1 end
                if hasTarget then byType[t].hasTarget = byType[t].hasTarget + 1 end
                if actorDriven[t] and not hasActor then
                    missingActorSamples[t] = missingActorSamples[t] or {}
                    if #missingActorSamples[t] < 3 then
                        tinsert(missingActorSamples[t], string.format(
                            "  type=%s actor='%s' target='%s' rank='%s' ymdh=%s/%s/%s/%s",
                            t,
                            tostring(e.actor  or ""),
                            tostring(e.target or ""),
                            tostring(e.rank   or ""),
                            tostring(e.y), tostring(e.m), tostring(e.d), tostring(e.h)))
                    end
                end
            end
            dprint("|cffffd700SV breakdown by type:|r")
            local typeOrder = { "join", "leave", "quit", "remove", "promote", "demote", "invite" }
            local seenOrder = {}
            for _, t in ipairs(typeOrder) do
                seenOrder[t] = true
                local b = byType[t]
                if b then
                    local color = "ff66ff66"
                    -- For actor-driven types, missing actor is suspicious.
                    if actorDriven[t] and b.hasActor < b.total then
                        color = "ffff5555"
                    end
                    dprint(string.format(
                        "  |c%s%-7s|r total=%d  hasActor=%d  hasTarget=%d",
                        color, t, b.total, b.hasActor, b.hasTarget))
                end
            end
            -- Surface unknown types too (Blizzard adds new ones occasionally).
            for t, b in pairs(byType) do
                if not seenOrder[t] then
                    dprint(string.format(
                        "  |cffffaa55%-7s|r total=%d  hasActor=%d  hasTarget=%d (unknown event type — please report)",
                        t, b.total, b.hasActor, b.hasTarget))
                end
            end
            local anyMissing = false
            for _, samples2 in pairs(missingActorSamples) do
                if #samples2 > 0 then
                    if not anyMissing then
                        dprint("|cffff5555Actor-driven entries missing an actor name:|r")
                        anyMissing = true
                    end
                    for _, s in ipairs(samples2) do dprint(s) end
                end
            end
            if not anyMissing then
                dprint("|cff66ff66All actor-driven entries (invite/remove/promote/demote) have an actor populated.|r")
            end
        end
    end

    dprint("--- Forcing QueryGuildEventLog() then re-reading in 2 s... ---")
    if QueryGuildEventLog then QueryGuildEventLog() end

    C_Timer.After(2, function()
        local n = (GetNumGuildEvents and GetNumGuildEvents()) or 0
        dprint(string.format("GetNumGuildEvents() after query: |cffffd700%d|r", n))
        if n == 0 then
            dprint("|cffff5555Blizzard's buffer is empty after the query.|r This is the failure point. Possible causes: (1) character lacks View Event Log guild permission, (2) guild has had no recent events, (3) Retail strips the event log entirely for some guild types. Check the in-game Guild panel's |cffffd700History/Events|r tab — if it's empty there too, the data isn't reaching the client.")
            return
        end

        dprint("First three events from Blizzard's buffer:")
        for i = 1, math.min(3, n) do
            local etype, p1, p2, rank, y, m, d, h = GetGuildEventInfo(i)
            dprint(string.format("  [%d] type=%s p1=%s p2=%s rank=%s ymdh=%s/%s/%s/%s",
                i,
                tostring(etype),
                tostring(p1),
                tostring(p2),
                tostring(rank),
                tostring(y), tostring(m), tostring(d), tostring(h)))
        end

        -- Instrumented inline ingest — replicates the real ingest()'s
        -- logic but prints every gate decision so we can see exactly
        -- where the 100 buffer events disappear.
        dprint("--- Instrumented ingest walk ---")
        dprint("isEnabled()        : " .. yn(isEnabled()))
        dprint("GuildKey()         : " .. (key or "|cffff5555nil|r"))
        local bucket2 = key and guildBucket(key) or nil
        dprint("bucket present    : " .. yn(bucket2 ~= nil))
        if bucket2 then
            dprint(string.format("bucket entries before: |cffffd700%d|r", #(bucket2.entries or {})))
        end

        if not (isEnabled() and key and bucket2) then
            dprint("|cffff5555ingest would early-exit on one of the gates above.|r")
            return
        end

        local seen = {}
        for _, e in ipairs(bucket2.entries) do seen[dedupeKey(e)] = true end

        local readOK, readNil, appended, skippedDup = 0, 0, 0, 0
        local sampleErrors = {}
        for i = 1, n do
            local e = readEvent(i)
            if not e then
                readNil = readNil + 1
                if #sampleErrors < 3 then
                    local etype, p1, p2, _, y2, m2, d2, h2 = GetGuildEventInfo(i)
                    tinsert(sampleErrors, string.format(
                        "i=%d etype=%s p1=%s p2=%s ymdh=%s/%s/%s/%s",
                        i, tostring(etype), tostring(p1), tostring(p2),
                        tostring(y2), tostring(m2), tostring(d2), tostring(h2)))
                end
            else
                readOK = readOK + 1
                local k = dedupeKey(e)
                if seen[k] then
                    skippedDup = skippedDup + 1
                else
                    seen[k] = true
                    tinsert(bucket2.entries, e)
                    appended = appended + 1
                end
            end
        end

        dprint(string.format("readEvent OK / nil : |cff66ff66%d|r / |cffff5555%d|r", readOK, readNil))
        dprint(string.format("appended / deduped : |cff66ff66%d|r / |cffaaaaaa%d|r", appended, skippedDup))
        if #sampleErrors > 0 then
            dprint("readEvent returned nil for:")
            for _, s in ipairs(sampleErrors) do dprint("  " .. s) end
        end

        local n3 = #(bucket2.entries or {})
        dprint(string.format("bucket entries after: |cffffd700%d|r", n3))
    end)
end
