---@diagnostic disable: undefined-field, undefined-global
---@type string
local AddonName = ...
---@class Private
local Private = select(2, ...)

local Theme = Private.Theme
local WTUI = Private.WildThingsUI -- WTUI is a CHILD of Private (UI/UI.lua:3), not Private itself --
                                   -- CreateIcon/CreateButton/BuildWidgets stay on Private;
                                   -- SavePosition/RestorePosition are reached through WTUI.

-- FEAST-01..04: caster-side feast-cast detection, a one-event RAID broadcast over the
-- existing WT_MSG pipeline, and a receiver-side HUD reminder (icon + "Feast" label) that
-- auto-hides after a configurable duration. This is one complete vertical slice --
-- detection, wire, validation, display, timer, settings seam, and load order -- proven
-- end-to-end before Phase 41's later plans add the lockdown-resilience queue (FEAST-04,
-- plan 41-03) and the movable-anchor edit mode (FEAST-03, plan 41-04).
--
-- Private.* dependencies:
--   At file-load time: Private.commHandlers (Init.lua), populated before this file loads
--   so Private:RegisterCommEvent below can register into it immediately.
--   At runtime only: Private:Broadcast (Transmission.lua), Private:CreateIcon
--   (UI/Widgets/Icon.lua), WTUI:SavePosition/RestorePosition (UI/Functions.lua) --
--   WTUI.frameSettings is nil until WTUI:Initialize runs from wt:OnInitialize, so nothing
--   in this file may touch position helpers before then (see EnsureFeastAnchor below,
--   which is why it is lazy and never called at file-load time).
--
-- TOC slot is load-bearing, not cosmetic: this file registers a comm handler at
-- file-load time (Private:RegisterCommEvent, below), so it must load strictly after
-- Init.lua establishes Private.commHandlers and after Transmission.lua defines
-- Private:Broadcast. See WildThings.toc's own comment at this file's slot.

-- The eight current-tier (Midnight) feast spell IDs, transcribed from a third-party
-- consumable-spell list on 2026-07-25 (see FeastReminder.notes.md).
-- [ASSUMED:midnight-data] -- these are third-party-sourced, NOT confirmed against
-- Blizzard's own docs, and the Midnight tier sits after this agent's knowledge cutoff.
-- Refresh this table at the next raid tier by re-reading the same source (or an
-- equivalent addon) against the live client. Cauldron/soulwell/repair spell IDs are
-- FEAST-F2 scope (deferred) and must not appear here.
--
-- BLOOMING_FEAST_ID is named separately (not an inline literal in the table below) so the
-- /wt feasttest harness can reuse the exact same ID as its fixture without a second
-- textual occurrence of the digits.
local BLOOMING_FEAST_ID = 1237104

local FEAST_SPELLS = {
    -- [1259657] = true, -- Quel'dorei Medley (non-Hearty, ignoriert)
    [1278915] = true, -- Hearty Quel'dorei Medley
    -- [1259658] = true, -- Harandar Celebration (non-Hearty, ignoriert)
    [1278929] = true, -- Hearty Harandar Celebration
    -- [BLOOMING_FEAST_ID] = true, -- Blooming Feast (non-Hearty, ignoriert)
    [1278909] = true, -- Hearty Blooming Feast
    -- [1259659] = true, -- Silvermoon Parade (non-Hearty, ignoriert)
    [1278895] = true, -- Hearty Silvermoon Parade
}

-- Cauldron, Soulwell, and Repair-Bot spell IDs, transcribed from the same
-- third-party consumable-spell list on 2026-07-25 (see FeastReminder.notes.md).
-- [ASSUMED:midnight-data] -- same caveat as FEAST_SPELLS above.
local CAULDRON_SPELLS = {
    [1240267] = true, -- Voidlight Potion Cauldron
    [1240195] = true, -- Voidlight of Sin'dorei Flasks
}

local SOULWELL_SPELLS = {
    [29893] = true, -- Soulwell (Ritual of Souls)
}

local REPAIR_SPELLS = {
    [199109] = true, -- Auto-Hammer
    [67826]  = true, -- Jeeves
}

-- FEAST-F1: flat set of every known Hearty Well-Fed buff spell ID.
-- When a player receives a feast reminder, the handler checks whether the
-- player already has ANY of these buffs active -- if so, the reminder is
-- suppressed (the player is already sated). A flat set (no castID→buffID
-- mapping) because matching individual feasts to their specific buff IDs is
-- unreliable across tiers -- a sated player in the raid has exactly one
-- Well Fed buff from one specific feast, so checking a flat set of "any
-- known Hearty Well-Fed" gives the correct suppression behaviour with no
-- false negatives. Sourced from the live Midnight client via /wt wellfed
-- on 2026-07-26.
local WELL_FED_BUFF_IDS = {
    [1285644] = true, -- Hearty Well Fed (Mastery)
    [1233712] = true, -- Hearty Well Fed (Haste)
    [1233704] = true, -- Hearty Well Fed (Critical Strike)
    [1232082] = true, -- Hearty Well Fed (Versatility)
    [1233717] = true, -- Hearty Well Fed (Mastery)
    [1233714] = true, -- Hearty Well Fed (Haste)
    [1233729] = true, -- Hearty Well Fed (Critical Strike)
    [1233715] = true, -- Hearty Well Fed (Versatility)
    [1233705] = true, -- Hearty Well Fed (Mastery)
    [1233710] = true, -- Hearty Well Fed (Haste)
    [1233724] = true, -- Hearty Well Fed (Critical Strike)
    [1233733] = true, -- Hearty Well Fed (Versatility)
    [1284643] = true, -- Hearty Well Fed (Mastery)
    [1233703] = true, -- Hearty Well Fed (Haste)
    [1233706] = true, -- Hearty Well Fed (Critical Strike)
    [1233708] = true, -- Hearty Well Fed (Versatility)
    [1233711] = true, -- Hearty Well Fed (Mastery)
    [1233713] = true, -- Hearty Well Fed (Haste)
    [1233726] = true, -- Hearty Well Fed (Critical Strike)
    [1232078] = true, -- Hearty Well Fed (Versatility)
    [1233716] = true, -- Hearty Well Fed (Mastery)
    [1233719] = true, -- Hearty Well Fed (Haste)
    [1284642] = true, -- Hearty Well Fed (Critical Strike)
    [1284648] = true, -- Hearty Well Fed (Versatility)
    [1232076] = true, -- Hearty Well Fed (Mastery)
    [1232080] = true, -- Hearty Well Fed (Haste)
    [1233707] = true, -- Hearty Well Fed (Critical Strike)
    [1233709] = true, -- Hearty Well Fed (Versatility)
    [1233723] = true, -- Hearty Well Fed (Mastery)
    [1233730] = true, -- Hearty Well Fed (Haste)
    [1233732] = true, -- Hearty Well Fed (Critical Strike)
    [1284641] = true, -- Hearty Well Fed (Versatility)
    [1284650] = true, -- Hearty Well Fed (Mastery)
    [1233718] = true, -- Hearty Well Fed (Haste)
    [1233720] = true, -- Hearty Well Fed (Critical Strike)
    [1233721] = true, -- Hearty Well Fed (Versatility)
    [1233722] = true, -- Hearty Well Fed (Mastery)
    [1233725] = true, -- Hearty Well Fed (Haste)
    [1233727] = true, -- Hearty Well Fed (Critical Strike)
    [1233728] = true, -- Hearty Well Fed (Versatility)
    [1233731] = true, -- Hearty Well Fed (Mastery)
    [1284644] = true, -- Hearty Well Fed (Haste)
    [1284647] = true, -- Hearty Well Fed (Critical Strike)
    [1284649] = true, -- Hearty Well Fed (Versatility)
    [1305153] = true, -- Hearty Well Fed (Mastery)
    [1305155] = true, -- Hearty Well Fed (Haste)
    [1305150] = true, -- Hearty Well Fed (Critical Strike)
    [1305157] = true, -- Hearty Well Fed (Versatility)
}

-- REMINDER_TYPES: dispatch table mapping type string → {label, spells}.
-- Every reminder type the comm handler and display path understand is declared
-- here exactly once -- adding a new type means adding one row to this table
-- plus its spell-ID table above.
local REMINDER_TYPES = {
    feast    = { label = "Feast",    spells = FEAST_SPELLS },
    cauldron = { label = "Cauldron", spells = CAULDRON_SPELLS },
    soulwell = { label = "Soulwell", spells = SOULWELL_SPELLS },
    repair   = { label = "Repair",   spells = REPAIR_SPELLS },
}

-- ALL_CAST_SPELLS: merged O(1) lookup table mapping every known cast spell ID
-- to its reminder type string. Built once at file-load time from the four
-- per-type tables so the cast-detection path (UNIT_SPELLCAST_SUCCEEDED) can
-- resolve spellID → type in a single hash lookup.
local ALL_CAST_SPELLS = {}
do
    for typeKey, def in pairs(REMINDER_TYPES) do
        for spellID in pairs(def.spells) do
            ALL_CAST_SPELLS[spellID] = typeKey
        end
    end
end

-- Flat, addon-wide frameSettings key (UI/Functions.lua's WTUI:SavePosition/RestorePosition
-- keyspace) -- provably unused among the current occupants ("ReadyCheckOverlay",
-- "IconAudit").
local FEAST_ANCHOR_KEY = "FeastReminder"

-- Construction-time seed AND the nil-resolve fallback for ShowFeastReminder below
-- (classic Fish Feast icon, a deliberately generic choice). CreateIcon errors hard on a nil
-- stem (UI/Widgets/Icon.lua:25-27), so this must never be nil.
local FEAST_FALLBACK_ICON = 19705

-- Build-once lazy HUD frame (mirrors WildThings.lua's iconAuditWindow / ReadyCheck.lua's
-- overlayWindow idiom -- created on first call, only re-shown afterwards, never
-- recreated). Building this at file-load time would be a hard nil-index error:
-- WTUI.frameSettings does not exist until WTUI:Initialize runs from wt:OnInitialize,
-- well after every file has loaded.
local anchor

-- FEAST-03: edit-mode state, false at load. Toggled by the Settings unlock
-- row (the ONLY entry and exit point -- see ExitFeastEditMode comment below).
-- MUST be declared above EnsureFeastAnchor: the drag scripts it installs close
-- over this variable, and in Lua a local is invisible to code written before its
-- declaration -- declared any lower, those closures would silently read a global
-- nil instead and the anchor could never be dragged.
local editMode = false

-- EnsureFeastAnchor(): lazily builds the reminder's HUD container frame.
-- Plan 50-02: rebuilt as a flex container that holds per-type child frames
-- (icon + label pairs), arranged horizontally or vertically per the `layout`
-- SavedVars setting. The anchor itself has no fixed size and no glyph/label
-- of its own -- each reminder type owns an independent child frame created
-- via EnsureReminderChild(). Position persistence and edit-mode wiring live
-- on the anchor exactly as before (same FEAST_ANCHOR_KEY so existing saved
-- positions carry forward).
local function EnsureFeastAnchor()
    if anchor then return anchor end

    anchor = CreateFrame("Frame", nil, UIParent)
    anchor:SetFrameStrata("DIALOG")
    -- Give the anchor a resolvable rect from the moment it exists. RelayoutChildren() resizes
    -- it to match its actual content, but until that first call a frame with one point and no
    -- size has NO rect at all (GetLeft() returns nil), and every region anchored through it
    -- inherits that nil and is never drawn -- the defect that kept all four reminders
    -- invisible (.planning/debug/settings-reminders-no-icon.md). 725a87a had this line,
    -- 067b737 dropped it. SetClampedToScreen below also needs a rect to have anything to clamp.
    anchor:SetSize(64, 84)
    anchor:SetClampedToScreen(true)
    anchor:EnableMouse(false)
    anchor:Hide()

    -- Per-type child frames, keyed by type string. Each child carries its
    -- own .glyph icon and .label font string, built on first use by
    -- EnsureReminderChild().
    anchor.children = {}

    -- FEAST-03: edit-mode mover wiring, patterned after UI/Widgets/Window.lua:100-148.
    -- The anchor itself is the drag handle.
    anchor:SetMovable(true)
    anchor:SetScript("OnMouseDown", function(_, button)
        if button == "LeftButton" and editMode then
            anchor:StartMoving()
        end
    end)
    anchor:SetScript("OnMouseUp", function(_, button)
        if button == "LeftButton" and editMode then
            anchor:StopMovingOrSizing()
            WTUI:SavePosition(anchor, FEAST_ANCHOR_KEY)
        end
    end)

    -- FEAST-03: edit-mode visual treatment. A child frame fills the anchor
    -- with a rounded accent fill. The fill is resized to cover all visible
    -- children when edit mode activates (see EnterFeastEditMode below).
    anchor.editTreatment = CreateFrame("Frame", nil, anchor)
    anchor.editTreatment:SetAllPoints(anchor)
    WTUI:AddRoundedFill(anchor.editTreatment, Theme.RADIUS_SM, Theme.ACCENT_A20)
    anchor.editTreatment:Hide()

    if not WTUI:RestorePosition(anchor, FEAST_ANCHOR_KEY) or anchor:GetNumPoints() == 0 then
        anchor:SetPoint("CENTER", UIParent, "CENTER", 0, 200)
    end

    return anchor
end

-- EnsureReminderChild(reminderType): lazily creates a child frame inside the
-- anchor for the given reminder type. Each child is a 64×84 frame holding a
-- 64px icon (TOP) and a font-string label (BOTTOM). Created once, reused
-- forever -- the same frame is returned on every subsequent call.
local function EnsureReminderChild(reminderType)
    if anchor.children[reminderType] then
        return anchor.children[reminderType]
    end

    local child = CreateFrame("Frame", nil, anchor)
    child:SetSize(64, 84)
    -- Explicitly mouse-transparent rather than merely inheriting CreateFrame's default: this
    -- child covers the ENTIRE visible icon+label area, so if it ever took mouse input the
    -- reminder would swallow clicks even with the anchor itself disabled. Its glyph is a
    -- Texture and its label a FontString, neither of which can receive mouse events at all,
    -- so pinning it here makes the whole subtree click-through by construction.
    child:EnableMouse(false)
    child:Hide()

    child.glyph = Private:CreateIcon(child, FEAST_FALLBACK_ICON, 64)
    child.glyph:ClearTint()
    child.glyph:SetAlpha(1)
    child.glyph:SetPoint("TOP", child, "TOP", 0, 0)

    child.label = child:CreateFontString(nil, "ARTWORK")
    child.label:SetFontObject(WildThingsFont13)
    child.label:SetJustifyH("CENTER")
    child.label:SetTextColor(Theme.TEXT.r, Theme.TEXT.g, Theme.TEXT.b, Theme.TEXT.a)
    child.label:SetPoint("TOP", child.glyph, "BOTTOM", 0, -Theme.GAP)

    anchor.children[reminderType] = child
    return child
end

-- RelayoutChildren(): repositions every visible child frame according to the
-- current layout setting ("horizontal" or "vertical"), centered within the
-- anchor. Called after any child is shown or hidden so the container always
-- reflects the live set of active reminders.
local function RelayoutChildren()
    local feastSV = WildThingsSavedVars.feast
    local layout = (feastSV and feastSV.layout) or "horizontal"

    -- Build an ordered list of visible children so layout is deterministic.
    local visible = {}
    for typeKey in pairs(anchor.children) do
        if anchor.children[typeKey]:IsShown() then
            visible[#visible + 1] = typeKey
        end
    end
    local order = { feast = 1, cauldron = 2, soulwell = 3, repair = 4 }
    table.sort(visible, function(a, b) return (order[a] or 99) < (order[b] or 99) end)

    local n = #visible
    if n == 0 then return end

    if layout == "vertical" then
        -- Stack children downward, centered horizontally on the anchor.
        -- Total height: n * 84 + (n-1) * GAP, center on anchor.
        local totalH = n * 84 + (n - 1) * Theme.GAP
        -- Size the container here, in the shared routine, rather than in the edit-mode
        -- preview only (where 61a8d32 put it). THIS IS THE CURE for "no reminder icon ever
        -- appears", proven by measurement in game (.planning/debug/settings-reminders-no-icon.md):
        -- EnsureFeastAnchor gives the anchor exactly ONE point and no size, and a frame with
        -- one point and no size has an UNDER-DETERMINED rect -- GetLeft/GetBottom/GetTop
        -- return nil, not 0 -- so it is never drawn. Worse, the nil propagates down the whole
        -- anchor chain: the child, its glyph and its label each carry their own size, own
        -- point and own texture and STILL measured L=nil B=nil T=nil, because they are
        -- positioned THROUGH the anchor. Every visibility flag meanwhile reads correct
        -- (shown=true, visible=true, alpha=1, valid texture fileID) -- which is exactly why
        -- the failure was completely silent and why three debug sessions of static reading
        -- walked past it.
        -- This is NOT clipping: SetClipsChildren is opt-in and appears nowhere in this file.
        -- A zero-extent parent whose own rect RESOLVES does render its children; this one's
        -- did not resolve at all. Do not "simplify" this line away again -- 725a87a had it,
        -- 067b737 dropped it, and a0b7fb3/61a8d32 restored it on the edit path only, which
        -- is why edit mode looked fine while the live reminder was invisible for four phases.
        -- Sizing additionally fixes three things that were only ever right on the edit path:
        --   1. the drag hit rect -- a rect-less frame receives no mouse input even with
        --      EnableMouse(true) (a0b7fb3 observed exactly this in game),
        --   2. anchor.editTreatment, which is SetAllPoints(anchor), so its rounded-fill
        --      TEXTURES are zero-area whenever the anchor is,
        --   3. SetClampedToScreen(true), which has nothing to clamp without a rect.
        -- And it makes a live reminder land where the edit-mode preview showed it
        -- (previously 42 px higher, because the child's centre sat on the anchor's TOP point).
        anchor:SetSize(64, totalH)
        local firstY = totalH / 2 - 42 -- half-height offset: center first child's center
        for i, typeKey in ipairs(visible) do
            local child = anchor.children[typeKey]
            child:ClearAllPoints()
            local yOff = firstY - (i - 1) * (84 + Theme.GAP)
            child:SetPoint("CENTER", anchor, "CENTER", 0, yOff)
        end
    else
        -- Arrange children side-by-side, centered horizontally on the anchor.
        -- Total width: n * 64 + (n-1) * GAP, center on anchor.
        local totalW = n * 64 + (n - 1) * Theme.GAP
        anchor:SetSize(totalW, 84) -- see the vertical branch's comment above for why
        local firstX = -totalW / 2 + 32 -- half-width offset: center first child's center
        for i, typeKey in ipairs(visible) do
            local child = anchor.children[typeKey]
            child:ClearAllPoints()
            local xOff = firstX + (i - 1) * (64 + Theme.GAP)
            child:SetPoint("CENTER", anchor, "CENTER", xOff, 0)
        end
    end
end

-- Per-type cancellable hide-timer handles, keyed by reminder type string.
-- Each is a C_Timer.NewTimer handle (never C_Timer.After, which has no
-- :Cancel()). A second reminder of the same type restarts only that type's
-- timer (FEAST-02); other types' timers are unaffected (FEAST-F2 D-09:
-- independent per-type timers, not "last-wins").
local hideTimers = {}

-- FEAST-04 held-send slot: nil when nothing is queued, otherwise a table
-- with { type, spellID } and queuedAt = GetTime(). Depth is exactly one,
-- last-wins -- a second cast during the same restriction window replaces
-- the first rather than adding to a backlog. Nothing about the held send
-- is persisted.
local pendingSend

-- ShowReminder(type, spellID): resolves the spell's own game icon for the
-- given type's child frame, sets its type-specific label, and (re)arms a
-- fresh auto-hide timer for that type alone. Other active types are left
-- untouched — a cauldron reminder does not cancel a running feast reminder
-- (FEAST-F2 D-09: independent per-type timers, no "last-wins").
--
-- Called from: comm receive handler, UNIT_SPELLCAST_SUCCEEDED (via the comm
-- round-trip), CHAT_MSG_EMOTE handler, and /wt feasttest harness.
local function ShowReminder(reminderType, spellID)
    local def = REMINDER_TYPES[reminderType]
    if not def then return end

    -- Per-type enable check: nil (not yet configured by migration) = default ON.
    local feastSV = WildThingsSavedVars.feast
    if feastSV and feastSV.enabled and feastSV.enabled[reminderType] == false then return end

    -- Cancel the existing hide timer for THIS type only, if any.
    -- Other types' timers continue undisturbed.
    if hideTimers[reminderType] then
        hideTimers[reminderType]:Cancel()
        hideTimers[reminderType] = nil
    end

    local reminderAnchor = EnsureFeastAnchor()
    local child = EnsureReminderChild(reminderType)

    -- Resolve spell icon texture.
    local info = C_Spell.GetSpellInfo(spellID)
    local texture = (info and info.originalIconID) or FEAST_FALLBACK_ICON
    child.glyph:SetTexture(texture)
    child.glyph:ClearTint()
    child.glyph:SetAlpha(1)

    -- Type-specific label (FEAST-F2 D-07).
    child.label:SetText(def.label)

    -- Show the child and arrange the layout.
    child:Show()
    RelayoutChildren()
    -- Keep the reminder click-through. This became load-bearing the moment the anchor gained a
    -- real rect (see RelayoutChildren): a sized frame with mouse input enabled swallows every
    -- click inside a 64x84 patch of screen, and a reminder is a passive HUD element the raider
    -- must be able to click, target and cast straight through. Mouse input belongs to edit mode
    -- ALONE, so bind it to `editMode` here instead of assuming ExitFeastEditMode already ran --
    -- a live reminder firing while the anchor is still unlocked would otherwise stay a click
    -- blocker for as long as it is on screen.
    reminderAnchor:EnableMouse(editMode)
    reminderAnchor:Show()

    -- Type-specific duration with global-default fallback (FEAST-F2 D-08).
    local duration = 15
    if feastSV then
        duration = feastSV.duration[reminderType] or feastSV.defaultDuration or 15
    end
    hideTimers[reminderType] = C_Timer.NewTimer(duration, function()
        hideTimers[reminderType] = nil
        -- FEAST-03: do not hide the child while edit mode is active.
        if not editMode then
            child:Hide()
            RelayoutChildren()
            -- Hide the anchor when no children remain visible.
            local anyShown = false
            for _, c in pairs(reminderAnchor.children) do
                if c:IsShown() then anyShown = true; break end
            end
            if not anyShown then
                reminderAnchor:Hide()
            end
        end
    end)
end

-- FEAST comm receive handler. Registered at file-load time (Private.commHandlers
-- already exists via Init.lua); the handler body only runs later, at actual comm-
-- receive time, reached exclusively through Transmission.lua's DispatchCommEvent
-- chokepoint. Self-messages are ALREADY skipped centrally there (Transmission.lua:85,
-- `UnitIsUnit(sender, "player")`), before this handler is even looked up -- so this
-- file must NEVER call ShowReminder from the broadcast path below (that would defeat
-- the free self-suppression and show the caster their own reminder). This is the
-- deliberate inverse of Phases 42/45/46/47, where the opener DOES self-populate
-- because loopback is impossible there -- do not "fix" this file to match those;
-- the asymmetry is intentional.
--
-- Gate order, each a fail-closed silent return, preceding any spell/texture API call:
--   1. Validate payload shape (new {type, spellID} table OR old-format bare number
--      treated as {type="feast", spellID=<number>} for backwards compat).
--   2. Validate type is a known REMINDER_TYPES key and spellID is a listed spell
--      for that type.
--   3. Check the per-type enable toggle.
--   4. FEAST-F1: for feast type only, check the receiver's own Well-Fed buff --
--      if the player already has any known Hearty Well-Fed buff, suppress the
--      reminder (D-03: only feast-food Well-Fed, not personal food).
--   5. ShowReminder(type, spellID) -- the single shared display path.
--
-- Raid/party membership (Transmission.lua:71-73) is already applied before this runs
-- -- the guild-rank capability check Timeline.lua's TIMELINE handler uses is
-- deliberately NOT used here: nothing is persisted and nothing privileged happens,
-- so the existing raid-membership check is the correct and sufficient boundary, and
-- a rank gate would break the common case of a non-officer dropping the feast.
-- IsPlayerAlreadyWellFed(): FEAST-F1 receiver-side check, shared by both feast-
-- detection paths (the comm round-trip below and the CHAT_MSG_EMOTE fallback
-- further down) so an already-sated player is suppressed identically no matter
-- which path detected the feast. Iterates the flat WELL_FED_BUFF_IDS set --
-- true if the player has ANY known Hearty Well-Fed buff active. A flat set
-- rather than a castID->buffID mapping because matching individual feasts to
-- their specific buff IDs is unreliable across tiers.
local function IsPlayerAlreadyWellFed()
    for buffID in pairs(WELL_FED_BUFF_IDS) do
        if C_UnitAuras.GetPlayerAuraBySpellID(buffID) then
            return true
        end
    end
    return false
end

Private:RegisterCommEvent("FEAST", function(sender, payload)
    -- Backwards compat: old clients send a bare spellID number.
    -- Treat it as {type="feast", spellID=<number>}.
    local reminderType, spellID
    if type(payload) == "number" then
        reminderType = "feast"
        spellID = payload
    elseif type(payload) == "table" and type(payload.type) == "string" and type(payload.spellID) == "number" then
        reminderType = payload.type
        spellID = payload.spellID
    else
        return
    end

    -- Validate type and spellID against the declared tables.
    local def = REMINDER_TYPES[reminderType]
    if not def then return end
    if not def.spells[spellID] then return end

    -- FEAST-F1: receiver-side Well-Fed buff suppression for feast type only.
    -- (Per-type enable toggle is checked once, inside ShowReminder itself --
    -- the single shared display path both this handler and CHAT_MSG_EMOTE
    -- funnel through, so it does not need to be repeated here.)
    if reminderType == "feast" and IsPlayerAlreadyWellFed() then
        return -- player is already sated, suppress reminder
    end

    ShowReminder(reminderType, spellID)
end)

---------------------------------------------------------------------------
-- FEAST-04 hold-and-flush queue -- two module-local functions named to keep
-- feast semantics at arm's length so Phases 42/45/46/47 can lift the shape
-- without lifting the feast identity. Do not pre-generalise into a shared
-- module; there is no second consumer yet.
---------------------------------------------------------------------------

-- QueuePendingSend(reminderType, spellID): assigns the single-slot held send,
-- replacing whatever was there (depth one, last-wins). The type and spellID
-- are treated as opaque data -- this function carries no feast semantics of
-- its own.
local function QueuePendingSend(reminderType, spellID)
    pendingSend = {
        type = reminderType,
        spellID = spellID,
        queuedAt = GetTime(),
    }
end

-- FlushPendingSend(): returns a status string the harness can print. Returns
-- early when the slot is empty. Otherwise copies the entry into a local and
-- sets pendingSend to nil BEFORE anything else -- clearing before the send,
-- never after, because an error inside the broadcast is contained by
-- Private.safecall and would otherwise leave a stuck entry that re-fires on
-- every subsequent restriction change (the Timeline.lua:537-540 cancel-then-nil
-- timer discipline, applied to state). Then applies the staleness guard:
-- discards without sending when the held age exceeds the configured display
-- duration, read live at flush time rather than captured at hold time so a
-- slider change during the restriction behaves sanely.
local function FlushPendingSend()
    if not pendingSend then
        return "nothing_held"
    end

    local queued = pendingSend
    pendingSend = nil

    local feastSV = WildThingsSavedVars.feast
    local duration = 15
    if feastSV then
        duration = feastSV.duration[queued.type] or feastSV.defaultDuration or 15
    end
    local age = GetTime() - queued.queuedAt

    if age > duration then
        return "stale"
    end

    -- Bound to locals first so both broadcast sites carry the identical
    -- (event, "RAID", {type, spellID}) payload shape.
    local reminderType = queued.type
    local spellID = queued.spellID
    Private:Broadcast("FEAST", "RAID", { type = reminderType, spellID = spellID })
    return "sent"
end

---------------------------------------------------------------------------
-- FEAST-03: anchor edit mode -- drag, persistence, reset.
--
-- Four exported entry points drive the Settings unlock toggle's get/set and
-- the reset button. Edit mode is deliberately entered and left ONLY through
-- Settings -- do NOT add an automatic exit on ENCOUNTER_START. The Settings
-- unlock toggle's get() runs once at build time (CreateSettingsScreen discards
-- BuildWidgets' refresh handle), so any second exit path would leave the
-- checkbox showing a state that is not true. A stale checkbox that claims the
-- reminder is unlocked when it is not is a worse defect than the annoyance an
-- auto-exit would prevent.
---------------------------------------------------------------------------

function Private:EnterFeastEditMode()
    EnsureFeastAnchor()
    editMode = true
    anchor:EnableMouse(true)
    anchor.editTreatment:Show()
    -- Show every enabled type as a child with its real spell icon + label
    -- so the user sees exactly what the anchor will look like with active
    -- reminders. Children for disabled types stay hidden.
    Private:RefreshFeastEditPreview()
    anchor:Show()
end

-- RefreshFeastEditPreview(): re-evaluates every reminder type's enable
-- toggle, shows/hides the corresponding child, and re-layouts the anchor.
-- Called from EnterFeastEditMode (initial build) and from the per-type
-- enable-toggle set() callbacks in SettingsScreen so the preview updates
-- live while the user toggles.
function Private:RefreshFeastEditPreview()
    if not anchor then return end
    if not editMode then return end

    local feastSV = WildThingsSavedVars.feast

    -- Edit fixture: the first spell ID from each type's table, used only
    -- to resolve a real spell icon for the preview. Never broadcast,
    -- never persisted — purely cosmetic.
    local fixtureByType = {}
    for typeKey, def in pairs(REMINDER_TYPES) do
        for id in pairs(def.spells) do
            fixtureByType[typeKey] = id
            break
        end
    end

    local anyShown = false
    for typeKey, def in pairs(REMINDER_TYPES) do
        local child = EnsureReminderChild(typeKey)
        local enabled = true
        if feastSV and feastSV.enabled and feastSV.enabled[typeKey] == false then
            enabled = false
        end

        if enabled then
            local spellID = fixtureByType[typeKey]
            if spellID then
                local info = C_Spell.GetSpellInfo(spellID)
                local texture = (info and info.originalIconID) or FEAST_FALLBACK_ICON
                child.glyph:SetTexture(texture)
            end
            child.glyph:ClearTint()
            child.glyph:SetAlpha(1)
            child.glyph:Show()
            child.label:SetText(def.label)
            child.label:Show()
            child:Show()
            anyShown = true
        else
            child:Hide()
        end
    end

    if anyShown then
        -- RelayoutChildren sizes the anchor itself now (one copy of that arithmetic, shared
        -- with the live display path) -- do not re-add a second SetSize here. The count is
        -- identical either way: every REMINDER_TYPES key above is explicitly Show()n or
        -- Hide()n, so the shown set is exactly the enabled set that the deleted local
        -- `visibleCount` used to tally, and RelayoutChildren derives n from IsShown().
        RelayoutChildren()
    else
        -- All types disabled: still show something so the user can drag.
        local feastChild = EnsureReminderChild("feast")
        feastChild:ClearAllPoints()
        feastChild:SetPoint("CENTER", anchor, "CENTER", 0, 0)
        feastChild.glyph:Show()
        feastChild.label:SetText("Feast")
        feastChild.label:Show()
        feastChild:Show()
        anchor:SetSize(64, 84)
    end
end

function Private:ExitFeastEditMode()
    if not anchor then return end
    -- Defensive StopMovingOrSizing: the user may have released the mouse
    -- outside the frame, leaving a drag gesture in progress -- this
    -- terminates it cleanly before the frame is locked again.
    anchor:StopMovingOrSizing()
    -- Save on exit as well as on drag-stop -- cheap insurance for the case
    -- where the user drags and then /reloads without a mouse-up landing on
    -- the frame (the OnMouseUp script above would not have fired).
    WTUI:SavePosition(anchor, FEAST_ANCHOR_KEY)
    editMode = false
    anchor:EnableMouse(false)
    anchor.editTreatment:Hide()
    -- Hide children that were only shown for positioning (not driven by a
    -- live reminder timer). Live children with an outstanding hide timer
    -- stay visible — RelayoutChildren will re-arrange them correctly.
    for typeKey, child in pairs(anchor.children) do
        if child:IsShown() and not hideTimers[typeKey] then
            child:Hide()
        end
    end
    RelayoutChildren()
    -- Hide the anchor when no children remain visible.
    if not next(hideTimers) then
        anchor:Hide()
    end
end

function Private:IsFeastEditMode()
    return editMode
end

function Private:ResetFeastAnchorPosition()
    local reminderAnchor = EnsureFeastAnchor()
    reminderAnchor:ClearAllPoints()
    -- Clear all child frame positions so they re-anchor from scratch.
    for _, child in pairs(reminderAnchor.children) do
        child:ClearAllPoints()
    end
    reminderAnchor:SetPoint("CENTER", UIParent, "CENTER", 0, 200)
    WTUI:SavePosition(reminderAnchor, FEAST_ANCHOR_KEY)
    RelayoutChildren()
    print("|cff99ff00Wild Things:|r Feast reminder position reset to default.")
end

---------------------------------------------------------------------------
-- Event frame -- Invite.lua's named-method OnEvent trampoline pattern
---------------------------------------------------------------------------

local feastFrame = CreateFrame("Frame")

function feastFrame:OnEvent(event, ...)
    Private.safecall(self[event], self, event, ...)
end

feastFrame:SetScript("OnEvent", feastFrame.OnEvent)

-- FEAST-04: this registration is PERMANENT for the session and must NEVER be torn down
-- under any condition. Tearing it down during addon-messaging lockdown is precisely the
-- failure FEAST-04 exists to prevent (a feast cast during the restriction window would be
-- silently never observed, leaving nothing to queue and nothing to re-send once the
-- restriction lifts). No code path anywhere in this file may remove, disable, or
-- conditionally re-add this registration -- not on restriction, not on zone change, not
-- on leaving a group. See FeastReminder.notes.md for the prior-art contrast that motivated
-- this rule.
feastFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player")
feastFrame:RegisterEvent("ADDON_RESTRICTION_STATE_CHANGED")
-- FEAST-F3: CHAT_MSG_EMOTE for Hearty feast detection from raiders
-- without the addon. The client emits an emote like "<Playername>
-- places a cozy Hearty Blooming Feast for everyone to enjoy!" when a
-- player places a feast. Parsing this catches casters who don't run
-- Wild Things (they have no UNIT_SPELLCAST_SUCCEEDED to observe).
feastFrame:RegisterEvent("CHAT_MSG_EMOTE")

-- Registered only for "player" (per RegisterUnitEvent above), so no unitTarget/secret
-- check is needed -- this can only ever observe our own cast. Payload order confirmed
-- against the live client: unitTarget, castGUID, spellID.
--
-- FEAST-F2: the cast spell ID is looked up in ALL_CAST_SPELLS for O(1) type resolution.
-- Previously only FEAST_SPELLS was checked; now Cauldron, Soulwell, and Repair-Bot
-- casts are detected through the same handler with zero additional overhead.
function feastFrame:UNIT_SPELLCAST_SUCCEEDED(event, unitTarget, castGUID, spellID)
    local reminderType = ALL_CAST_SPELLS[spellID]
    if not reminderType then return end
    if not IsInGroup() then return end

    -- FEAST-04: hold the send when the client is under addon-messaging restriction
    -- rather than dropping it silently. Re-arm, never abort -- the same posture the
    -- repo's arrange drain already uses (RosterArrange.lua:1301). The existence-guarded
    -- form is house style for shipped files (ReadyCheck.lua:215-219); the bare form also
    -- exists in this repo but only in a file that never ships -- do not copy that one.
    if C_ChatInfo and C_ChatInfo.InChatMessagingLockdown and C_ChatInfo.InChatMessagingLockdown() then
        QueuePendingSend(reminderType, spellID)
        return
    end

    -- Payload is {type, spellID}. Transmission.lua:51-52 documents that the sender
    -- name is NEVER placed in the payload -- the receiver trusts AceComm's server-
    -- supplied sender argument instead.
    Private:Broadcast("FEAST", "RAID", { type = reminderType, spellID = spellID })
end

-- FEAST-F3 CHAT_MSG_EMOTE handler: detects Hearty feast placement emotes
-- from raiders without the addon. The client emits an automatic emote when
-- a player places a feast item in the world, e.g.:
--   "<Playername> places a cozy Hearty Blooming Feast for everyone to enjoy!"
--
-- Three substring checks, all case-sensitive and deliberately simple (no
-- regex, no complex parsing — D-17):
--   1. "places" — confirms it's a placement emote (not a /feast chat)
--   2. "for everyone to enjoy" — the Blizzard emote suffix for feasts
--   3. "Hearty" — only Hearty (upgraded) feasts matter (D-17); regular
--      feasts without "Hearty" are not relevant for raiding
--
-- D-15: no cooldown and no duplicate icon — if a feast child is already
-- showing, ShowReminder's existing timer-restart behaviour handles it.
-- D-14: only Feasts are detected via emote — Cauldron/Soulwell/Repair-Bot
-- don't produce comparable automatic emote messages.
-- D-16: no visual distinction — the emote-triggered reminder looks identical
-- to a cast-triggered one since both come from the game client itself.
--
-- Only triggers when the player is in a group (avoid solo PUG-drop spam), the
-- emoting player is actually a member of that group (CHAT_MSG_EMOTE fires for
-- any nearby player, unlike UNIT_SPELLCAST_SUCCEEDED's "player"-only scope --
-- an unrelated bystander's feast must not trigger a reminder), and the
-- emoting player is not the local player (the game also fires this emote for
-- the caster's own placement; the file's self-suppression guarantee -- see
-- the comm handler's header comment above -- must hold on this path too).
function feastFrame:CHAT_MSG_EMOTE(event, text, playerName, ...)
    if not IsInGroup() then return end
    -- Midnight secret values (RosterKeys.lua's Private.HasSecretValue): this
    -- handler fires for ANY nearby player (see the note above), and a non-group
    -- player's identity is secret on instanced maps and in combat -- precisely
    -- the raid situation this runs in. type() reports the underlying type and so
    -- lets a secret string straight through, while every check below it either
    -- indexes (`text:find`) or compares (`UnitInParty`/`UnitIsUnit`) -- all of
    -- which raise. The guard has to precede all of them.
    if Private.HasSecretValue(text, playerName) then return end
    if type(text) ~= "string" then return end
    if not playerName or not (UnitInParty(playerName) or UnitInRaid(playerName)) then return end
    if UnitIsUnit(playerName, "player") then return end

    -- All three markers must be present: it's a placement, it's a feast,
    -- and it's the Hearty (upgraded) version.
    if not text:find("places", 1, true) then return end
    if not text:find("for everyone to enjoy", 1, true) then return end
    if not text:find("Hearty", 1, true) then return end

    -- FEAST-F1: the same Well-Fed suppression the comm path applies -- an
    -- already-sated observer should not see a reminder via the emote
    -- fallback either.
    if IsPlayerAlreadyWellFed() then return end

    -- Use an arbitrary listed feast spell ID for icon resolution — the
    -- emote doesn't tell us which specific feast was placed, but any
    -- Midnight feast icon is a reasonable visual.
    ShowReminder("feast", BLOOMING_FEAST_ID)
end

-- ADDON_RESTRICTION_STATE_CHANGED fires on both edges -- entering and leaving an
-- addon-messaging restriction -- but the event fires BEFORE the restriction query
-- reflects the new state. The one-frame deferral is mandatory (the single
-- highest-value detail taken from the reference implementation): querying
-- synchronously in the handler reads the stale value, so the held send either
-- flushes on the wrong edge or never flushes at all.
function feastFrame:ADDON_RESTRICTION_STATE_CHANGED(event)
    C_Timer.After(0, function()
        -- Entering restriction has nothing to do; only leaving it can flush.
        if C_ChatInfo and C_ChatInfo.InChatMessagingLockdown and C_ChatInfo.InChatMessagingLockdown() then
            return
        end
        FlushPendingSend()
    end)
end

---------------------------------------------------------------------------
-- Dev harness -- /wt feasttest
---------------------------------------------------------------------------

-- Private:FeastTest(mode, [type]): dev-only, single-client proof of the
-- display/duration/restart, hold-and-flush, and staleness-discard behaviour
-- with no second client and no comm round-trip. Single-client comm loopback
-- is impossible by construction (self-messages are skipped centrally), so
-- this is the only way the slice can be observed at all on one client.
--
-- The plain (no-mode) path drives ShowReminder directly with an optional type
-- argument (default "feast"), exactly like /wt timelinetest -- NEVER
-- Private:Broadcast, which would put real traffic on the live raid comm
-- channel and spam every raider.
--
-- 'queue <type>' calls QueuePendingSend with the fixture spell ID for that
-- type and prints the held slot's contents and age -- exercises the hold path
-- without a real restriction.
--
-- 'flush' calls FlushPendingSend directly, bypassing the real restriction
-- query -- an addon cannot force the client into a restriction, so driving
-- the flush directly is the only way the hold-and-flush logic and the
-- staleness discard become testable offline. WARNING: flush will place a
-- real FEAST message on the RAID channel when the held entry is fresh and
-- the player is in a group.
--
-- 'list' prints every spell ID the addon watches for, grouped by type.
--
-- The plain path additionally dumps the full rendering-relevant state of the anchor, the
-- child, the glyph and the label (DescribeReminderRegion below). That dump exists because
-- "no icon appears" has three mutually exclusive causes that are indistinguishable from
-- the outside, and each leaves a different fingerprint in the dump:
--   * WRONG RECTANGLE   -- L/B/T are nil (the rect cannot be resolved at all) or fall
--                          outside UIParent's rect, or size is 0 where it should be 64x84.
--   * RIGHT RECTANGLE,  -- rect is on screen and visible=true and alpha>0, so the frame
--     NOT DRAWN            geometry is correct and the cause is occlusion (strata/level) or
--                          the glyph's own draw state.
--   * TEXTURE MISSING   -- the glyph reports texture=nil, size 0, visible=false, or alpha 0.
-- Nothing on the live reminder paths (the comm handler, CHAT_MSG_EMOTE,
-- UNIT_SPELLCAST_SUCCEEDED, ShowReminder itself) prints any of this -- the dump is reached
-- only from the branch below, i.e. only when a developer types /wt feasttest.

-- FormatNumber(v): nil-tolerant number formatter for the dump. Every geometry getter here
-- can legitimately return nil (GetLeft/GetBottom/GetTop return nil when a region's rect is
-- not resolvable), and that nil is the single most diagnostic value the dump can produce --
-- so it must be printed as "nil", never crash the harness that is trying to report it.
local function FormatNumber(v)
    if type(v) ~= "number" then return tostring(v) end
    return string.format("%.1f", v)
end

-- DescribeReminderRegion(label, region): one-line state dump for a Frame, Texture or
-- FontString. Frame-only and Texture/FontString-only getters are existence-guarded rather
-- than branched on a type flag, so the same function serves all three widget kinds.
local function DescribeReminderRegion(label, region)
    if not region then return "  " .. label .. " = nil" end

    local w, h = region:GetSize()
    local out = "  " .. label
        .. " size=" .. FormatNumber(w) .. "x" .. FormatNumber(h)
        .. " L=" .. FormatNumber(region:GetLeft())
        .. " B=" .. FormatNumber(region:GetBottom())
        .. " T=" .. FormatNumber(region:GetTop())
        .. " shown=" .. tostring(region:IsShown())
        .. " visible=" .. tostring(region:IsVisible())
        .. " alpha=" .. FormatNumber(region:GetAlpha())

    if region.GetNumPoints then
        out = out .. " points=" .. tostring(region:GetNumPoints())
    end
    if region.GetEffectiveScale then
        out = out .. " escale=" .. FormatNumber(region:GetEffectiveScale())
    end
    if region.GetFrameStrata then
        out = out .. " strata=" .. tostring(region:GetFrameStrata())
            .. " level=" .. tostring(region:GetFrameLevel())
    end
    -- Why this is dumped: mouse=true on anything other than the edit-mode anchor means the
    -- reminder is eating clicks. Note, corrected against an observed dump rather than assumed --
    -- `IsMouseEnabled` lives on ScriptRegion, so Textures and FontStrings answer it too (glyph
    -- and label both printed mouse=false in game). The guard below is therefore not a
    -- Frame/region discriminator; it is plain defensiveness. Only Frames are actual mouse-input
    -- targets, so for the glyph and the label the field is informational, not a verdict.
    if region.IsMouseEnabled then
        out = out .. " mouse=" .. tostring(region:IsMouseEnabled())
    end
    if region.GetDrawLayer then
        out = out .. " layer=" .. tostring(region:GetDrawLayer())
    end
    if region.GetTexture then
        out = out .. " texture=" .. tostring(region:GetTexture())
    end
    if region.GetText then
        out = out .. " text=" .. tostring(region:GetText())
    end

    return out
end

function Private:FeastTest(mode, arg)
    -- Resolve a type string to a fixture spell ID (first entry from its spell
    -- table). Returns nil + prints a warning when the type is unknown.
    local function FixtureSpellID(reminderType)
        local def = REMINDER_TYPES[reminderType]
        if not def then
            print("|cff99ff00Wild Things:|r feasttest: unknown type '" .. tostring(reminderType) .. "'")
            return nil
        end
        -- Grab an arbitrary spell ID from the type's table -- the first
        -- key pairs() yields will do for a display-path fixture.
        for id in pairs(def.spells) do return id end
        return nil
    end

    if mode == "queue" then
        local reminderType = type(arg) == "string" and arg ~= "" and arg or "feast"
        local fixtureID = FixtureSpellID(reminderType)
        if not fixtureID then return end
        QueuePendingSend(reminderType, fixtureID)
        if pendingSend then
            local age = GetTime() - pendingSend.queuedAt
            print("|cff99ff00Wild Things:|r feasttest queue type=" .. tostring(pendingSend.type)
                .. " spellID=" .. tostring(pendingSend.spellID)
                .. " age=" .. string.format("%.1f", age) .. "s")
        end
    elseif mode == "flush" then
        local status = FlushPendingSend()
        if status == "sent" then
            print("|cff99ff00Wild Things:|r feasttest flush sent -- a real FEAST message was placed on the RAID channel")
        elseif status == "stale" then
            print("|cff99ff00Wild Things:|r feasttest flush discarded (stale -- age exceeded configured duration)")
        elseif status == "nothing_held" then
            print("|cff99ff00Wild Things:|r feasttest flush nothing held")
        end
    elseif mode == "list" then
        print("|cff99ff00Wild Things:|r feasttest -- watched spell IDs:")
        for typeKey, def in pairs(REMINDER_TYPES) do
            local ids = {}
            for id in pairs(def.spells) do ids[#ids + 1] = tostring(id) end
            print("  " .. def.label .. " (" .. typeKey .. "): " .. table.concat(ids, ", "))
        end
    elseif mode == nil or mode == "" or REMINDER_TYPES[mode] then
        -- Plain mode: show the reminder for the given type. WildThings.lua's
        -- dispatcher always captures the first token after "feasttest " into
        -- `mode` (never `arg`), so an explicit type name (e.g. "/wt feasttest
        -- feast") arrives HERE, not in `arg` -- prefer mode when it is a
        -- recognized REMINDER_TYPES key, then fall back to a non-empty arg,
        -- then default to "feast". Reserved sub-commands are matched above,
        -- so a recognized type reaching this branch is unambiguous.
        local reminderType = REMINDER_TYPES[mode] and mode
            or (type(arg) == "string" and arg ~= "" and arg)
            or "feast"
        local fixtureID = FixtureSpellID(reminderType)
        if not fixtureID then return end
        ShowReminder(reminderType, fixtureID)

        local reminderAnchor = EnsureFeastAnchor()
        local feastSV = WildThingsSavedVars.feast
        local duration = 15
        if feastSV then
            duration = feastSV.duration[reminderType] or feastSV.defaultDuration or 15
        end
        local point, _, _, offsetX, offsetY = reminderAnchor:GetPoint(1)

        local child = EnsureReminderChild(reminderType)
        print("|cff99ff00Wild Things:|r feasttest type=" .. reminderType
            .. " spellID=" .. tostring(fixtureID)
            .. " texture=" .. tostring(child.glyph:GetTexture())
            .. " duration=" .. tostring(duration)
            .. " point=" .. tostring(point) .. " (" .. tostring(offsetX) .. ", " .. tostring(offsetY) .. ")")

        -- Geometry dump -- see the header comment above for how to read it. Deliberately
        -- printed AFTER ShowReminder so it reports the state the player is looking at, and
        -- deliberately in this branch only so no live reminder ever prints.
        local shownChildren = 0
        for _, c in pairs(reminderAnchor.children) do
            if c:IsShown() then shownChildren = shownChildren + 1 end
        end
        print("|cff99ff00Wild Things:|r feasttest geometry -- editMode=" .. tostring(editMode)
            .. " layout=" .. tostring((feastSV and feastSV.layout) or "horizontal")
            .. " shownChildren=" .. tostring(shownChildren))
        print(DescribeReminderRegion("anchor  ", reminderAnchor))
        print(DescribeReminderRegion("child   ", child))
        print(DescribeReminderRegion("glyph   ", child.glyph))
        print(DescribeReminderRegion("label   ", child.label))
        -- UIParent last, as the on-screen reference rect the three above are compared against.
        print(DescribeReminderRegion("UIParent", UIParent))
    else
        print("|cff99ff00Wild Things:|r Usage: /wt feasttest [type|queue <type>|flush|list]")
    end
end

-- Private:WellFedAudit(): dev command (/wt wellfed) that scans the player's
-- active auras and prints every buff spell ID whose name matches "Well Fed".
-- Use this to populate WELL_FED_TABLE:
--   1. Eat from ONE specific feast in-game.
--   2. /wt wellfed -- note the printed buff spell ID.
--   3. Edit WELL_FED_TABLE[<feastCastID>] = <buffSpellID> in this file.
--   4. Repeat for each of the 8 feasts.
-- Aura names and spell IDs are sourced via C_UnitAuras (available out of
-- combat). The output includes the aura name so you can verify it's the
-- right buff (e.g. "Well Fed" with haste stat vs. a different stat).
function Private:WellFedAudit()
    local found = {}

    -- Iterate player auras by index, filtering to HELPFUL (buffs) only.
    -- Max 40 buffs is the Blizzard UI limit; the API returns nil
    -- for indices beyond the player's active aura count.
    for i = 1, 40 do
        local aura = C_UnitAuras.GetAuraDataByIndex("player", i, "HELPFUL")
        if not aura then break end
        if aura.name and type(aura.name) == "string" and aura.name:find("Well Fed", 1, true) then
            found[#found + 1] = aura
        end
    end

    if #found == 0 then
        print("|cff99ff00Wild Things:|r No 'Well Fed' aura found. Eat from a feast first, then run /wt wellfed.")
        return
    end

    print("|cff99ff00Wild Things:|r === Well Fed auras on player ===")
    for _, aura in ipairs(found) do
        print(string.format("  spellID=%d  name=\"%s\"  icon=%d", aura.spellId or 0, aura.name or "?", aura.icon or 0))
    end

    print("|cff99ff00Wild Things:|r ---")
    print("|cff99ff00Wild Things:|r To fill WELL_FED_TABLE: match this buff's spellID to the feast you just ate.")
    local count = 0
    for _ in pairs(WELL_FED_BUFF_IDS) do count = count + 1 end
    print("|cff99ff00Wild Things:|r Current WELL_FED_BUFF_IDS count: " .. count .. " buff IDs registered")
end
