-- TOG Tools — Mailbox Stale Watcher (engine)
-- Captures per-alt mailbox snapshots when the alt visits the mailbox, then
-- alerts on login when mail is approaching the 30-day auto-deletion.
--
-- Snapshot stamps absolute expiresAt = GetServerTime() + (daysLeft * 86400)
-- so days-remaining math stays accurate even when the alt does not re-visit
-- a mailbox for a while. Same content-derived-timestamp pattern TOGPM uses.
--
-- Storage:
--   db.global.mailbox = {
--       thresholdDays = 2,
--       snapshots = { [charKey] = { snapshotAt, items = {{...}} } },
--   }

local _, addon = ...

local Mailbox = {}
addon.modules["mailbox"] = Mailbox

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

local function MailDB()
    return addon.DB and addon.DB.global and addon.DB.global.mailbox
end

-- Audit round 4 finding G. This used to be spelled `if not db.enabled` inline,
-- which defaults a MISSING flag to OFF -- while addon:IsModuleEnabled defaults
-- the same missing flag to ON, by explicit design (TOGTools.lua:350-356). On a
-- fresh or partly-migrated DB that meant half the addon was on and half off,
-- depending only on which spelling a given module happened to use. One predicate
-- everywhere, with the default decided in one place. Gratz:124 and Smack:156
-- already did it this way; this is the module catching up, not a new idiom.
local function isEnabled()
    return addon:IsModuleEnabled("mailbox")
end

-- ---------------------------------------------------------------------------
-- Capture
-- ---------------------------------------------------------------------------
-- Is the mailbox UI open? Set on MAIL_SHOW, cleared on MAIL_CLOSED.
--
-- ⛔ THIS IS A BRACKET, NOT A GUESS, and that distinction is the whole point
-- (audit round 17). MAIL_INBOX_UPDATE demonstrably reaches handlers while the
-- mailbox is CLOSED: Blizzard's own Classic MailFrame.lua registers it in
-- OnLoad and never unregisters, and MailFrame_OnEvent calls InboxFrame_Update()
-- with no is-shown check. Capture overwrites the whole snapshot on every fire,
-- so a fire outside a real mailbox visit could replace a good snapshot with an
-- empty one and that alt would silently stop being watched.
--
-- Whether GetInboxNumItems() actually returns 0 in such a fire is engine-side
-- and unreadable from Lua -- it was parked as unverifiable in round 12. THE
-- REMEDY DOES NOT NEED THAT ANSWER: a write that can only happen between
-- MAIL_SHOW and MAIL_CLOSED cannot be corrupted by a fire outside the bracket,
-- whatever the client returns. This is the module-toggle lesson (finding A) in
-- a capture path: "is the mailbox open" is INSTALLED STATE with real edges, and
-- inferring it per-event from the payload is the substitution that family is
-- about. Both events are declared in the Classic Era client's own
-- MailInfoDocumentation.lua (:50 and :97).
--
-- ⛔ AND NOT THE OBVIOUS SHORTCUT. "Never overwrite a non-empty snapshot with an
-- empty one" looks cheaper and is wrong: an alt that genuinely empties its
-- mailbox would keep a stale snapshot forever, which is the same stale-data leak
-- GetExpiringSoon's docstring exists to prevent. The bracket distinguishes
-- "no data" from "no mail"; a non-empty test cannot tell them apart.
local _mailboxOpen = false

-- MAIL_INBOX_UPDATE fires repeatedly while the inbox loads. We just overwrite
-- on each event; the last call (after data settles) wins. Headers with nil
-- daysLeft are skipped — they represent rows still loading.
local function Capture()
    local db = MailDB()
    if not db then return end
    if not isEnabled() then return end  -- module-level off-switch
    if not _mailboxOpen then return end -- outside a real mailbox visit

    local n = GetInboxNumItems() or 0
    local now = GetServerTime()
    local items = {}
    for i = 1, n do
        local _, _, sender, subject, money, COD, daysLeft, itemCount = GetInboxHeaderInfo(i)
        if daysLeft and daysLeft > 0 then
            items[#items + 1] = {
                sender    = sender  or "?",
                subject   = subject or "",
                expiresAt = now + (daysLeft * 86400),
                hasItems  = (itemCount or 0) > 0,
                hasMoney  = (money or 0) > 0 or (COD or 0) > 0,
            }
        end
    end

    db.snapshots = db.snapshots or {}
    db.snapshots[CharKey()] = {
        snapshotAt = now,
        items      = items,
    }
end

-- ---------------------------------------------------------------------------
-- Read API — used by Login Digest + the tab
-- ---------------------------------------------------------------------------
-- Returns: totalCount, altCount, thresholdDays, perAlt = { [charKey] = count }
-- When the module is disabled returns zeros (without touching SV), so cross-
-- module callers like LoginDigest's mail field silently skip rather than
-- leaking stale snapshots from before the user turned the watcher off.
function Mailbox:GetExpiringSoon()
    local db = MailDB()
    if not db or not db.snapshots then
        return 0, 0, (db and db.thresholdDays) or 2, {}
    end
    if not isEnabled() then
        return 0, 0, db.thresholdDays or 2, {}
    end
    local now = GetServerTime()
    local thresholdSec = (db.thresholdDays or 2) * 86400
    local total, altCount = 0, 0
    local perAlt = {}
    for charKey, snap in pairs(db.snapshots) do
        local count = 0
        for _, item in ipairs(snap.items or {}) do
            local left = item.expiresAt - now
            if left > 0 and left <= thresholdSec then
                count = count + 1
            end
        end
        if count > 0 then
            total      = total + count
            altCount   = altCount + 1
            perAlt[charKey] = count
        end
    end
    return total, altCount, db.thresholdDays or 2, perAlt
end

-- Returns sorted summaries for the tab.
-- Each entry: { charKey, snapshotAt, items = {expiringItemsSortedBySoonest},
--               allCount, expiringCount }
-- Order: alts with expiring mail first (soonest first), then alts without.
--
-- ⚠ DELIBERATELY NOT gated on isEnabled(), unlike its sibling GetExpiringSoon
-- above -- and that asymmetry is load-bearing on a fact in ANOTHER FILE (audit
-- round 12, finding tog-Y). GetExpiringSoon is called cross-module by
-- LoginDigest, so it must answer "nothing" when the watcher is off. This one has
-- exactly ONE caller, GUI/MailboxTab.lua, and GUI/MainWindow.lua:86-90 drops a
-- disabled module's tab from the strip entirely, so it is unreachable in that
-- state. Verified by reading both, not assumed.
-- ⛔ THE INVARIANT IS THEREFORE "no second caller". A broker, a Login Digest
-- field or any other consumer added here would read stale snapshots from before
-- the user switched the watcher off -- which is the exact thing GetExpiringSoon's
-- own docstring says it exists to prevent. Add the gate before adding a caller.
function Mailbox:GetAltSummaries()
    local db = MailDB()
    if not db or not db.snapshots then return {} end
    local now = GetServerTime()
    local thresholdSec = (db.thresholdDays or 2) * 86400

    local summaries = {}
    for charKey, snap in pairs(db.snapshots) do
        local expiring = {}
        for _, item in ipairs(snap.items or {}) do
            local left = item.expiresAt - now
            if left > 0 and left <= thresholdSec then
                expiring[#expiring + 1] = item
            end
        end
        -- Tie-broken on sender then subject (audit round 12, finding tog-X --
        -- finding tog-O and reviewer finding S at a third site). Identical
        -- `expiresAt` is the COMMON case here, not an edge case: a batch of
        -- "Auction expired" mails lands in one snapshot with the same daysLeft
        -- and the same `now`, so the products are equal to the second. Ties are
        -- what table.sort is free to reorder in 5.1, and MAIL_INBOX_UPDATE
        -- redraws this tab on every event while the mailbox is open -- so the
        -- rows visibly reshuffled under the user while nothing changed.
        --
        -- Two mails with the same sender, subject AND expiry are still not
        -- ordered against each other; they render identically, so the order
        -- between them is not observable.
        table.sort(expiring, function(a, b)
            if a.expiresAt ~= b.expiresAt then return a.expiresAt < b.expiresAt end
            local as, bs = a.sender or "", b.sender or ""
            if as ~= bs then return as < bs end
            return (a.subject or "") < (b.subject or "")
        end)
        summaries[#summaries + 1] = {
            charKey       = charKey,
            snapshotAt    = snap.snapshotAt,
            items         = expiring,
            allCount      = #(snap.items or {}),
            expiringCount = #expiring,
        }
    end
    table.sort(summaries, function(a, b)
        if (a.expiringCount > 0) ~= (b.expiringCount > 0) then
            return a.expiringCount > 0
        end
        if a.expiringCount > 0 and a.items[1].expiresAt ~= b.items[1].expiresAt then
            return a.items[1].expiresAt < b.items[1].expiresAt
        end
        -- charKey is unique per alt, so falling through to it makes this a TOTAL
        -- order -- which matters because the input is a pairs() walk over
        -- db.snapshots and therefore arrives in hash order (tog-X).
        return a.charKey < b.charKey
    end)
    return summaries
end

-- ---------------------------------------------------------------------------
-- Login alert
-- ---------------------------------------------------------------------------
local function PrintLoginAlert()
    -- :GetExpiringSoon already short-circuits to zeros when the module is
    -- disabled, so we don't need a separate enabled-check here — total<=0
    -- catches both "nothing to report" and "module off".
    local total, altCount, thresholdDays = Mailbox:GetExpiringSoon()
    if total <= 0 then return end
    local plural    = (total == 1) and "" or "s"
    local altPlural = (altCount == 1) and "" or "s"
    print(string.format(
        "|c%s[TOGTools]|r %d mail%s expiring in <=%d day%s across %d alt%s. /togt mail for details.",
        addon.BrandColor or "ffFF8000",
        total, plural,
        thresholdDays, (thresholdDays == 1) and "" or "s",
        altCount, altPlural
    ))
end

-- ---------------------------------------------------------------------------
-- Event wiring
-- ---------------------------------------------------------------------------
local _frame = CreateFrame("Frame")
_frame:RegisterEvent("MAIL_SHOW")
_frame:RegisterEvent("MAIL_CLOSED")
_frame:RegisterEvent("MAIL_INBOX_UPDATE")
_frame:RegisterEvent("PLAYER_LOGIN")
_frame:SetScript("OnEvent", function(_, event)
    if event == "MAIL_SHOW" then
        _mailboxOpen = true
    elseif event == "MAIL_CLOSED" then
        _mailboxOpen = false
    elseif event == "MAIL_INBOX_UPDATE" then
        Capture()
        -- Live-refresh the Mailbox tab if it's currently showing, so the
        -- list reflects the new snapshot without forcing the user to tab
        -- away and back. MAIL_INBOX_UPDATE fires multiple times as the
        -- inbox loads; the redraw is cheap so we don't debounce.
        if addon.MainWindow and addon.MainWindow.activeTab == "mailbox" then
            addon.MainWindow:Refresh()
        end
    elseif event == "PLAYER_LOGIN" then
        -- Defer so the alert lands after Ace's "Loaded" line and other
        -- addons settle their login spam.
        C_Timer.After(2, PrintLoginAlert)
    end
end)

function Mailbox:OnEnable()
    -- Event wiring above runs at file load. Hook is here for module-pattern
    -- consistency in case OnEnable is needed later.
end
