-- TOG Tools — Guild Bank Log (engine)
--
-- Snapshots Blizzard's in-game guild bank transaction log every time the
-- bank frame opens, and persists entries beyond the small rolling window
-- Blizzard keeps. Per-guild buckets so alts in different guilds keep
-- independent histories.
--
-- Recipe:
--   * On GUILDBANKFRAME_OPENED, kick a QueryGuildBankLog for each of the
--     7 item tabs + 1 money log. Each query is async — server returns
--     data via GUILDBANKLOG_UPDATE.
--   * GUILDBANKLOG_UPDATE fires with (tab) arg; ingest that tab's
--     transactions via GetGuildBankTransaction(tab, i) /
--     GetGuildBankMoneyTransaction(i).
--   * Dedupe by stable (type, name, itemSig, count, tab1, tab2, amount)
--     tuple — ymdh excluded because those are "X ago" relative offsets
--     that drift between viewers (same caveat as GuildLog).
--
-- Sync identity: each entry carries a stable `id` built from the guild
-- key + dedupe tuple, prefixed "gblog|" to distinguish from sibling log
-- types. Two viewers ingesting the same transaction compute the same
-- id (modulo the ymdh drift, which is excluded from the id by design).
-- Reserved for future cross-addon sync with TOGBankClassic and others.
--
-- Classic Era note: guild banks didn't ship until TBC (Interface 20000+).
-- This file loads on every TOC but no-ops gracefully when the bank API
-- is absent — leaving the sub-tab visible and ready to receive entries
-- from a future TOGBankClassic integration via InjectEntry().

local _, addon = ...

local GuildBankLog = {}
addon.logCategories = addon.logCategories or {}
addon.logCategories["guildbank"] = GuildBankLog

GuildBankLog.subKey    = "guildbank"
GuildBankLog.label     = "Guild Bank"
GuildBankLog.configKey = "guildBankLog"
GuildBankLog.help = {
    title = "Logs > Guild Bank",
    lines = {
        "Snapshots Blizzard's in-game guild bank transaction log every time you open the guild bank, and persists entries beyond the small rolling window Blizzard keeps. Per-guild buckets so alts in different guilds keep independent histories.",
        "|cffffd700Auto-capture:|r runs on every GUILDBANKFRAME_OPENED. The 7 item tabs and the money log are queried in turn; new transactions land here automatically — no manual refresh needed.",
        "|cffffd700Filters:|r Guild (defaults to your current guild), Type (Deposit / Withdraw / Move / Repair / Bought tab), Tab (specific bank tab or money log — moves match either tab they touch), Date range.",
        "|cffffd700Type column|r is colour-coded: |cff66ff66green|r = deposit, |cffffcc66yellow|r = withdraw, |cff66ccffblue|r = move between tabs, |cffffaa55orange|r = repair, |cffccaaffpurple|r = tab purchase.",
        "|cffffd700Time precision:|r Blizzard's API only exposes hour-coarse timestamps for bank transactions (no minute / second), so the time column rounds to the hour. Older entries show as \"MM/DD HH:00\".",
        "|cffffd700Permission gated:|r if your guild rank doesn't have View Tab on a given tab, that tab's transactions stay invisible. Money log requires the Withdraw Gold permission.",
        "|cffffd700Sync-ready:|r each captured entry carries a stable cross-viewer identifier reserved for future sync with TOGBankClassic (Vanilla) and other TOG suite addons.",
        "|cffffd700Classic Era:|r guild banks didn't ship until TBC, so in-game capture is dormant on Vanilla. The tab is here so a future TOGBankClassic integration can feed entries directly.",
    },
}

-- Blizzard caps guild bank item tabs at 7; the money log lives at
-- MAX_GUILDBANK_TABS + 1, which has been 8 on every release that has
-- guild banks. Hard-coding to avoid a global-lookup failure on Era.
local NUM_ITEM_TABS = 7
local MONEY_TAB     = 8

-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
local function GuildBankLogDB()
    return addon.DB and addon.DB.global and addon.DB.global.guildBankLog
end

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

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 = GuildBankLogDB()
    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 then
        addon:Debug("[gblog] refreshSubTab skip: no MainWindow (addon not open)")
        return
    end
    if mw.activeTab ~= "logs" then
        addon:Debug(string.format(
            "[gblog] refreshSubTab skip: activeTab=%s (not 'logs')",
            tostring(mw.activeTab)))
        return
    end
    local frames = addon.DB and addon.DB.char and addon.DB.char.frames
    local sub = frames and frames.logsActiveSubTab
    if sub ~= "guildbank" then
        addon:Debug(string.format(
            "[gblog] refreshSubTab skip: logsActiveSubTab=%s (not 'guildbank')",
            tostring(sub)))
        return
    end
    if not mw.Refresh then
        addon:Debug("[gblog] refreshSubTab skip: MainWindow:Refresh missing")
        return
    end
    addon:Debug("[gblog] refreshSubTab firing MainWindow:Refresh")
    mw:Refresh()
end

-- Same caveat as GuildLog.tupleToTs: ymdh are "X ago" offsets, not
-- absolute calendar values. Subtract from now() for an approximate
-- absolute ts. Hour precision only.
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

-- Strip an itemLink down to its itemID so dedupe doesn't break on
-- player-context payloads (crafted-by-X, suffix rolls, etc.). Bank
-- transactions are usually clean but this is defensive.
local function linkSig(itemLink)
    if not itemLink or itemLink == "" then return "" end
    local id = itemLink:match("|Hitem:(%d+):") or itemLink:match("item:(%d+):")
    return id or itemLink
end

-- The (type, itemSig, count, tab1, tab2, amount) tuple is what two
-- viewers of the same transaction will agree on. ymdh is excluded
-- because it drifts every hour for the same event (Blizzard returns
-- "X ago", not absolute).
--
-- IMPORTANT — `name` is NOT in the dedupe tuple, even though it logically
-- identifies who did the transaction. Empirical observation on TBC
-- (v0.5.1 diagnostic dump from a real guild bank): Blizzard's
-- `GetGuildBankTransaction` returns the same transaction multiple times
-- across consecutive `QueryGuildBankLog` calls with INCONSISTENT name
-- field — populated on one query, nil/empty on the next. Including name
-- in the dedupe meant the two payloads hashed differently and BOTH
-- landed in the SV. Excluding name collapses them into one entry; the
-- ingest path then backfills the name when a later query returns it
-- populated. Two truly-distinct transactions in the same hour with
-- identical (type, item, count, tabs, amount) collapse to one — same
-- dedupe-collapse caveat as Guild Log.
local function dedupeKey(entry)
    return string.format("%s|%s|%s|%s|%s|%s",
        tostring(entry.type    or ""),
        tostring(entry.itemSig or ""),
        tostring(entry.count   or 0),
        tostring(entry.tab1    or 0),
        tostring(entry.tab2    or 0),
        tostring(entry.amount  or 0)
    )
end

-- Stable cross-addon row identifier. Composed from the guild bucket key
-- + the dedupe-key fields, so two addons reading the same Blizzard bank
-- buffer for the same guild compute the same id for the same logical
-- transaction. The "gblog|" prefix distinguishes this log type from
-- sibling log types ("glog|", "mlog|", "tlog|"). `name` is intentionally
-- excluded — see dedupeKey comment above; including it would give two
-- viewers different ids for the same transaction if Blizzard's API gave
-- one of them a nil name. Reserved for future TOGBankClassic /
-- cross-account sync; not surfaced in the default UI.
local function rowId(guildKey, entry)
    return string.format("gblog|%s|%s|%s|%s|%s|%s|%s",
        tostring(guildKey       or ""),
        tostring(entry.type     or ""),
        tostring(entry.itemSig  or ""),
        tostring(entry.count    or 0),
        tostring(entry.tab1     or 0),
        tostring(entry.tab2     or 0),
        tostring(entry.amount   or 0)
    )
end

-- One-shot cleanup of SV duplicates introduced by the pre-v0.5.1 dedupe
-- that included `name`. Walks every bucket entry, rehashes under the
-- new (name-less) dedupe key, and collapses matches — keeping the
-- copy with a populated `name` when one exists. Marker on the bucket
-- prevents re-running across sessions; the cleanup is idempotent so
-- re-running would be safe, just wasteful.
local function cleanupDuplicateEntries(bucket, guildKey)
    if not bucket or bucket._dupesCleaned then return 0 end
    if not bucket.entries then bucket._dupesCleaned = true; return 0 end

    local byKey = {}
    local removed = 0
    for _, e in ipairs(bucket.entries) do
        local k = dedupeKey(e)
        local kept = byKey[k]
        if not kept then
            byKey[k] = e
        else
            -- Prefer the entry with a populated name; if neither has
            -- one, the first-seen wins (arbitrary but stable).
            local keptHasName = kept.name and kept.name ~= ""
            local newHasName  = e.name and e.name ~= ""
            if not keptHasName and newHasName then
                kept.name = e.name
            end
            removed = removed + 1
        end
    end

    if removed > 0 then
        local rebuilt = {}
        for _, e in pairs(byKey) do
            -- Regenerate the cross-viewer id from the new (name-less)
            -- formula so the SV doesn't carry stale ids that hash
            -- under the old scheme.
            e.id = rowId(guildKey, e)
            tinsert(rebuilt, e)
        end
        table.sort(rebuilt, function(a, b) return (a.ts or 0) > (b.ts or 0) end)
        bucket.entries = rebuilt
        addon:Debug(string.format(
            "[gblog]   cleanupDuplicateEntries: collapsed %d duplicate rows in %s",
            removed, tostring(guildKey)))
    end
    bucket._dupesCleaned = true
    return removed
end

-- ---------------------------------------------------------------------------
-- Capture
-- ---------------------------------------------------------------------------
local function readItemTxn(tabIndex, i)
    if not GetGuildBankTransaction then return nil end
    local etype, name, itemLink, count, tab1, tab2, year, month, day, hour =
        GetGuildBankTransaction(tabIndex, i)
    if not etype then
        addon:Debug(string.format(
            "[gblog]   readItemTxn(tab=%d, i=%d) returned nil etype",
            tabIndex, i))
        return nil
    end
    return {
        kind     = "item",
        type     = etype,
        name     = name,
        itemLink = itemLink,
        itemSig  = linkSig(itemLink),
        count    = tonumber(count) or 0,
        tab1     = tonumber(tab1) or tabIndex,
        tab2     = tonumber(tab2) or 0,
        y = year, m = month, d = day, h = hour,
        ts       = tupleToTs(year, month, day, hour),
    }
end

local function readMoneyTxn(i)
    if not GetGuildBankMoneyTransaction then return nil end
    local etype, name, amount, year, month, day, hour =
        GetGuildBankMoneyTransaction(i)
    if not etype then
        addon:Debug(string.format(
            "[gblog]   readMoneyTxn(i=%d) returned nil etype", i))
        return nil
    end
    return {
        kind   = "money",
        type   = etype,
        name   = name,
        amount = tonumber(amount) or 0,
        y = year, m = month, d = day, h = hour,
        ts     = tupleToTs(year, month, day, hour),
    }
end

local function ingestTab(tabIndex)
    if not isEnabled() then
        addon:Debug(string.format("[gblog] ingestTab(%d) skip: disabled", tabIndex))
        return
    end
    local key = GuildKey()
    if not key then
        addon:Debug(string.format("[gblog] ingestTab(%d) skip: no guild key", tabIndex))
        return
    end
    local bucket = guildBucket(key)
    if not bucket then
        addon:Debug(string.format("[gblog] ingestTab(%d) skip: no bucket for %s", tabIndex, key))
        return
    end

    -- Clean up pre-v0.5.1 duplicates (one-shot per bucket per session,
    -- skipped after `bucket._dupesCleaned = true`).
    cleanupDuplicateEntries(bucket, key)

    -- Build a dedupeKey → existing-entry MAP (not a set) so we can both
    -- detect duplicates AND backfill `name` onto an existing entry when
    -- a later Blizzard query returns the same transaction with a
    -- populated name where the earlier query gave us nil.
    local seen = {}
    for _, e in ipairs(bucket.entries) do
        seen[dedupeKey(e)] = e
    end

    -- backfilled is for the debug trace below — counts how many times
    -- we updated an existing entry's `name` field from a duplicate
    -- API row that came in with the populated value.
    local newCount, readNil, deduped, backfilled = 0, 0, 0, 0

    -- Inline helper: shared add-or-backfill logic for money + item paths.
    local function tryAddOrBackfill(e)
        local k = dedupeKey(e)
        local existing = seen[k]
        if not existing then
            seen[k] = e
            e.id = rowId(key, e)
            tinsert(bucket.entries, e)
            newCount = newCount + 1
            return
        end
        deduped = deduped + 1
        -- Backfill name when the existing entry doesn't have one and
        -- this dup does. Same for itemLink (sometimes nil where the
        -- name is also nil, but item is otherwise sound via itemSig).
        if (not existing.name or existing.name == "") and e.name and e.name ~= "" then
            existing.name = e.name
            backfilled = backfilled + 1
        end
        if (not existing.itemLink or existing.itemLink == "") and e.itemLink and e.itemLink ~= "" then
            existing.itemLink = e.itemLink
        end
    end

    if tabIndex == MONEY_TAB then
        local n = GetNumGuildBankMoneyTransactions and GetNumGuildBankMoneyTransactions() or 0
        addon:Debug(string.format(
            "[gblog] ingestTab(money) n=%d, bucket-before=%d",
            n, #bucket.entries))
        for i = 1, n do
            local e = readMoneyTxn(i)
            if e then
                tryAddOrBackfill(e)
            else
                readNil = readNil + 1
            end
        end
    else
        local n = GetNumGuildBankTransactions and GetNumGuildBankTransactions(tabIndex) or 0
        addon:Debug(string.format(
            "[gblog] ingestTab(%d) n=%d, bucket-before=%d",
            tabIndex, n, #bucket.entries))
        for i = 1, n do
            local e = readItemTxn(tabIndex, i)
            if e then
                tryAddOrBackfill(e)
            else
                readNil = readNil + 1
            end
        end
    end

    addon:Debug(string.format(
        "[gblog] ingestTab(%s) result: new=%d, deduped=%d, backfilled=%d, readNil=%d, bucket-after=%d",
        tabIndex == MONEY_TAB and "money" or tostring(tabIndex),
        newCount, deduped, backfilled, readNil, #bucket.entries))

    if newCount > 0 then
        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

-- Cancellable handle for the deferred ingest scheduled by requestAllTabs.
-- Tracked at module scope so ClearAll can cancel it — without this, a
-- pending ingest queued during a recent bank-open / gbcheck would fire
-- AFTER the user clicked Clear and silently re-populate the just-wiped
-- SV (user-reported "Clear data button isn't clearing"). The previous
-- C_Timer.After call was uncancellable.
local _deferredIngestTimer = nil

-- Debounce timestamp for the GUILDBANK_UPDATE_TABS trigger. On TBC /
-- Anniversary `GUILDBANKFRAME_OPENED` doesn't fire at all
-- (user-confirmed via debug trace: only GUILDBANK_UPDATE_TABS x24 fires
-- when the bank opens, no GUILDBANKFRAME_OPENED, no GUILDBANKLOG_UPDATE).
-- We treat GUILDBANK_UPDATE_TABS as the bank-open signal, but since
-- Blizzard spams it ~24 times as tab metadata trickles in, debounce so
-- we only kick the bank-open flow once per ~30s window.
local _lastBankOpenTrigger = 0
local BANK_OPEN_DEBOUNCE_SEC = 30

local function cancelDeferredIngest()
    if _deferredIngestTimer and _deferredIngestTimer.Cancel then
        _deferredIngestTimer:Cancel()
    end
    _deferredIngestTimer = nil
end

local function requestAllTabs()
    if not isEnabled() then
        addon:Debug("[gblog] requestAllTabs skip: disabled")
        return
    end
    if not QueryGuildBankLog then
        addon:Debug("[gblog] requestAllTabs skip: QueryGuildBankLog API missing (Vanilla?)")
        return
    end
    if not IsInGuild or not IsInGuild() then
        addon:Debug("[gblog] requestAllTabs skip: not in a guild")
        return
    end
    addon:Debug(string.format(
        "[gblog] requestAllTabs firing — querying tabs 1..%d + money(%d), bankFrameShown=%s",
        NUM_ITEM_TABS, MONEY_TAB,
        tostring(GuildBankFrame and GuildBankFrame:IsShown() or false)))
    for tab = 1, NUM_ITEM_TABS do
        QueryGuildBankLog(tab)
        -- Per-tab pre-buffer trace: shows whether the buffer is already
        -- populated at query time vs. populated only after the server
        -- response. On TBC the buffer is typically EMPTY until the
        -- response lands ~2 s later.
        local n = GetNumGuildBankTransactions and GetNumGuildBankTransactions(tab) or -1
        addon:Debug(string.format("[gblog]   tab=%d after-query GetNumGuildBankTransactions=%d", tab, n))
    end
    QueryGuildBankLog(MONEY_TAB)
    local mn = GetNumGuildBankMoneyTransactions and GetNumGuildBankMoneyTransactions() or -1
    addon:Debug(string.format("[gblog]   money after-query GetNumGuildBankMoneyTransactions=%d", mn))

    -- Deferred ingest fallback. Empirically `GUILDBANKLOG_UPDATE`
    -- doesn't reliably fire on TBC / Anniversary after a
    -- `QueryGuildBankLog` call — exactly the same failure mode that
    -- `GUILD_EVENT_LOG_UPDATE` hit on Retail Midnight (legacy
    -- notification event silenced even though the legacy read API
    -- was kept). User-confirmed via `/togt gbcheck`: that command
    -- works because it waits 2 s before reading. Without this
    -- deferred ingest, the user opens the bank, our queries fire,
    -- the server responds, the per-tab buffers populate — and we
    -- never re-read because no event tells us to. Schedule an
    -- explicit `ingestTab` for every tab 2 s after the queries so
    -- we don't depend on the event firing. The
    -- `GUILDBANKLOG_UPDATE` handler is still wired (it does fire on
    -- some flavours) and `ingestTab` is idempotent via the dedupe
    -- map, so double-firing is harmless.
    --
    -- Uses NewTimer (not C_Timer.After) so we get a cancellable handle
    -- — ClearAll needs to be able to kill a pending pass that would
    -- otherwise revert the user's wipe.
    if C_Timer and C_Timer.NewTimer then
        cancelDeferredIngest()
        _deferredIngestTimer = C_Timer.NewTimer(2, function()
            -- Trace per-tab counts as we enter the deferred ingest —
            -- these are the values that matter, since they were
            -- supposed to be populated by the server response to the
            -- queries we fired 2 s ago.
            addon:Debug("[gblog] deferred ingest after requestAllTabs (2s elapsed)")
            for tab = 1, NUM_ITEM_TABS do
                local n = GetNumGuildBankTransactions and GetNumGuildBankTransactions(tab) or -1
                addon:Debug(string.format("[gblog]   tab=%d deferred-read GetNumGuildBankTransactions=%d", tab, n))
            end
            local moneyN = GetNumGuildBankMoneyTransactions and GetNumGuildBankMoneyTransactions() or -1
            addon:Debug(string.format("[gblog]   money deferred-read GetNumGuildBankMoneyTransactions=%d", moneyN))
            _deferredIngestTimer = nil
            for tab = 1, NUM_ITEM_TABS do ingestTab(tab) end
            ingestTab(MONEY_TAB)
            -- Final unconditional refresh — covers the case where the
            -- deferred ingest is the only thing that actually picked
            -- up new data (the per-tab GUILDBANKLOG_UPDATE events
            -- never fired, or fired before ingestTab updated SV).
            refreshSubTab()
        end)
        addon:Debug("[gblog] deferred ingest scheduled in 2s")
    else
        addon:Debug("[gblog] no C_Timer.NewTimer — deferred ingest cannot be scheduled")
    end
end

-- ---------------------------------------------------------------------------
-- Event wiring
-- ---------------------------------------------------------------------------
local _evtFrame = CreateFrame("Frame")
_evtFrame:RegisterEvent("PLAYER_LOGIN")
_evtFrame:RegisterEvent("GUILDBANKFRAME_OPENED")
_evtFrame:RegisterEvent("GUILDBANKFRAME_CLOSED")
_evtFrame:RegisterEvent("GUILDBANKLOG_UPDATE")
_evtFrame:RegisterEvent("GUILDBANK_UPDATE_TABS")
_evtFrame:RegisterEvent("GUILDBANK_UPDATE_MONEY")
_evtFrame:SetScript("OnEvent", function(_, event, arg1)
    -- Catch-all event trace. Behind the verbose-debug flag so it's
    -- silent in normal play, but invaluable when triaging "open the
    -- bank, nothing happens" reports — lets you see exactly which
    -- guild-bank events do (and don't) fire on your client.
    -- GUILDBANKFRAME_CLOSED / GUILDBANK_UPDATE_TABS /
    -- GUILDBANK_UPDATE_MONEY are registered purely for visibility;
    -- we don't act on them. If they fire when the user expected
    -- GUILDBANKFRAME_OPENED to fire, that's the diagnosis.
    addon:Debug(string.format("[gblog] event=%s arg1=%s", event, tostring(arg1)))

    if event == "PLAYER_LOGIN" then
        -- One-shot trace at login so the user can confirm the module
        -- loaded and the events list is what they expect. Useful when
        -- chasing "did my edits even take effect after /reload" — if
        -- this line doesn't appear in chat with debug on, the file
        -- didn't replicate to the running client.
        addon:Debug(string.format(
            "[gblog] PLAYER_LOGIN — file loaded; API present: Query=%s GetNumTxn=%s GetTxn=%s GetNumMoney=%s GetMoney=%s; BankFrame=%s",
            tostring(QueryGuildBankLog ~= nil),
            tostring(GetNumGuildBankTransactions ~= nil),
            tostring(GetGuildBankTransaction ~= nil),
            tostring(GetNumGuildBankMoneyTransactions ~= nil),
            tostring(GetGuildBankMoneyTransaction ~= nil),
            tostring(GuildBankFrame ~= nil)))
        -- Buffer is empty until the player actually visits a guild bank;
        -- the opened-event handler does the real work.
        return

    elseif event == "GUILDBANKFRAME_OPENED" or event == "GUILDBANK_UPDATE_TABS" then
        -- GUILDBANKFRAME_OPENED is the documented bank-open signal but
        -- empirically does NOT fire on TBC / Anniversary (user-confirmed
        -- via debug trace: only GUILDBANK_UPDATE_TABS x24 fires when the
        -- bank opens). GUILDBANK_UPDATE_TABS is Blizzard's "tab metadata
        -- changed" event — fires as bank tab info trickles in from the
        -- server. We use it as a proxy bank-open signal.
        --
        -- Debounce because GUILDBANK_UPDATE_TABS bursts ~24 times in
        -- quick succession; without the gate we'd fire the bank-open
        -- flow (immediate ingest + 8 queries + 2 s deferred ingest)
        -- 24 times back-to-back, hammering the server.
        local now = (GetTime and GetTime()) or 0
        if now - _lastBankOpenTrigger < BANK_OPEN_DEBOUNCE_SEC then
            return
        end
        _lastBankOpenTrigger = now

        addon:Debug(string.format(
            "[gblog] %s — IsInGuild=%s, enabled=%s, bankFrameShown=%s — bank-open flow",
            event,
            tostring(IsInGuild and IsInGuild() or false),
            tostring(isEnabled()),
            tostring(GuildBankFrame and GuildBankFrame:IsShown() or false)))

        -- Immediate ingest from Blizzard's local per-tab buffers BEFORE
        -- we kick off the refresh queries. Two reasons:
        --   1) If the bank was open in a previous session and we have
        --      buffered transactions cached in the client, they ingest
        --      instantly — the user sees data on bank-open rather than
        --      waiting for 8 server round-trips.
        --   2) Cheap insurance against missing the first
        --      GUILDBANKLOG_UPDATE that fires before our handler is
        --      definitely registered (rare but possible during login).
        -- ingestTab is idempotent via the dedupe map so re-running
        -- after the subsequent query response is harmless.
        for tab = 1, NUM_ITEM_TABS do ingestTab(tab) end
        ingestTab(MONEY_TAB)

        -- Unconditional UI refresh on bank-open, decoupled from
        -- ingestTab's newCount > 0 gate. Reason: when SV already has
        -- entries from a previous session, the immediate ingest above
        -- mostly dedupes (newCount=0), so ingestTab's internal refresh
        -- call never fires. Without this explicit refresh, the user
        -- opens the bank, expects to see fresh data, but the UI keeps
        -- showing whatever it had before — even though SV is correct.
        refreshSubTab()

        -- Blizzard's UI also queries on this event, so we're piggybacking
        -- on the same server roundtrip, not adding load. A 1 s deferral
        -- lets Blizzard's queries land first so our reads aren't racing
        -- partial buffers.
        if C_Timer and C_Timer.After then
            C_Timer.After(1, requestAllTabs)
        else
            requestAllTabs()
        end

    elseif event == "GUILDBANKFRAME_CLOSED" then
        -- Reset the debounce so the next bank-open re-triggers
        -- immediately rather than being suppressed for the rest of the
        -- 30 s window.
        addon:Debug("[gblog] GUILDBANKFRAME_CLOSED — resetting bank-open debounce")
        _lastBankOpenTrigger = 0

    elseif event == "GUILDBANKLOG_UPDATE" then
        -- arg1 = tab index that just updated. Some clients (TBC,
        -- empirically) pass nil here; when missing, ingest every tab —
        -- idempotent via dedupe.
        if tonumber(arg1) then
            ingestTab(tonumber(arg1))
        else
            for tab = 1, NUM_ITEM_TABS do ingestTab(tab) end
            ingestTab(MONEY_TAB)
        end
        -- Unconditional UI refresh after each event, regardless of
        -- whether ingestTab reported newCount > 0. The second / third /
        -- Nth GUILDBANKLOG_UPDATE in a burst typically reports
        -- newCount=0 (everything already deduped against earlier
        -- events) — without this, the UI never picks up the rows that
        -- the FIRST event added but the user hadn't seen yet because
        -- they happened to be on a different sub-tab at that moment.
        refreshSubTab()
    end
    -- GUILDBANKFRAME_CLOSED / GUILDBANK_UPDATE_TABS /
    -- GUILDBANK_UPDATE_MONEY: trace-only, no handling.
end)

-- ---------------------------------------------------------------------------
-- Read API consumed by GUI/GuildBankLogSubTab.lua
-- ---------------------------------------------------------------------------
function GuildBankLog:GetGuildKeys()
    local db = GuildBankLogDB()
    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.
function GuildBankLog:GetCurrentGuildKey()
    return GuildKey()
end

--- Filtered, newest-first list of entries.
---   guildKey      string|nil      guild bucket; nil = all guilds merged
---   filterType    string|nil      transaction type filter; nil = all types
---   fromTs, toTs  number|nil      inclusive ts bounds
---   filterTab     number|string?  tab filter; nil = all tabs; a number 1..MAX_GUILDBANK_TABS-1
---                                 matches entries where tab1 OR tab2 equals it
---                                 (Move transactions span two tabs, so we
---                                 match either side); the string "money"
---                                 matches only money-log entries (kind=="money").
function GuildBankLog:GetEntries(guildKey, filterType, fromTs, toTs, filterTab)
    local db = GuildBankLogDB()
    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 and filterTab then
                if filterTab == "money" then
                    if e.kind ~= "money" then ok = false end
                else
                    -- Number filter — match against tab1 OR tab2 because
                    -- a Move records its source tab in tab1 and its
                    -- destination in tab2, so the same physical move
                    -- should surface under either tab's filter.
                    local n = tonumber(filterTab)
                    if e.kind == "money" then
                        ok = false
                    elseif tonumber(e.tab1) ~= n and tonumber(e.tab2) ~= n then
                        ok = false
                    end
                end
            end
            if ok then
                -- Lazy-migrate: entries persisted before the rowId field
                -- existed get one computed and stored on first read.
                if not e.id then e.id = rowId(gk.key, e) end
                local copy = {
                    kind     = e.kind,
                    type     = e.type,
                    name     = e.name,
                    itemLink = e.itemLink,
                    itemSig  = e.itemSig,
                    count    = e.count,
                    amount   = e.amount,
                    tab1     = e.tab1,
                    tab2     = e.tab2,
                    y = e.y, m = e.m, d = e.d, h = e.h, ts = e.ts,
                    guildKey = gk.key,
                    id       = e.id,
                }
                tinsert(out, copy)
            end
        end
    end
    table.sort(out, function(a, b) return (a.ts or 0) > (b.ts or 0) end)
    return out
end

function GuildBankLog:ClearAll()
    -- Clear means clear: wipe the persisted SV bucket and let the UI
    -- redraw empty. Do NOT re-ingest from Blizzard's local buffers
    -- here — that would defeat the user's clear intent. To get fresh
    -- data after a Clear the user can close+reopen the bank (triggers
    -- GUILDBANKFRAME_OPENED → requestAllTabs → 2 s deferred ingest)
    -- or run `/togt gbcheck` which does the same.
    --
    -- ALSO cancel any deferred ingest queued by an earlier
    -- requestAllTabs (e.g. the user opened the bank, then clicked
    -- Clear within the 2 s deferral window) — otherwise the pending
    -- ingest fires moments after the wipe and silently re-populates
    -- the SV from Blizzard's per-tab buffers. User-reported as
    -- "Clear data button isn't clearing".
    cancelDeferredIngest()
    local db = GuildBankLogDB()
    if db then db.guilds = {} end
    refreshSubTab()
end

--- Manual re-query trigger. Used by future UI surfaces that want to
--- force a refresh without waiting for the next bank-frame open.
function GuildBankLog:Requery()
    requestAllTabs()
end

-- ---------------------------------------------------------------------------
-- Chat-print helpers used by Diagnose() and Dump() below. Module-scope so
-- both diagnostic surfaces can share them.
-- ---------------------------------------------------------------------------
local function dprint(line)
    print(addon.UI.Brand("GuildBankLog") .. " " .. 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 of the current guild's persisted bank transactions,
--- newest first. Every field on its own line per entry — NO formatting
--- of empty fields (so blank player names render as `name=''`, not
--- `name='—'`). Used to triage "the UI shows N blank players but the
--- bank API claimed N transactions"-style reports. Defaults to 100
--- entries; pass a different N via `/togt gbdump 30` etc. Per-event-
--- type stats are printed at the head so you can see at a glance which
--- transaction types are missing the player name.
function GuildBankLog: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

    -- Defensive sort newest-first; ingest already sorts but a Dump
    -- shouldn't depend on that staying true.
    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))

    -- Per-type breakdown: count entries and how many have a populated
    -- name. This is the actual question we're answering ("which
    -- transaction types are silently missing the player name"). Walks
    -- all entries (not just the dumped N) so the totals are accurate.
    local byType = {}
    for _, e in ipairs(sorted) do
        local t = e.type or "?"
        byType[t] = byType[t] or { total = 0, hasName = 0 }
        byType[t].total = byType[t].total + 1
        if e.name and e.name ~= "" then
            byType[t].hasName = byType[t].hasName + 1
        end
    end
    dprint("|cffffd700Per-type name population:|r")
    for t, b in pairs(byType) do
        local color = (b.hasName == b.total) and "ff66ff66" or "ffff5555"
        dprint(string.format(
            "  |c%s%-16s|r total=%d  hasName=%d  missing=%d",
            color, t, b.total, b.hasName, b.total - b.hasName))
    end

    -- Per-entry dump.
    for i = 1, dumpN do
        local e = sorted[i]
        dprint(string.format(
            "  [%d] kind=%s type=%s name='%s' itemSig='%s' count=%s amount=%s tab1=%s tab2=%s ymdh=%s/%s/%s/%s ts=%s",
            i,
            tostring(e.kind or ""),
            tostring(e.type or ""),
            tostring(e.name or ""),
            tostring(e.itemSig or ""),
            tostring(e.count),
            tostring(e.amount),
            tostring(e.tab1),
            tostring(e.tab2),
            tostring(e.y), tostring(e.m), tostring(e.d), tostring(e.h),
            tostring(e.ts)))
    end
end

function GuildBankLog:Diagnose()
    dprint("--- API availability ---")
    dprint("QueryGuildBankLog                : " .. yn(QueryGuildBankLog ~= nil))
    dprint("GetNumGuildBankTransactions      : " .. yn(GetNumGuildBankTransactions ~= nil))
    dprint("GetGuildBankTransaction          : " .. yn(GetGuildBankTransaction ~= nil))
    dprint("GetNumGuildBankMoneyTransactions : " .. yn(GetNumGuildBankMoneyTransactions ~= nil))
    dprint("GetGuildBankMoneyTransaction     : " .. yn(GetGuildBankMoneyTransaction ~= nil))
    dprint("GuildBankFrame                   : " .. yn(GuildBankFrame ~= nil))
    dprint("GuildBankFrame:IsShown()         : " .. yn(GuildBankFrame and GuildBankFrame:IsShown() or false))
    dprint("IsInGuild                        : " .. yn(IsInGuild and IsInGuild() or false))

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

    if not QueryGuildBankLog then
        dprint("|cffff5555Bank API absent — likely Vanilla / Classic Era (guild banks added in TBC). Nothing to diagnose further.|r")
        return
    end

    if not (GuildBankFrame and GuildBankFrame:IsShown()) then
        dprint("|cffff5555GuildBankFrame is not currently shown.|r The guild bank log API only returns data when the player has the bank UI open; |cffffd700open the guild bank in-game, then re-run /togt gbcheck|r.")
        return
    end

    dprint("--- Forcing per-tab QueryGuildBankLog() then re-reading in 2s... ---")
    for tab = 1, NUM_ITEM_TABS do QueryGuildBankLog(tab) end
    QueryGuildBankLog(MONEY_TAB)

    C_Timer.After(2, function()
        for tab = 1, NUM_ITEM_TABS do
            local n = GetNumGuildBankTransactions and GetNumGuildBankTransactions(tab) or 0
            dprint(string.format("Tab %d: |cffffd700%d|r transactions in Blizzard buffer", tab, n))
            if n > 0 then
                -- First entry only — keeps chat output reasonable.
                local etype, name, itemLink, count, t1, t2, y, m, d, h =
                    GetGuildBankTransaction(tab, 1)
                dprint(string.format(
                    "  [tab=%d i=1] type=%s name=%s item=%s count=%s t1=%s t2=%s ymdh=%s/%s/%s/%s",
                    tab,
                    tostring(etype), tostring(name),
                    tostring(itemLink and itemLink:match("|h%[(.-)%]|h") or itemLink),
                    tostring(count), tostring(t1), tostring(t2),
                    tostring(y), tostring(m), tostring(d), tostring(h)))
            end
        end
        local moneyN = GetNumGuildBankMoneyTransactions and GetNumGuildBankMoneyTransactions() or 0
        dprint(string.format("Money log: |cffffd700%d|r transactions in Blizzard buffer", moneyN))
        if moneyN > 0 then
            local etype, name, amount, y, m, d, h = GetGuildBankMoneyTransaction(1)
            dprint(string.format(
                "  [money i=1] type=%s name=%s amount=%s ymdh=%s/%s/%s/%s",
                tostring(etype), tostring(name), tostring(amount),
                tostring(y), tostring(m), tostring(d), tostring(h)))
        end

        dprint("--- Running ingest for all tabs now (idempotent via dedupe) ---")
        for tab = 1, NUM_ITEM_TABS do ingestTab(tab) end
        ingestTab(MONEY_TAB)

        local bucket = key and guildBucket(key) or nil
        local n = bucket and bucket.entries and #bucket.entries or 0
        dprint(string.format("Final persisted entry count       : |cffffd700%d|r", n))
        dprint("If counts went up — capture is working; the issue is display-side.")
        dprint("If counts stayed flat — capture is failing. Enable |cffffd700/togt settings > Verbose debug output|r and re-open the bank to see the per-event debug trace.")
    end)
end

--- Public hook for future TOGBankClassic integration to inject entries
--- captured by that addon's own snapshot mechanism. Expected entry shape
--- matches readItemTxn / readMoneyTxn (kind, type, name, ts, count or
--- amount, etc.). Caller supplies the guildKey ("GuildName-Realm" form);
--- itemSig is auto-derived from itemLink if not supplied.
function GuildBankLog:InjectEntry(guildKey, entry)
    if not guildKey or not entry then return end
    local bucket = guildBucket(guildKey)
    if not bucket then return end

    entry.itemSig = entry.itemSig or linkSig(entry.itemLink)

    local seen = {}
    for _, e in ipairs(bucket.entries) do seen[dedupeKey(e)] = true end
    if seen[dedupeKey(entry)] then return end

    entry.id = rowId(guildKey, entry)
    tinsert(bucket.entries, entry)
    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
