-- 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), 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.",
    },
}

-- 5-minute safety re-query for users who park at the bank watching new
-- transactions trickle in from guildmates. Cheap because we only fire it
-- when the bank frame is currently shown.
local QUERY_INTERVAL_SEC = 5 * 60

-- 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 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 == "guildbank" and mw.Refresh then
        mw:Refresh()
    end
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, name, 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). Same dedupe-collapse caveat as GuildLog: two
-- truly-distinct transactions with identical fields collapse into one.
local function dedupeKey(entry)
    return string.format("%s|%s|%s|%s|%s|%s|%s",
        tostring(entry.type    or ""),
        tostring(entry.name    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|"). 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|%s",
        tostring(guildKey       or ""),
        tostring(entry.type     or ""),
        tostring(entry.name     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

-- ---------------------------------------------------------------------------
-- 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 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 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 return end
    local key = GuildKey()
    if not key then return end
    local bucket = guildBucket(key)
    if not bucket then return end

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

    local newCount = 0
    if tabIndex == MONEY_TAB then
        local n = GetNumGuildBankMoneyTransactions and GetNumGuildBankMoneyTransactions() or 0
        for i = 1, n do
            local e = readMoneyTxn(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
                end
            end
        end
    else
        local n = GetNumGuildBankTransactions and GetNumGuildBankTransactions(tabIndex) or 0
        for i = 1, n do
            local e = readItemTxn(tabIndex, 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
                end
            end
        end
    end

    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

local function requestAllTabs()
    if not isEnabled() then return end
    if not QueryGuildBankLog then return end
    if not IsInGuild or not IsInGuild() then return end
    for tab = 1, NUM_ITEM_TABS do
        QueryGuildBankLog(tab)
    end
    QueryGuildBankLog(MONEY_TAB)
end

-- ---------------------------------------------------------------------------
-- Event wiring
-- ---------------------------------------------------------------------------
local _evtFrame = CreateFrame("Frame")
_evtFrame:RegisterEvent("PLAYER_LOGIN")
_evtFrame:RegisterEvent("GUILDBANKFRAME_OPENED")
_evtFrame:RegisterEvent("GUILDBANKLOG_UPDATE")
_evtFrame:SetScript("OnEvent", function(_, event, arg1)
    if event == "PLAYER_LOGIN" then
        -- Buffer is empty until the player actually visits a guild bank;
        -- the opened-event handler does the real work.
        return

    elseif event == "GUILDBANKFRAME_OPENED" then
        -- 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 == "GUILDBANKLOG_UPDATE" then
        -- arg1 = tab index that just updated. Some clients 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
    end
end)

-- Safety re-query for users parked at the bank waiting on guildmate
-- transactions. Only fires when the bank frame is currently shown, so
-- it's a no-op the rest of the time.
if C_Timer and C_Timer.NewTicker then
    C_Timer.NewTicker(QUERY_INTERVAL_SEC, function()
        if GuildBankFrame and GuildBankFrame:IsShown() then
            requestAllTabs()
        end
    end)
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
function GuildBankLog:GetEntries(guildKey, filterType, fromTs, toTs)
    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 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()
    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

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