-- TOG Tools — Mail Log (engine)
--
-- Captures every mail received (taken from the inbox) and sent (composed
-- via SendMail). Append-only flat list under `db.global.mailLog.entries`.
--
-- Entry shape:
--   { ts, dir = "in"|"out", player, other, subject,
--     items = { { link, count }, ... }, money, cod }
--
-- Receive hook recipe (standard WoW mail-API pattern):
--   * hooksecurefunc("TakeInboxItem", MailIndex, ItemIndex)
--   * hooksecurefunc("AutoLootMailItem", MailIndex)       -- "Take All"
--   * hooksecurefunc("TakeInboxMoney", MailIndex)
-- Each take fires while the mail row is being processed; consecutive
-- takes from the same sender within MERGE_WINDOW_SEC merge into the
-- most-recent entry so a 12-item "Take All" produces one row, not 12.
--
-- Send hook recipe:
--   * hooksecurefunc("SendMail", recipient, subject, body)
--     captures recipient + subject before the actual send.
--   * MAIL_SEND_INFO_UPDATE event rebuilds the staged item cache (the
--     compose window's 12 attachment slots).
--   * UI_INFO_MESSAGE with arg2 == ERR_MAIL_SENT commits the entry.
--
-- Sub-category registration: MailLog is NOT a top-level addon.modules
-- entry. It lives in addon.logCategories["mail"], where the Logs outer
-- tab's nested TabGroup finds it. The sub-tab Draw is merged on by
-- GUI/MailLogSubTab.lua.

local _, addon = ...

-- Fingerprint global so the user can verify which version of MailLog.lua
-- is actually loaded via `/run print(TOGTOOLS_MAILLOG_VERSION)`. Bump
-- this when shipping a new MailLog change to confirm /reload pulled it
-- in. (Diagnostic only; remove before release.)
_G.TOGTOOLS_MAILLOG_VERSION = "v0.5.2-tsm-lazy-detect-2026-05-26-13:55"

local MailLog = {}
addon.logCategories = addon.logCategories or {}
addon.logCategories["mail"] = MailLog

MailLog.subKey    = "mail"
MailLog.label     = "Mail"
MailLog.configKey = "mailLog"
MailLog.help = {
    title = "Logs > Mail",
    lines = {
        "Every mail received (inbox takes) and sent (composed via the in-game mail UI) is appended here automatically.",
        "|cffffd700Receive merging:|r consecutive takes from the same sender within 5 seconds collapse into one entry, so a 12-item Take-All produces one row, not 12. Items in that entry are stacked by link.",
        "|cffffd700Filters:|r Character (per-alt), Direction (Received / Sent / Both), Date range. Newest entries at the top by default — click any column header to re-sort.",
        "|cffffd700Other column:|r |cff66ff66<-|r prefix = received from this sender; |cffffaa55->|r prefix = sent to this recipient.",
        "|cffffd700Items column:|r leads with the first item link; multi-item mails show \"+N more\" on the right.",
        "|cffffd700Clear data:|r two-step confirm. First click flips to \"Really? (5s)\"; second click within 5 s wipes every entry. Lets the timer expire to cancel.",
    },
}

local MERGE_WINDOW_SEC = 5

-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
local function CharKey()
    local realm = (GetRealmName() or ""):gsub("%s+", "")
    return (UnitName("player") or "?") .. "-" .. realm
end

local function MailLogDB()
    return addon.DB and addon.DB.global and addon.DB.global.mailLog
end

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

-- Refresh the Mail sub-tab when it's the active sub-tab inside an open
-- Logs tab. The outer MainWindow:Refresh re-runs DrawTab for the active
-- outer tab; LogsTab redraws its inner TabGroup and calls MailLog:Draw
-- which re-reads entries from SV. Cheap and idempotent.
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
    local activeSub = frames and frames.logsActiveSubTab
    if activeSub == "mail" and mw.Refresh then
        mw:Refresh()
    end
end

-- ---------------------------------------------------------------------------
-- Receive capture
-- ---------------------------------------------------------------------------
-- Per-MailIndex cache of "currently-being-taken" entries. Items, money,
-- and COD-from-header updates for the same mail merge into one entry;
-- a take on a different MailIndex creates a new entry even if the
-- sender matches. The earlier "merge by last-entry sender + 5 sec
-- window" lost per-mail granularity when an addon (Postal, etc.) opened
-- multiple mails from the same sender in quick succession — they all
-- collapsed into one row. Cache is wiped on MAIL_CLOSED.
local _receiveCache = {}  -- [MailIndex] = entry
-- Dedupe between take-hook path and snapshot-diff path. Keyed by
-- `sender .. "|" .. subject` (NOT MailIndex — indices shift after every
-- take, and the snapshot stores the index a mail was at at snapshot
-- time, while the take-hook fires on the LIVE post-shift index, so the
-- lookups didn't match and snapshot logged duplicates). Reference-
-- counted so multiple distinct mails with the same (sender, subject) —
-- e.g. a batch of identical AH expirations — increment and decrement
-- in lockstep. Wiped on MAIL_CLOSED.
local _takenSenders = {}   -- [sender|subject] = stamp count

local function appendReceive(MailIndex, itemLink, itemCount, money)
    addon:Debug(string.format(
        "[mail] appendReceive idx=%s itemLink=%s count=%s money=%s",
        tostring(MailIndex), tostring(itemLink), tostring(itemCount), tostring(money)))
    if not isEnabled() then
        addon:Debug("[mail]   appendReceive abort: log disabled")
        return
    end
    local db = MailLogDB()
    if not db then
        addon:Debug("[mail]   appendReceive abort: MailLogDB() nil")
        return
    end
    db.entries = db.entries or {}

    local _, _, sender, subject, _, codAmount, daysLeft = GetInboxHeaderInfo(MailIndex)
    local firstLink = GetInboxItemLink(MailIndex, 1)
    addon:Debug(string.format(
        "[mail]   header read: sender=%s subject=%s daysLeft=%s firstLink=%s cod=%s",
        tostring(sender), tostring(subject), tostring(daysLeft),
        tostring(firstLink), tostring(codAmount)))
    -- If we can't identify the mail, abort. This is the TSM-tainted-
    -- inbox-cache scenario: TSM's deferred ScanCollectedMail fires our
    -- hook AFTER the original TakeInboxItem has run, which on some
    -- flavours clears the client-side header cache. Defaulting sender
    -- to "?" / subject to "" would still create an entry, but it'd be
    -- a placeholder row AND every subsequent take in the same 5-second
    -- window from the same sender would `mergeable=true` against it
    -- (same sender, same empty subject) and silently collapse into
    -- the same row. Bailing here lets the snapshot-diff fallback log
    -- the mail using the data it captured at MAIL_SHOW time, which was
    -- BEFORE any taint propagated.
    --
    -- Subject is checked for nil (read failed) NOT for empty string
    -- (legitimate no-subject player mail). The Blizzard API returns nil
    -- when the field couldn't be resolved, "" when the mail genuinely
    -- has no subject.
    if not sender or sender == "" then
        addon:Debug("[mail]   appendReceive abort: header sender empty (likely TSM-tainted post-take cache)")
        return
    end
    if subject == nil then
        addon:Debug("[mail]   appendReceive abort: header subject nil (likely TSM-tainted post-take cache)")
        return
    end
    local now    = GetServerTime() or time()
    local player = CharKey()

    -- Mail identity = (sender, subject, daysLeft, firstAttachmentLink). WoW
    -- doesn't surface a per-mail GUID to Lua, so this composite is the
    -- strongest practical identity we can build:
    --   * sender         — the player or NPC that sent the mail
    --   * subject        — mail subject text (AH mails encode the item name)
    --   * daysLeft       — fractional days until expiry; arrived-at-distinct-
    --                      moments mails differ at the microsecond level
    --                      (6-decimal float, e.g. 29.623275756836). Same-
    --                      arrival-tick mails (50 AH batch expirations) are
    --                      indistinguishable on this field — firstLink
    --                      catches that case.
    --   * firstLink      — full item link of slot 1 (encodes uniqueID, suffix,
    --                      enchant, gems). Two physical instances of even
    --                      the same item type usually have different
    --                      uniqueIDs so their links differ.
    -- daysLeft uses STRICT equality (not a tolerance window). Multi-take of
    -- the SAME mail via AutoLootMailItem happens inside one hook invocation
    -- so all reads return literally the same daysLeft value. Multi-take via
    -- separate TakeInboxItem clicks several seconds apart will see daysLeft
    -- tick down (~1.16e-5/sec) and won't merge — that's a deliberate trade-
    -- off: the click-each-slot pattern is rare and producing one row per
    -- click is acceptable, vs. the AH-return-merge bug which is common and
    -- destructive.
    local cached = _receiveCache[MailIndex]
    local sameSender   = cached and cached.other     == sender
    local sameSubject  = cached and cached.subject   == subject
    local sameLink     = cached and cached.firstLink == firstLink
    local sameDaysLeft = cached and cached.daysLeft  == daysLeft
    local withinWindow = cached and (now - (cached.ts or 0)) <= MERGE_WINDOW_SEC
    local mergeable = cached
        and sameSender
        and sameSubject
        and sameLink
        and sameDaysLeft
        and withinWindow
    if cached then
        addon:Debug(string.format(
            "[mail]   merge-check: sender=%s subject=%s link=%s daysLeft=%s win=%s -> %s",
            tostring(sameSender), tostring(sameSubject), tostring(sameLink),
            tostring(sameDaysLeft), tostring(withinWindow),
            mergeable and "MERGE" or "NEW ENTRY"))
        if not sameDaysLeft then
            addon:Debug(string.format(
                "[mail]     daysLeft cached=%s current=%s",
                tostring(cached.daysLeft), tostring(daysLeft)))
        end
    else
        addon:Debug("[mail]   merge-check: no cached entry for this MailIndex -> NEW ENTRY")
    end
    local entry
    if mergeable then
        entry    = cached
        entry.ts = now
    else
        entry = {
            ts        = now,
            dir       = "in",
            player    = player,
            other     = sender,
            subject   = subject,
            items     = {},
            money     = 0,
            cod       = (codAmount or 0) > 0,
            daysLeft  = daysLeft,   -- per-mail identity, see merge-check above
            firstLink = firstLink,
        }
        tinsert(db.entries, entry)
        _receiveCache[MailIndex] = entry
        -- Increment the take-hook stamp ONLY when a NEW entry is created
        -- (a fresh physical mail captured). Multi-item takes that merge
        -- into an existing entry don't increment — otherwise a 5-item
        -- mail would stamp 5 times for one physical mail, leaving 4
        -- stale stamps that would dedupe wrongly when a different mail
        -- from the same sender+subject arrives later in the session.
        local takenKey = sender .. "|" .. subject
        _takenSenders[takenKey] = (_takenSenders[takenKey] or 0) + 1
    end

    if itemLink and (itemCount or 0) > 0 then
        -- Merge by itemLink so a 5×5 stack from "Take All" shows as one
        -- row "[Link] ×5" rather than five rows of ×1.
        local merged = false
        for _, it in ipairs(entry.items) do
            if it.link == itemLink then
                it.count = (it.count or 0) + itemCount
                merged = true
                break
            end
        end
        if not merged then
            tinsert(entry.items, { link = itemLink, count = itemCount })
        end
    end
    if money and money > 0 then
        entry.money = (entry.money or 0) + money
    end

    addon:Debug(string.format(
        "[mail]   appended/merged entry — db.entries now has %d total; itemsInEntry=%d money=%d",
        #db.entries, #entry.items, entry.money or 0))

    local Logs = addon.modules and addon.modules["logs"]
    if Logs and Logs.PruneEntries then Logs:PruneEntries(db.entries) end
    refreshSubTab()
end

-- Saved references to our hooksecurefunc-wrapped globals. If another addon
-- (TSM does this) globally replaces _G.TakeInboxItem after us, the saved
-- reference no longer equals _G.TakeInboxItem at PLAYER_LOGIN — that's the
-- signature we use to detect "someone wrapped us in a deferred chain" even
-- when IsAddOnLoaded refuses to report the offending addon.
local _ourTakeInboxItemRef
local _ourAutoLootMailItemRef
local _ourTakeInboxMoneyRef

hooksecurefunc("TakeInboxItem", function(MailIndex, ItemIndex)
    addon:Debug(string.format("[mail] TakeInboxItem hook fired idx=%s slot=%s",
        tostring(MailIndex), tostring(ItemIndex)))
    if not isEnabled() then
        addon:Debug("[mail]   skip: log disabled")
        return
    end
    local itemLink = GetInboxItemLink(MailIndex, ItemIndex)
    local _, _, _, count = GetInboxItem(MailIndex, ItemIndex)
    if itemLink then
        appendReceive(MailIndex, itemLink, count or 1, 0)
    else
        addon:Debug("[mail]   take-hook saw no item link — letting snapshot path handle it")
    end
end)
_ourTakeInboxItemRef = _G.TakeInboxItem

hooksecurefunc("AutoLootMailItem", function(MailIndex)
    addon:Debug(string.format("[mail] AutoLootMailItem hook fired idx=%s", tostring(MailIndex)))
    if not isEnabled() then
        addon:Debug("[mail]   skip: log disabled")
        return
    end
    -- AutoLootMailItem takes BOTH items and money in one server call — it
    -- does NOT internally dispatch through TakeInboxItem / TakeInboxMoney,
    -- so we must capture both ourselves here. If both reads come back
    -- empty (no item links AND money == 0) we deliberately call
    -- appendReceive zero times so the snapshot-diff path picks up the
    -- vanishing mail using its MAIL_SHOW snapshot data.
    local _, _, _, _, money = GetInboxHeaderInfo(MailIndex)
    addon:Debug(string.format("[mail]   header money read: %s", tostring(money)))
    local captured = false
    local foundLinks = 0
    for i = 1, 12 do
        local itemLink = GetInboxItemLink(MailIndex, i)
        if itemLink then
            foundLinks = foundLinks + 1
            local _, _, _, count = GetInboxItem(MailIndex, i)
            addon:Debug(string.format(
                "[mail]   slot %d: link=%s count=%s", i, tostring(itemLink), tostring(count)))
            appendReceive(MailIndex, itemLink, count or 1, 0)
            captured = true
        end
    end
    addon:Debug(string.format("[mail]   scanned 12 slots, found %d item links", foundLinks))
    if money and money > 0 then
        appendReceive(MailIndex, nil, 0, money)
        captured = true
    end
    if not captured then
        addon:Debug("[mail]   take-hook captured nothing — letting snapshot path handle it")
    end
end)
_ourAutoLootMailItemRef = _G.AutoLootMailItem

hooksecurefunc("TakeInboxMoney", function(MailIndex)
    addon:Debug(string.format("[mail] TakeInboxMoney hook fired idx=%s", tostring(MailIndex)))
    if not isEnabled() then
        addon:Debug("[mail]   skip: log disabled")
        return
    end
    local _, _, _, _, money = GetInboxHeaderInfo(MailIndex)
    if money and money > 0 then
        appendReceive(MailIndex, nil, 0, money)
    else
        addon:Debug("[mail]   take-hook saw no money — letting snapshot path handle it")
    end
end)
_ourTakeInboxMoneyRef = _G.TakeInboxMoney

-- ---------------------------------------------------------------------------
-- Inbox-snapshot diff capture (TSM-coexistence FALLBACK — opt-in)
-- ---------------------------------------------------------------------------
-- The hook path above captures every mail correctly for the vast majority
-- of users. This snapshot path exists ONLY because TradeSkillMaster does
-- a full global function replacement on TakeInboxItem / AutoLootMailItem
-- / TakeInboxMoney at its Mail.OnInitialize, then runs up to 5 deferred
-- 0.2-second retries before calling back to the original via
-- `private.hooks[oFunc]`. By the time our hooksecurefunc callback fires
-- through TSM's chain, the Blizzard client-side inbox cache is already
-- cleared and GetInboxItemLink / GetInboxHeaderInfo return nil — our
-- hook captures nothing and the mail is lost.
--
-- The snapshot path doesn't hook any Blizzard mail-take API — it only
-- listens to the MAIL_SHOW / MAIL_INBOX_UPDATE / MAIL_CLOSED events.
-- It snapshots inbox state on MAIL_SHOW and every MAIL_INBOX_UPDATE,
-- and when a (sender, subject) bucket loses an occupant between
-- snapshots — i.e. a mail vanished from the inbox, which means it was
-- taken or auto-returned — we log that vanished mail using the
-- metadata captured in the prior snapshot.
--
-- Registration is GATED on `IsAddOnLoaded("TradeSkillMaster")` at
-- PLAYER_LOGIN. Users without TSM never see this path run; the events
-- are not even registered. This keeps the hook path the single source
-- of truth for the common case and avoids any chance of the dedupe
-- machinery causing missed-mail bugs for users who never had a problem.
--
-- Dedupe vs the take-hooks: appendReceive increments
-- `_takenSenders[sender|subject]` ONLY when it creates a brand-new
-- entry (multi-item single-mail takes that merge into an existing
-- entry don't increment, otherwise a 5-item mail would stamp 5
-- times for one physical mail). When a mail vanishes in the
-- snapshot diff, appendReceiveFromSnapshot decrements one stamp
-- under the same (sender, subject) key — if a stamp was present
-- the take-hook already wrote that mail and we skip; if the count
-- is zero or absent, the take-hook never captured this mail (e.g.
-- under TSM taint) and we log via snapshot.
--
-- Why count, not boolean? A batch of N identical AH expirations
-- shares one (sender, subject) key. The take-hook fires N times,
-- bumping the count to N. The snapshot diff sees N vanishings and
-- decrements N times — each take stays deduped. A boolean flag
-- would dedupe only the first and log N-1 dupes.
--
-- Why (sender, subject), not mailIndex? mailIndex DRIFTS — the
-- snapshot stores the index a mail was at when it was snapshotted,
-- but the take-hook fires at the LIVE index after the inbox has
-- shifted. Keys based on stable mail metadata (sender, subject)
-- don't drift.
--
-- `_takenSenders` is wiped on MAIL_CLOSED so stamps don't leak
-- across mailbox sessions.
local _inboxSnapshot = {}   -- [sender .. "|" .. subject] = array of mail tuples

local function captureInboxItems(MailIndex)
    local out = {}
    for i = 1, 12 do
        local itemLink = GetInboxItemLink(MailIndex, i)
        if itemLink then
            local _, _, _, count = GetInboxItem(MailIndex, i)
            tinsert(out, { link = itemLink, count = count or 1 })
        end
    end
    return out
end

local function snapshotInbox()
    local snap = {}
    local n = GetInboxNumItems and GetInboxNumItems() or 0
    local totalMails = 0
    for idx = 1, n do
        local _, _, sender, subject, money, codAmount = GetInboxHeaderInfo(idx)
        local items = captureInboxItems(idx)
        local key   = (sender or "?") .. "|" .. (subject or "")
        snap[key]   = snap[key] or {}
        tinsert(snap[key], {
            mailIndex = idx,
            sender    = sender  or "?",
            subject   = subject or "",
            money     = money   or 0,
            items     = items,
            cod       = (codAmount or 0) > 0,
        })
        totalMails = totalMails + 1
    end
    addon:Debug(string.format("[mail] snapshot: captured %d mails (GetInboxNumItems=%d)", totalMails, n))
    return snap
end

local function appendReceiveFromSnapshot(mail)
    -- Dedupe against the take-hook path: if a take-hook stamped a mail
    -- with this (sender, subject), the entry is already in SV. Consume
    -- ONE stamp from the count so the next vanishing for the same key
    -- (e.g. a batch of identical AH expirations, or a second mail from
    -- the same sender+subject) gets independently deduped or logged
    -- depending on whether the take-hook captured it. We key on
    -- sender|subject — NOT mailIndex — because the snapshot stores the
    -- index the mail was at when SNAPSHOTTED (often far from its live
    -- index by take time, since takes shift the inbox).
    local takenKey = mail.sender .. "|" .. mail.subject
    local stampCount = _takenSenders[takenKey]
    if stampCount and stampCount > 0 then
        if stampCount == 1 then
            _takenSenders[takenKey] = nil
        else
            _takenSenders[takenKey] = stampCount - 1
        end
        addon:Debug(string.format(
            "[mail] snapshot: skip — take-hook already logged key=%s (remaining stamps=%d)",
            takenKey, _takenSenders[takenKey] or 0))
        return
    end
    local db = MailLogDB()
    if not db then return end
    db.entries = db.entries or {}
    tinsert(db.entries, {
        ts      = GetServerTime() or time(),
        dir     = "in",
        player  = CharKey(),
        other   = mail.sender,
        subject = mail.subject,
        items   = mail.items,
        money   = mail.money,
        cod     = mail.cod,
    })
    addon:Debug(string.format(
        "[mail] snapshot: LOGGED vanished idx=%s sender=%s subject=%s items=%d money=%d",
        tostring(mail.mailIndex), tostring(mail.sender), tostring(mail.subject),
        #mail.items, mail.money or 0))
    local Logs = addon.modules and addon.modules["logs"]
    if Logs and Logs.PruneEntries then Logs:PruneEntries(db.entries) end
    refreshSubTab()
end

local function diffSnapshots(prev, current)
    -- For each (sender, subject) bucket in prev, if current has fewer
    -- entries than prev, the missing tail entries vanished. Log each.
    local vanishings = 0
    for key, prevList in pairs(prev) do
        local curList  = current[key]
        local curCount = curList and #curList or 0
        if curCount < #prevList then
            for i = curCount + 1, #prevList do
                vanishings = vanishings + 1
                appendReceiveFromSnapshot(prevList[i])
            end
        end
    end
    if vanishings > 0 then
        addon:Debug(string.format("[mail] diff: %d mail vanishing(s) detected", vanishings))
    end
end

-- The snapshot path is only NEEDED when an addon does a global replacement
-- of the mail-take APIs and breaks our hooksecurefunc chain. The canonical
-- offender is TradeSkillMaster (see comment block above). For users
-- without TSM, the hook path captures every mail correctly and the
-- snapshot path is pure overhead — and worse, the dedupe machinery
-- between the two paths is one more thing that can subtly malfunction
-- and cause missed-mail bugs for the 90% of users who never had a
-- problem in the first place. So: detect TSM once at PLAYER_LOGIN and
-- only register the snapshot events when it's loaded.
local _tsmLoaded     = false   -- set lazily on first MAIL_SHOW (see detectTSM)
local _tsmDetectDone = false   -- one-shot guard; detection runs once per session

-- Forward-declare so MAIL_SHOW handler below can call into the detection
-- routine defined further down. (The MAIL_SHOW closure captures detectTSM
-- as an upvalue; without this forward-declaration the closure would treat
-- it as a global lookup and resolve nil at runtime.)
local detectTSM

local _mailSnapFrame = CreateFrame("Frame")
_mailSnapFrame:SetScript("OnEvent", function(_, event)
    if event == "MAIL_SHOW" then
        addon:Debug("[mail] MAIL_SHOW — snapshotting inbox (TSM-coexistence path)")
        -- Detect TSM lazily — by MAIL_SHOW time, TSM (if installed) has
        -- fully initialised and globally swapped the mail APIs.
        if not _tsmDetectDone then
            _tsmDetectDone = true
            _tsmLoaded = detectTSM()
            if _tsmLoaded then
                addon:Debug("[mail] TradeSkillMaster detected — snapshot-diff path active")
            else
                addon:Debug("[mail] TradeSkillMaster not detected — hook path only")
            end
        end
        _inboxSnapshot = (_tsmLoaded and isEnabled()) and snapshotInbox() or {}
    elseif event == "MAIL_INBOX_UPDATE" then
        if not (_tsmLoaded and isEnabled()) then return end
        addon:Debug("[mail] MAIL_INBOX_UPDATE — re-snapshotting and diffing")
        local newSnap = snapshotInbox()
        diffSnapshots(_inboxSnapshot, newSnap)
        _inboxSnapshot = newSnap
    elseif event == "MAIL_CLOSED" then
        addon:Debug("[mail] MAIL_CLOSED — clearing snapshot + takenSenders")
        _inboxSnapshot = {}
        wipe(_takenSenders)
    end
end)
-- Snapshot events are ALWAYS registered. The MAIL_SHOW handler decides at
-- runtime whether to actually snapshot/log (based on TSM detection).
_mailSnapFrame:RegisterEvent("MAIL_SHOW")
_mailSnapFrame:RegisterEvent("MAIL_INBOX_UPDATE")
_mailSnapFrame:RegisterEvent("MAIL_CLOSED")

-- TSM detection runs LAZILY on first MAIL_SHOW each session, not at
-- PLAYER_LOGIN. Reasons:
--   * IsAddOnLoaded("TradeSkillMaster") returns false at PLAYER_LOGIN on
--     Classic Era + recent TSM, confirmed empirically (TSM's own chat
--     spam printed but the API still returned nil).
--   * TSM's global swap happens in Mail.OnInitialize, which AceAddon may
--     run AFTER our PLAYER_LOGIN handler depending on addon-namespace
--     init ordering.
--   * By the time the user opens the mailbox, TSM is unambiguously
--     fully initialised — Mail.OnInitialize has run, _G.TakeInboxItem
--     has been swapped if TSM is going to swap it, and any of the
--     detection signals will fire reliably.
-- Detection probes four signals; trip if ANY says TSM is present:
--   1. IsAddOnLoaded (legacy global)
--   2. C_AddOns.IsAddOnLoaded (Retail post-10.2 replacement)
--   3. _G.TradeSkillMaster (the addon namespace table)
--   4. _G.<mail-api> swapped from our saved reference (the strongest signal —
--      catches anyone intercepting mail-take APIs even if they hide
--      their addon name)
detectTSM = function()
    local sig1 = IsAddOnLoaded and IsAddOnLoaded("TradeSkillMaster") and true or false
    local sig2 = C_AddOns and C_AddOns.IsAddOnLoaded
        and C_AddOns.IsAddOnLoaded("TradeSkillMaster") and true or false
    local sig3 = _G.TradeSkillMaster and true or false
    local sig4 = (_ourTakeInboxItemRef    and _G.TakeInboxItem    ~= _ourTakeInboxItemRef)
              or (_ourAutoLootMailItemRef and _G.AutoLootMailItem ~= _ourAutoLootMailItemRef)
              or (_ourTakeInboxMoneyRef   and _G.TakeInboxMoney   ~= _ourTakeInboxMoneyRef)
              or false
    addon:Debug(string.format(
        "[mail] TSM detect signals: IsAddOnLoaded=%s C_AddOns.IsAddOnLoaded=%s _G.TradeSkillMaster=%s globalsReplaced=%s",
        tostring(sig1), tostring(sig2), tostring(sig3), tostring(sig4)))
    return sig1 or sig2 or sig3 or sig4
end

-- ---------------------------------------------------------------------------
-- Send capture
-- ---------------------------------------------------------------------------
local _pendingSend = nil   -- { recipient, subject, items, money }

local function rebuildSendItemsCache()
    _pendingSend = _pendingSend or {}
    _pendingSend.items = {}
    for i = 1, 12 do
        local link = GetSendMailItemLink(i)
        if link then
            local _, _, _, count = GetSendMailItem(i)
            local merged = false
            for _, it in ipairs(_pendingSend.items) do
                if it.link == link then
                    it.count = (it.count or 0) + (count or 1)
                    merged = true
                    break
                end
            end
            if not merged then
                tinsert(_pendingSend.items, { link = link, count = count or 1 })
            end
        end
    end
end

hooksecurefunc("SendMail", function(recipient, subject)
    if not isEnabled() then return end
    _pendingSend = _pendingSend or {}
    _pendingSend.recipient = recipient
    _pendingSend.subject   = subject
end)

local _evtFrame = CreateFrame("Frame")
_evtFrame:RegisterEvent("MAIL_SEND_INFO_UPDATE")
_evtFrame:RegisterEvent("MAIL_CLOSED")
_evtFrame:RegisterEvent("UI_INFO_MESSAGE")
_evtFrame:SetScript("OnEvent", function(_, event, ...)
    if event == "MAIL_SEND_INFO_UPDATE" then
        if isEnabled() then rebuildSendItemsCache() end

    elseif event == "MAIL_CLOSED" then
        -- Compose window is gone; drop any in-flight send cache. Also
        -- wipe the per-MailIndex receive cache so re-opening the mail-
        -- box starts fresh — indices may have shifted between visits.
        _pendingSend = nil
        wipe(_receiveCache)

    elseif event == "UI_INFO_MESSAGE" then
        local _, msg = ...
        if msg == ERR_MAIL_SENT and isEnabled()
           and _pendingSend and _pendingSend.recipient then
            local db = MailLogDB()
            if not db then return end
            db.entries = db.entries or {}
            -- Re-read items + money at commit time so anything added
            -- between MAIL_SEND_INFO_UPDATE and the send confirmation
            -- is captured.
            rebuildSendItemsCache()
            local money = GetSendMailMoney() or 0
            tinsert(db.entries, {
                ts      = GetServerTime() or time(),
                dir     = "out",
                player  = CharKey(),
                other   = _pendingSend.recipient,
                subject = _pendingSend.subject or "",
                items   = _pendingSend.items or {},
                money   = money,
                cod     = false,
            })
            local Logs = addon.modules and addon.modules["logs"]
            if Logs and Logs.PruneEntries then Logs:PruneEntries(db.entries) end
            _pendingSend = nil
            refreshSubTab()
        end
    end
end)

-- ---------------------------------------------------------------------------
-- Read API consumed by GUI/MailLogSubTab.lua
-- ---------------------------------------------------------------------------
--- Returns a newest-first list of entries matching the filters.
---   filterPlayer  string|nil    character key, or nil/empty = no player filter
---   filterDir     string|nil    "in" / "out", or nil = both
---   fromTs, toTs  number|nil    inclusive bounds (nil = unbounded)
function MailLog:GetEntries(filterPlayer, filterDir, fromTs, toTs)
    local db = MailLogDB()
    if not db or not db.entries then return {} end
    local out = {}
    for _, e in ipairs(db.entries) do
        local ok = true
        if filterPlayer and filterPlayer ~= "" and e.player ~= filterPlayer then ok = false end
        if ok and filterDir and e.dir ~= filterDir 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 tinsert(out, e) end
    end
    table.sort(out, function(a, b) return (a.ts or 0) > (b.ts or 0) end)
    return out
end

--- Unique character keys seen in the log; alphabetically sorted. Used by
--- the sub-tab's character-filter dropdown.
function MailLog:GetPlayerKeys()
    local db = MailLogDB()
    if not db or not db.entries then return {} end
    local seen = {}
    for _, e in ipairs(db.entries) do
        if e.player then seen[e.player] = true end
    end
    local list = {}
    for k in pairs(seen) do tinsert(list, k) end
    table.sort(list)
    return list
end

function MailLog:ClearAll()
    local db = MailLogDB()
    if db then db.entries = {} end
    refreshSubTab()
end
