-- TOG Tools — Shared GUI helpers
-- Loaded right after TOGTools.lua. Defines helpers used by every tab:
--   addon.Tooltip.Owner / AnchorFrame   — smart tooltip anchoring
--   addon.AceGUIFrameScripts            — leak-safe raw frame scripts on pooled widgets
--   addon.GUI.DetachPool                — safe cleanup for raw-frame virtual-scroll pools
--   addon.GUI.AttachTooltip             — wire OnEnter/OnLeave tooltip onto any AceGUI widget
--   addon.GUI.MakeColumnHeader          — brand-colored InteractiveLabel column headers

local _, addon = ...
local AceGUI = LibStub("AceGUI-3.0")

-- ---------------------------------------------------------------------------
-- Tooltip helpers
-- ---------------------------------------------------------------------------
-- Always call `addon.Tooltip.Owner` (and `addon.Tooltip.AnchorTo` for
-- post-population re-positioning) instead of raw `GameTooltip:SetOwner`
-- so the flip-budget anchor logic is consistent across the addon.
--
-- The canonical `addon.Tooltip.Owner` lives in `GUI/UI.lua`, alongside
-- the newer `addon.Tooltip.AnchorTo` helper — both share an internal
-- `_computePlacement` so the threshold (250 px below the owner) stays
-- the same between attachment-time `SetOwner` and post-population
-- `SetPoint` re-anchors. The previous Compat.lua definition (which
-- used a simpler "top vs bottom half" heuristic) was removed once UI.lua
-- was guaranteed to load (it sits later in the TOC) — the dead local
-- override caused subtle anchor inconsistencies in places that loaded
-- before UI.lua and grabbed the old definition.
addon.Tooltip = addon.Tooltip or {}

-- Anchor an arbitrary popup frame adjacent to a source widget/frame using
-- the same screen-half logic.  Pass either a raw Frame or an AceGUI widget.
function addon.Tooltip.AnchorFrame(frame, source)
    local sourceFrame = source.GetCenter and source or source.frame
    if not sourceFrame or not sourceFrame.GetCenter then return end
    frame:ClearAllPoints()
    local _, y = sourceFrame:GetCenter()
    if y and y > GetScreenHeight() / 2 then
        frame:SetPoint("TOPLEFT", sourceFrame, "BOTTOMLEFT", 0, 0)
    else
        frame:SetPoint("BOTTOMLEFT", sourceFrame, "TOPLEFT", 0, 0)
    end
end

-- ---------------------------------------------------------------------------
-- Leak-safe raw frame scripts on AceGUI widgets
-- ---------------------------------------------------------------------------
-- AceGUI pools widgets across the entire account. SetCallback entries are
-- cleared on Release, but raw widget.frame:SetScript calls are NOT — the
-- script survives into the next owner's UI.  Many widget Constructors also
-- install internal dispatch scripts on widget.frame (e.g. Button's
-- Control_OnEnter), so blindly nilling them on release would break the
-- recycled widget for whoever uses it next.
--
-- This helper SAVES the prior script and RESTORES it on OnRelease instead
-- of nilling it.
--
-- Prefer widget:SetCallback("OnEnter"/"OnLeave"/"OnClick"/...) when the
-- widget supports it. Only use this for widgets without native dispatch
-- (e.g. SimpleGroup) or events the widget doesn't expose (OnMouseDown).
--
-- Usage:
--   addon.AceGUIFrameScripts(widget, { OnMouseDown = function(f, btn) ... end })
function addon.AceGUIFrameScripts(widget, scripts)
    if not (widget and widget.frame and scripts) then return end
    local saved = {}
    for evt, fn in pairs(scripts) do
        saved[evt] = widget.frame:GetScript(evt)
        widget.frame:SetScript(evt, fn)
    end
    -- Chained, NOT SetCallback: the OnRelease slot is single and assignment
    -- would silently destroy any other release-time cleanup on this widget
    -- (audit round 8, finding tog-Q -- the long note above addon.GUI.OnRelease).
    -- Resolved at call time, so it is fine that OnRelease is defined further
    -- down this file; AceGUIFrameScripts only ever runs at draw time.
    addon.GUI.OnRelease(widget, function(self)
        if not self.frame then return end
        for evt, prior in pairs(saved) do
            self.frame:SetScript(evt, prior)
        end
    end)
end

-- ---------------------------------------------------------------------------
-- Chat namespace
-- ---------------------------------------------------------------------------
addon.Chat = addon.Chat or {}   -- `or {}` is load-bearing for the same reason as addon.GUI below

--- Can this SendChatMessage channel type be spoken into right now?
---
--- LIVES HERE BECAUSE IT WAS WRONG IN TWO PLACES AT ONCE. Gratz and Smack each had
--- their own `sendToChannels` with a copied three-arm dispatch, and both used a bare
--- `IsInGroup()` for PARTY, RAID and INSTANCE_CHAT -- findings tog-AQ and tog-AT.
--- Fixing it in Gratz alone would have left divergence number two waiting, which is
--- exactly what finding tog-AC was about.
---
--- Blizzard states all four predicates in one function, and they are four DIFFERENT
--- questions -- `ChatFrameEditBoxMixin:ResetChatType`, Classic Era
--- `Blizzard_ChatFrameBase/Shared/ChatFrameEditBox.lua:562-573`:
---
---   PARTY          IsInGroup(LE_PARTY_CATEGORY_HOME)       <- bare IsInGroup() IS this
---   RAID           IsInRaid(LE_PARTY_CATEGORY_HOME)        <- bare IsInRaid() IS this
---   GUILD/OFFICER  IsInGuild()
---   INSTANCE_CHAT  IsInGroup(LE_PARTY_CATEGORY_INSTANCE)   <- a DIFFERENT category
---
--- SAY / YELL / EMOTE need no membership and always answer true; whether the CLIENT
--- will honour them is a separate question (Retail blocks automated public chat), and
--- that stays with the caller, because only the caller knows if it holds a hardware
--- event. This function answers membership and nothing else.
---
--- Degradation is safe by construction: if `LE_PARTY_CATEGORY_INSTANCE` were ever
--- missing, `IsInGroup(nil)` falls back to the HOME category -- which is precisely the
--- behaviour being replaced, so the worst case reproduces the old bug and never raises.
function addon.Chat.ChannelAvailable(chanType)
    if chanType == "GUILD" or chanType == "OFFICER" then
        return IsInGuild and IsInGuild() and true or false
    elseif chanType == "PARTY" then
        return IsInGroup and IsInGroup() and true or false
    elseif chanType == "RAID" then
        return IsInRaid and IsInRaid() and true or false
    elseif chanType == "INSTANCE_CHAT" then
        return IsInGroup and IsInGroup(LE_PARTY_CATEGORY_INSTANCE) and true or false
    end
    return true
end

-- ---------------------------------------------------------------------------
-- GUI namespace
-- ---------------------------------------------------------------------------
-- ⛔ THE `or {}` IS LOAD-BEARING. DO NOT SIMPLIFY IT TO `addon.GUI = {}`.
--
-- It reads like defensive boilerplate and is the thing that makes a duplicated
-- TOC entry harmless here (audit round 18). A file listed twice in a `.toc`
-- EXECUTES TWICE, and the failure is not "the second copy wins" -- it is a
-- PARTITION: consumers that bound a reference before the duplicate line keep the
-- first table while consumers after it get the second, so writes land in one and
-- reads in the other, silently. Reusing the existing table means a second
-- execution reassigns the helpers onto the SAME table and every file-scope alias
-- stays valid.
--
-- That matters more since finding tog-AC, which made `local f = addon.GUI.X` at
-- file scope the house idiom: this line is what keeps that idiom safe.
-- `Tests/toc_spec.lua` also asserts no manifest lists a file twice, so the
-- hazard is caught at its source rather than only survived here.
addon.GUI = addon.GUI or {}

-- ---------------------------------------------------------------------------
-- OnRelease chaining
-- ---------------------------------------------------------------------------
-- An AceGUI widget has exactly ONE OnRelease slot and SetCallback ASSIGNS to it:
-- `AceGUI-3.0.lua:292-296` is `self.events[name] = func`, verified in the
-- installed Ace3 rather than assumed. So two helpers that both want release-time
-- cleanup silently destroy each other, and this file documents two of them --
-- AceGUIFrameScripts above and DetachPool below (audit round 8, finding tog-Q).
--
-- Both failure directions are precisely the bug the other helper exists to stop:
-- lose the script restore and raw handlers leak into the next owner of the pooled
-- widget; lose the pool detach and the pool frames bleed into another addon's
-- window. Neither errors, so nothing tells you it happened.
--
-- Latent today -- nothing uses both -- but the path is already wired:
-- addon.UI.AttachTooltip installs AceGUIFrameScripts SILENTLY on any Dropdown or
-- EditBox (GUI/UI.lua:184-193), and GuildFilterDropdown below creates exactly
-- such a dropdown and tooltips it.
--
-- Use this instead of widget:SetCallback("OnRelease", ...) anywhere in the addon.
function addon.GUI.OnRelease(widget, fn)
    if not (widget and widget.SetCallback and type(fn) == "function") then return end
    local prior = widget.events and widget.events.OnRelease
    widget:SetCallback("OnRelease", function(self, ...)
        -- Ours first, then whatever was already there. Both run under AceGUI's
        -- safecall, so one erroring does not swallow the other.
        fn(self, ...)
        if prior then prior(self, ...) end
    end)
end

-- ---------------------------------------------------------------------------
-- Minimum resize floor
-- ---------------------------------------------------------------------------
-- Port of FGI's UI.ApplyMinResize. Clamps the window's drag-resize floor
-- using the modern SetResizeBounds API with a SetMinResize fallback for
-- legacy WoW clients. Accepts either an AceGUI widget (uses widget.frame)
-- or a raw Frame.
function addon.GUI.ApplyMinResize(frameOrWidget, minW, minH)
    local raw = (frameOrWidget and frameOrWidget.frame) or frameOrWidget
    if not raw then return end
    if raw.SetResizeBounds then
        raw:SetResizeBounds(minW or 0, minH or 0)
    elseif raw.SetMinResize then
        raw:SetMinResize(minW or 0, minH or 0)
    end
end

-- ---------------------------------------------------------------------------
-- Pool detach
-- ---------------------------------------------------------------------------
-- Tabs that maintain a pool of raw CreateFrame rows (virtual-scroll perf
-- trick) parent those frames to an AceGUI widget's content frame. When
-- AceGUI recycles that widget into another addon's UI the pool frames bleed
-- into the other addon's window.  Call this from OnRelease to safely orphan
-- them: Hide + re-parent to UIParent + ClearAllPoints.  The frames stay in
-- the pool table and are re-attached on the next draw.
--
-- Usage (via addon.GUI.OnRelease, NOT SetCallback -- see finding Q above):
--   addon.GUI.OnRelease(widget, function(self) addon.GUI.DetachPool(self._myPool) end)
function addon.GUI.DetachPool(pool)
    if not pool then return end
    for _, f in ipairs(pool) do
        if f then
            f:Hide()
            f:SetParent(UIParent)
            f:ClearAllPoints()
        end
    end
end

-- ---------------------------------------------------------------------------
-- Tooltip wiring
-- ---------------------------------------------------------------------------
-- `addon.GUI.AttachTooltip` previously lived here as a standalone
-- function. The canonical implementation is now `addon.UI.AttachTooltip`
-- in `GUI/UI.lua`, which handles both AceGUI widgets AND raw frames in
-- one place and aliases `addon.GUI.AttachTooltip = UI.AttachTooltip` at
-- the bottom of that file. UI.lua loads after Compat.lua per the TOC
-- ordering, so either namespace resolves to the same leak-safe helper.
-- Removed from here to keep a single source of truth.

-- ---------------------------------------------------------------------------
-- Guild-key formatting and the guild filter dropdown
-- ---------------------------------------------------------------------------
-- Both live here because GuildLog and GuildBankLog had byte-identical private
-- copies (found by `dupscan`, 2026-08-16). Unlike the Gratz/Smack tab pair --
-- which stays duplicated on purpose, see the header of GUI/SmackTab.lua -- this
-- one is a genuine single concept: the two logs are different data over the SAME
-- guild-key domain, and both modules already expose the identical three members
-- this needs. Merging also settled a live drift: only the Guild Bank copy had a
-- tooltip on its dropdown, so the same control explained itself in one tab and
-- not the other.

-- "Guild-Realm" -> "Guild" when the realm is the player's own, so the common
-- single-realm case stays compact; cross-realm keeps the suffix to stay
-- unambiguous. Pure string work, so it is specced offline.
function addon.GUI.FormatGuildKey(key)
    if not key or key == "" then return "|cff888888?|r" end
    local own = (GetRealmName() or ""):gsub("%s+", "")
    local guildName, realm = key:match("^(.+)%-([^%-]+)$")
    if guildName and realm == own then return guildName end
    return key
end

-- ---------------------------------------------------------------------------
-- Hour-precision timestamps derived from Blizzard's "X ago" tuples
-- ---------------------------------------------------------------------------
-- ⛔ THIS EXISTS BECAUSE THE TWO COPIES DIVERGED AND ONE WAS FIXED (audit round
-- 13, finding tog-AC). GuildLog and GuildBankLog both build `ts` the same way --
-- tupleToTs subtracts Blizzard's relative (y, m, d, h) offsets from
-- GetServerTime(), so the value is on the SERVER clock at HOUR precision -- and
-- both sub-tabs had a byte-identical private `fmtTime` rendering it with
-- `date()`, which formats in the MACHINE's timezone. Reviewer finding Y was
-- raised against the Guild Bank copy and fixed there; the Guild Log copy was
-- the same defect on the same data shape and was not touched, because nothing
-- connected them.
--
-- Two things this must not be confused with:
--   * A log whose `ts` is `GetServerTime()` at the moment the player was there
--     (Mail / Trade / Whisper / Gathering) is CORRECT to render with plain
--     `date()`: it should agree with Blizzard's chat timestamps, which are
--     `date()` too. Do not "unify" those onto this function.
--   * `addon:Debug`'s trace clock, which is local for the same reason and says
--     so at TOGTools.lua:340.
--
-- `serverSkew` is the realm's UTC offset: C_DateAndTime.GetServerTimeLocal
-- returns server time already offset by the server's timezone, so the
-- difference between it and GetServerTime() is that offset (verified against
-- warcraft.wiki.gg with a worked example; the function is present in the
-- Classic Era client's own DateAndTimeDocumentation.lua). Feature-detected, so
-- a client without the namespace degrades to the previous rendering rather than
-- erroring.
--
-- `date("!...")` formats as UTC BY SPECIFICATION rather than by convention: WoW's
-- Lua is 5.1 and `date` is `os.date`, whose reference manual states "If format
-- starts with '!', then the date is formatted in Coordinated Universal Time"
-- (F:\Dev Reference\Lua 5.1\manual.html, os.date). ⭐ That supersedes the
-- evidence this originally rested on, kept below because the reasoning is still
-- worth reading: a language guarantee cannot rot, a survey of other addons can.
--
-- (Original evidence.) `date("!...")` is confirmed to work in this
-- client by shipped addons that use it -- Blizzard's own source never does, so
-- its absence there proves nothing either way.
local function serverSkew()
    local stl = C_DateAndTime and C_DateAndTime.GetServerTimeLocal
    if not stl or not GetServerTime then return 0 end
    return (stl() or 0) - (GetServerTime() or 0)
end

-- Under ~11 months the year is implied by the MM/DD and omitting it keeps the
-- Time column narrow; past that, an entry from a previous year would be
-- indistinguishable from one this year.
local OMIT_YEAR_WITHIN = 330 * 86400

--- Render a server-clock, hour-precision timestamp for a log table.
--- Recent entries read as "Just now" / "Nh ago"; older ones as an absolute
--- date in REALM time with the minutes hardcoded to :00, because the
--- underlying data has no minute component and inventing one would imply
--- precision that is not there.
function addon.GUI.FormatServerHourTime(ts)
    if not ts or ts <= 0 then return "—" end
    local now = (GetServerTime and GetServerTime()) or time()
    local diff = now - ts
    if diff < 3600  then return "Just now" end
    if diff < 86400 then return string.format("%dh ago", math.floor(diff / 3600)) end
    local shown = ts + serverSkew()
    if diff < OMIT_YEAR_WITHIN then return date("!%m/%d %H:00", shown) end
    return date("!%m/%d/%y %H:00", shown)
end

-- `mod` is the log module and must expose GetCurrentGuildKey(), GetGuildKeys()
-- and a writable `_filterGuild` -- asserted rather than assumed, because a typo
-- in the module name would otherwise produce an empty dropdown that looks like a
-- player with no guild history rather than a bug.
function addon.GUI.GuildFilterDropdown(mod, onChange, tipTitle, tipBody)
    assert(mod and mod.GetCurrentGuildKey and mod.GetGuildKeys,
           "GuildFilterDropdown: module must expose GetCurrentGuildKey and GetGuildKeys")

    local dd = AceGUI:Create("Dropdown")
    dd:SetLabel("Guild")
    dd:SetWidth(180)

    local current = mod:GetCurrentGuildKey()
    local list, order = { __all = "All guilds" }, { "__all" }
    if current then
        list[current] = addon.GUI.FormatGuildKey(current) .. " (current)"
        tinsert(order, current)
    end
    for _, k in ipairs(mod:GetGuildKeys()) do
        if k ~= current then
            list[k] = addon.GUI.FormatGuildKey(k)
            tinsert(order, k)
        end
    end

    -- Current guild when known and present, else "All guilds". A stored
    -- _filterGuild wins, but only if it is still in the list -- a guild the
    -- player has left would otherwise select a value the dropdown cannot show.
    local default = mod._filterGuild or current or "__all"
    if not list[default] then default = "__all" end

    dd:SetList(list, order)
    dd:SetValue(default)
    dd:SetCallback("OnValueChanged", function(_, _, key)
        mod._filterGuild = key
        if onChange then onChange() end
    end)

    -- Bare, not `and addon.GUI.AttachTooltip` -- finding tog-S flagged the two spellings in this
    -- file, and peer-review rev-A settled which is considered: AttachTooltip is defined in
    -- GUI/UI.lua, which loads AFTER this file, and both call sites run at DRAW time, so the nil the
    -- guard defended against cannot occur. The guard was cargo-culted, and worse than useless --
    -- a reader copies it into a path where the nil IS real and it silently swallows the tooltip.
    -- Tests/toc_spec.lua now pins UI.lua loading after Compat.lua in every flavour, which is the
    -- condition rev-A named and which nothing was enforcing.
    if tipBody then
        addon.GUI.AttachTooltip(dd, tipTitle or "Guild filter", tipBody)
    end
    return dd
end

-- ---------------------------------------------------------------------------
-- Column header factory
-- ---------------------------------------------------------------------------
-- Creates a brand-colored, no-wrap InteractiveLabel for use as a column
-- header above sortable tables.
--
-- Required opts: parent (AceGUI container), label (string), width (number)
-- Optional opts:
--   justifyH      — "LEFT" | "CENTER" | "RIGHT"
--   tooltipTitle  — first tooltip line
--   tooltipDesc   — tooltip body
--   onClick       — called when the header is clicked (for sort toggling)
function addon.GUI.MakeColumnHeader(opts)
    assert(opts and opts.parent and opts.label and opts.width,
           "MakeColumnHeader: parent, label and width are required")

    local lbl = AceGUI:Create("InteractiveLabel")
    lbl:SetText("|c" .. (addon.BrandColor or "ffFF8000") .. opts.label .. "|r")
    lbl:SetWidth(opts.width)

    if opts.justifyH and lbl.SetJustifyH then
        lbl:SetJustifyH(opts.justifyH)
    end

    -- Prevent wrapping — a two-line header breaks alignment with data rows.
    if lbl.label and lbl.label.SetWordWrap then
        lbl.label:SetWordWrap(false)
    end

    if opts.tooltipTitle or opts.tooltipDesc then
        addon.GUI.AttachTooltip(lbl, opts.tooltipTitle, opts.tooltipDesc)
    end

    if opts.onClick then
        lbl:SetCallback("OnClick", function() opts.onClick() end)
    end

    opts.parent:AddChild(lbl)
    return lbl
end
