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

-- INSPECT-01, INSPECT-02, INSPECT-11: the collect-many comm core for Raid Inspect --
-- one new WT_MSG event (INSPECT) where every client self-reports after each boss kill
-- or wipe, a combat-deferred ENCOUNTER_END observer (C_Timer.NewTicker polling
-- InCombatLockdown until false, max 30s), a BuildInspectReport helper reading
-- C_CurrencyInfo.GetCurrencyInfo(3347) / GetItemCount / GetSpecialization(),
-- self-population before broadcast (Transmission.lua:85 self-skip makes loopback
-- impossible -- SummonComm.lua:91-93 precedent), and a round model (module-local,
-- persisted to WildThingsSavedVars.inspect.lastRound, surviving /reload per INSPECT-11)
-- with class/role/unit joined from SnapshotRaidStatus at collection time.
--
-- 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:CanonicalizeLiveName
--   (RaidSnapshot.lua), Private:GetSelfKey (RosterKeys.lua), Private.safecall
--   (Utility.lua), Private:SnapshotRaidStatus (RaidSnapshot.lua).
--
-- TOC slot is load-bearing, not cosmetic: this file registers one comm handler at
-- file-load time and creates an ENCOUNTER_END observer frame, 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. Ships in
-- every build -- sits below the do-not-package fence and must NOT be added to
-- pkgmeta.yaml's ignore list.
--
-- No local wt = Private.Ace binding -- this file does not use any Ace3 mixins
-- directly (RaidSnapshot.lua:32 precedent for no-Ace files).

---------------------------------------------------------------------------
-- Constants -- item/currency IDs for self-report
---------------------------------------------------------------------------

-- INSPECT-01: Myth Dawncrest crest (currency 3347).
local MYTH_DAWNCREST_ID = 3347

-- Combat/Throughput potion IDs (Midnight expansion, persist through death).
-- Phase 51 (INSPECT-F1): replaces the old FLEETING_POTION_IDS with a hash-style
-- lookup of explicitly-listed combat potion itemIDs. Summing across all known
-- types so the report captures any variant the player carries.
-- [ASSUMED:midnight-data] — itemIDs are Midnight-tier fleeting potions; update
-- when a new raid tier introduces new potion ranks.
local COMBAT_POTION_IDS = {
    [274765] = true, -- Fleeting Alluring Nostrum
    [245910] = true, -- Fleeting Draught of Rampant Abandon (Rank 1)
    [245911] = true, -- Fleeting Draught of Rampant Abandon (Rank 2)
    [245898] = true, -- Fleeting Light's Potential (Rank 1)
    [245897] = true, -- Fleeting Light's Potential (Rank 2)
    [274764] = true, -- Fleeting Liquid Luster (Rank 1)
    [274763] = true, -- Fleeting Liquid Luster (Rank 2)
    [245904] = true, -- Fleeting Potion of Devoured Dreams (Rank 1)
    [245905] = true, -- Fleeting Potion of Devoured Dreams (Rank 2)
    [245902] = true, -- Fleeting Potion of Recklessness (Rank 1)
    [245903] = true, -- Fleeting Potion of Recklessness (Rank 2)
    [245901] = true, -- Fleeting Potion of Zealotry (Rank 1)
    [245900] = true, -- Fleeting Potion of Zealotry (Rank 2)
}

-- Fleeting flask IDs (Midnight expansion, persist through death). Summing across
-- all known fleeting flask types so the report captures any variant the player
-- carries. INSPECT-01: fleeting-flask count reported after each pull.
--
-- These are the four flasks the Cauldron of Sin'dorei Flasks produces -- the
-- guild-cauldron consumables, which is what this column exists to police -- with
-- two ranks each on adjacent itemIDs. NON-fleeting Midnight flasks (241322 and
-- neighbours) are deliberately NOT counted, matching both this table's name and
-- the fleeting-only COMBAT_POTION_IDS table above.
--
-- The previous contents were 211503-211506, carried over from The War Within and
-- never migrated when COMBAT_POTION_IDS was (Phase 51, INSPECT-F1) -- that
-- asymmetry is what made the flask column read 0 while potions counted fine.
-- A stale id here is INERT rather than loud: GetItemCount on an unknown id
-- returns 0, so the column silently under-reports instead of erroring, which is
-- exactly why it survived a whole expansion boundary unnoticed. Re-verify this
-- table at every tier, e.g. with '/wt inspecttest ids'.
--
-- [ASSUMED:midnight-data] — all eight id-to-name bindings below were confirmed
-- IN-GAME on 2026-07-30 via '/wt inspecttest ids': every id resolved through
-- GetItemInfo to the name written beside it, pairing cleanly as four flasks x two
-- ranks. So no id in this table is wrong or dead. What remains assumed is only the
-- set's COMPLETENESS -- a fifth Midnight fleeting flask outside 245926-245933
-- would go uncounted, and being inert (GetItemCount returns 0) it would look like
-- a raider simply carrying none.
--
-- Note when re-verifying: GetItemInfo answers from the client's item cache, so a
-- first run prints "(uncached)" for ids this character has not seen and the run
-- itself warms the cache. Judge the names on a SECOND run. The counts need no
-- cache and are authoritative on the first.
--
-- The "(Rank N)" suffixes are by ascending id and are comment-only.
local FLEETING_FLASK_IDS = {
    245926, -- Fleeting Flask of Thalassian Resistance (Rank 1) -- Versatility
    245927, -- Fleeting Flask of Thalassian Resistance (Rank 2)
    245928, -- Fleeting Flask of the Shattered Sun (Rank 1) -- Critical Strike
    245929, -- Fleeting Flask of the Shattered Sun (Rank 2)
    245930, -- Fleeting Flask of the Blood Knights (Rank 1) -- Haste
    245931, -- Fleeting Flask of the Blood Knights (Rank 2)
    245932, -- Fleeting Flask of the Magisters (Rank 1) -- Mastery
    245933, -- Fleeting Flask of the Magisters (Rank 2)
}

-- Maximum seconds to poll InCombatLockdown() before giving up on this pull.
local MAX_COMBAT_WAIT = 30

-- How far two clients' round stamps may differ and still count as the SAME pull.
--
-- The stamp is GetServerTime() sampled in the ENCOUNTER_END handler, so the only
-- thing this window has to absorb is per-client event-delivery jitter: the server
-- fires ENCOUNTER_END at everyone at once, but each client samples it a fraction
-- of a second later, and a one-second-resolution clock can land either side of a
-- second boundary. That is under two seconds in practice.
--
-- Deliberately NOT sized for the self-report deferral: a deferred report carries
-- its ROUND's stamp, fixed at ENCOUNTER_END, not the stamp of the moment it is
-- finally sent -- so MAX_COMBAT_WAIT never eats into this budget.
--
-- The upper bound is the gap between two consecutive ENCOUNTER_ENDs, which is a
-- full release/run-back/re-pull/wipe cycle -- 45s at the very fastest. 15s sits
-- an order of magnitude above the jitter it must accept and comfortably below
-- the spacing it must reject.
local ROUND_MATCH_TOLERANCE = 15

---------------------------------------------------------------------------
-- Module-locals (file scope, no Private attachment)
---------------------------------------------------------------------------

-- ENCOUNTER_END observer frame, created at file-load time (FeastReminder.lua:340
-- pattern for self-contained event frames). Created here as nil placeholder; the
-- actual CreateFrame call is at line ~234, also at file-load time.
local observeFrame = nil

-- Round model (INSPECT-02, INSPECT-11): module-local, persisted to SavedVars.
-- Fields:
--   pullID     (number) -- the pull this round describes (incremented on ENCOUNTER_END)
--   roundStamp (number) -- GetServerTime() at ENCOUNTER_END: the SHARED round identity
--   reports    (table)  -- canonical-key -> {crest, potions, flasks, specID, class, role, unit}
--
-- pullID and roundStamp are not interchangeable and both have to exist:
-- pullID is a LOCAL ordinal ("this client's Nth pull") used for display, while
-- roundStamp is the only field two clients can be expected to agree on. Deciding
-- whether a received report belongs to the current pull is roundStamp's job
-- exclusively -- see RoundMatches.
local currentRound = nil

-- Pull counter, restored from SavedVars on load. Increments on each ENCOUNTER_END
-- so it survives /reload (INSPECT-11).
--
-- STRICTLY LOCAL, never a cross-client identity. Each client counts its own
-- encounters -- dungeon bosses, LFR, another night's raid, a fresh install -- so
-- two raiders' counters diverge immediately and permanently. Comparing them was
-- the bug that made the page read "1/22 reported".
local pullID = 0

-- The C_Timer.NewTicker handle for the out-of-combat poll. Nil when no pending
-- self-report. C_Timer.NewTicker is used so the ticker can be cancelled on a fresh
-- ENCOUNTER_END before the prior one resolved.
local combatTicker = nil

-- Tick counter for the max-wait timeout (incremented each second by the ticker).
local ticksWaited = 0

---------------------------------------------------------------------------
-- Persistence helpers
---------------------------------------------------------------------------

-- Restores pullID and currentRound from WildThingsSavedVars.inspect on first use
-- (WildThingsSavedVars is guaranteed available at runtime, not file-load time).
--
-- Called from EVERY path that reads the round, not just ENCOUNTER_END: the two
-- public accessors, the INSPECT receive handler and the INSPECT_REQ handler all
-- call it first. Restoring only on ENCOUNTER_END left a /reload between pulls
-- with currentRound == nil while SavedVars held a perfectly good round -- the
-- page showed "No data" and the receive handler dropped every incoming report
-- until the next pull, which is INSPECT-11's promise ("survives /reload")
-- failing on the read side.
--
-- The latch is only set once SavedVars actually exists. Latching on an absent
-- table would permanently swallow the restore if any future caller ran before
-- wt:OnInitialize() -- a silent no-op of exactly the kind this file's callers
-- cannot detect. Every current caller is post-OnInitialize; this keeps that from
-- being load-bearing.
local restoreStateCalled = false
local function RestoreStateIfNeeded()
    if restoreStateCalled then return end
    local sv = WildThingsSavedVars and WildThingsSavedVars.inspect
    if not sv then return end
    restoreStateCalled = true
    pullID = sv.pullID or 0
    currentRound = sv.lastRound or nil
end

-- RoundMatches(round, reportRoundStamp): does a received report belong to the
-- round we are currently collecting?
--
-- Replaces the old `reportPullID ~= currentRound.pullID` equality test, which
-- compared two independent per-client counters and therefore rejected nearly
-- every remote report. The guard's PURPOSE is unchanged and still enforced -- a
-- genuinely stale report from a previous pull must not pollute the current round
-- -- but the identity being compared is now one both clients derive from the
-- same server event.
--
-- Two deliberate accept-on-unknown branches, both fail-OPEN rather than
-- fail-closed, because the cost of a wrongly-accepted report is one pull's worth
-- of stale consumable counts while the cost of a wrongly-rejected one is the
-- blank page this whole fix exists to remove:
--
--   1. No stamp on the wire at all -- the sender is a pre-fix build. Mixed
--      versions are normal on a raid night; an old client only ever broadcasts
--      within MAX_COMBAT_WAIT of its own ENCOUNTER_END or in direct answer to an
--      INSPECT_REQ, so its reports are fresh in every realistic case. Accepting
--      them means an updated raid lead sees the whole raid, not just the updated
--      half. (The reverse direction cannot be rescued from here: an old RECEIVER
--      still compares against its own private pullID and drops us.)
--   2. Our own round has no stamp -- it was restored from SavedVars written by a
--      pre-fix build. Nothing to compare against for one pull; the next
--      ENCOUNTER_END stamps the round properly.
local function RoundMatches(round, reportRoundStamp)
    if type(reportRoundStamp) ~= "number" then return true end
    if type(round.roundStamp) ~= "number" then return true end
    return math.abs(reportRoundStamp - round.roundStamp) <= ROUND_MATCH_TOLERANCE
end

-- Persists currentRound and pullID to WildThingsSavedVars.inspect so the last
-- pull's data survives /reload (INSPECT-11). WildThingsSavedVars.inspect is
-- fill-absent-defaulted by Migrations.lua, so it is always a table at runtime.
local function PersistRound()
    if not WildThingsSavedVars then WildThingsSavedVars = {} end
    if type(WildThingsSavedVars.inspect) ~= "table" then WildThingsSavedVars.inspect = {} end
    WildThingsSavedVars.inspect.lastRound = currentRound
    WildThingsSavedVars.inspect.pullID = pullID
end

---------------------------------------------------------------------------
-- BuildInspectReport() -- module-local helper (not on Private)
---------------------------------------------------------------------------

-- Returns a table with four numeric fields describing this client's consumable and
-- crest state right now. INSPECT-01: crest+potion+flask+specID self-read. Currency
-- quantity falls back to 0 when the player has never earned the currency (info is
-- nil on a fresh character, unlike GetItemCount which returns 0 for zero items).
local function BuildInspectReport()
    local crestInfo = C_CurrencyInfo.GetCurrencyInfo(MYTH_DAWNCREST_ID)
    local crestCount = (crestInfo and crestInfo.quantity) or 0
    local totalEarned = (crestInfo and crestInfo.totalEarned) or 0
    local maxQuantity = (crestInfo and crestInfo.maxQuantity) or 0

    -- INSPECT-F1: combat/throughput potions only — pairs iteration over the
    -- hash-style COMBAT_POTION_IDS table.
    local combatPotionCount = 0
    for id in pairs(COMBAT_POTION_IDS) do
        local count = GetItemCount(id)
        if count and count > 0 then
            combatPotionCount = combatPotionCount + count
        end
    end

    -- INSPECT-F1: utility potion slot exists in the data model but no itemIDs
    -- are assigned this phase — always 0. The infrastructure is here so a
    -- future phase can activate utility tracking by adding UTILITY_POTION_IDS.
    local utilityPotionCount = 0

    local flaskCount = 0
    for _, id in ipairs(FLEETING_FLASK_IDS) do
        local count = GetItemCount(id)
        if count and count > 0 then
            flaskCount = flaskCount + count
        end
    end

    -- GetSpecialization() returns a 1-to-4 specialization *index* scoped to the
    -- player's own class, not a global spec ID. The receiver resolves it against
    -- the reported class file name via GetSpecializationInfoForClassID(classID,
    -- index); a by-global-id lookup would resolve it to the wrong spec entirely.
    -- 0 means no spec chosen yet (edge case: very fresh alt).
    local specID = GetSpecialization() or 0

    return {
        crest = crestCount,
        combatPotions = combatPotionCount,
        utilityPotions = utilityPotionCount,
        flasks = flaskCount,
        specID = specID,
        totalEarned = totalEarned,
        maxQuantity = maxQuantity,
        pullID = pullID,
    }
end

---------------------------------------------------------------------------
-- Out-of-combat deferral + self-report (INSPECT-02)
---------------------------------------------------------------------------

-- Arms a C_Timer.NewTicker that polls InCombatLockdown() every second. Once out
-- of combat, it self-reports and broadcasts. Max wait is MAX_COMBAT_WAIT seconds
-- -- if still in combat after that, the pull is skipped. Cancels any prior
-- pending ticker before arming (fresh ENCOUNTER_END supersedes a still-waiting
-- prior one).
local function DeferredSelfReport()
    -- Cancel any still-running prior ticker (fresh ENCOUNTER_END wins).
    if combatTicker then
        combatTicker:Cancel()
        combatTicker = nil
    end

    ticksWaited = 0

    combatTicker = C_Timer.NewTicker(1, function()
        ticksWaited = ticksWaited + 1

        if not InCombatLockdown() then
            -- Out of combat: cancel ticker, self-report, broadcast.
            if combatTicker then
                combatTicker:Cancel()
                combatTicker = nil
            end

            local report = BuildInspectReport()
            local selfKey = Private:GetSelfKey()

            -- Self-populate BEFORE broadcast (SummonComm.lua:125-131 precedent:
            -- loopback is impossible per Transmission.lua:85 self-skip, so the
            -- opener's own report must be inserted locally).
            if not currentRound then
                currentRound = { pullID = pullID, roundStamp = GetServerTime(), reports = {} }
            end
            currentRound.reports[selfKey] = report

            -- Join class/role/unit from SnapshotRaidStatus at collection time
            -- (INSPECT-02: same SnapshotRaidStatus integration pattern as SummonComm.lua).
            local raidStatus = Private:SnapshotRaidStatus()
            if raidStatus[selfKey] then
                report.class = raidStatus[selfKey].class
                report.role = raidStatus[selfKey].role
                report.unit = raidStatus[selfKey].unit
            end

            -- Broadcast INSPECT to the whole RAID so every client (including
            -- ours, minus self-loopback) stores this report.
            --
            -- The stamp sent is the ROUND's (fixed at ENCOUNTER_END), never a
            -- fresh GetServerTime() -- this send can be up to MAX_COMBAT_WAIT
            -- seconds late, and stamping it now would make a legitimately
            -- deferred report look like it belongs to a later pull.
            Private:Broadcast("INSPECT", "RAID", report.crest, report.combatPotions,
                report.utilityPotions, report.flasks, report.specID, report.totalEarned,
                report.maxQuantity, report.pullID, currentRound.roundStamp)

            -- Persist so /reload survives (INSPECT-11).
            PersistRound()

        elseif ticksWaited >= MAX_COMBAT_WAIT then
            -- Timed out: give up on this pull, cancel ticker, skip report.
            if combatTicker then
                combatTicker:Cancel()
                combatTicker = nil
            end
        end
    end)
end

---------------------------------------------------------------------------
-- ENCOUNTER_END observer frame (FeastReminder.lua:340-358 pattern)
---------------------------------------------------------------------------

-- Self-contained observer frame created at file-load time, NOT hooked into
-- Core.lua's file-local mainFrame (which is not on Private and cannot be
-- reached from here). Registers only ENCOUNTER_START at file-load time; on
-- first ENCOUNTER_START, it registers ENCOUNTER_END so it fires after every
-- boss kill or wipe (INSPECT-01). PLAYER_ENTERING_WORLD is not registered
-- (nothing to defer/resume across reload for inspect).
observeFrame = CreateFrame("Frame")

function observeFrame:ENCOUNTER_START(encounterID, encounterName, difficultyID, groupSize)
    -- One-shot: register ENCOUNTER_END on first encounter start so it fires
    -- after every encounter. Re-registering is harmless (Blizzard ignores
    -- duplicate event registrations on the same frame).
    observeFrame:RegisterEvent("ENCOUNTER_END")
end

function observeFrame:ENCOUNTER_END(encounterID, encounterName, difficultyID, groupSize, success)
    -- INSPECT-01: after every boss kill or wipe, each client reports.
    -- INSPECT-02: reporting is deferred until out of combat.

    -- Restore persisted state on first encounter (SavedVars guaranteed
    -- available by the time any encounter fires).
    RestoreStateIfNeeded()

    -- Increment pull counter and start a fresh round for this pull.
    pullID = pullID + 1

    -- Task 1 (header block, CONTEXT "Header block -- LOCKED"): success is 1/0
    -- on retail (a number), not true/false. Normalise into a tri-state: nil
    -- stays nil, a kill stores true, a wipe stores false. An a-and-b-or-c
    -- idiom cannot express false as its middle-arm result -- the moment the
    -- middle value is false, evaluation falls through to the or arm -- so an
    -- explicit if is the only correct shape whenever the computed value may
    -- legitimately be false.
    local encounterSuccess = nil
    if success ~= nil then encounterSuccess = (success == 1 or success == true) end

    -- encounterName/success/timestamp are local-state additions only -- they
    -- never cross the wire, so they are not a protocol change. roundStamp DOES
    -- cross the wire (it is the round identity every receiver matches against).
    --
    -- roundStamp and timestamp are two different clocks on purpose:
    --   roundStamp = GetServerTime() -- SERVER epoch, therefore the same number
    --     on every client in the raid. The only value here that may be compared
    --     across clients. Timeline.lua:423 uses it the same way for `sharedAt`.
    --   timestamp  = time()          -- the CLIENT MACHINE's epoch, formatted by
    --     the page as date("%H:%M:%S", ...) for a local wall-clock display. It
    --     is unusable as an identity (raiders' OS clocks drift apart), and
    --     switching it to server time would show a wrong-looking clock to anyone
    --     whose timezone differs from the realm's. Left exactly as it was.
    currentRound = {
        pullID = pullID,
        roundStamp = GetServerTime(),
        reports = {},
        encounterName = (type(encounterName) == "string") and encounterName or nil,
        success = encounterSuccess,
        timestamp = time(),
    }
    PersistRound()

    -- Defer the self-report until genuinely out of combat (INSPECT-02).
    -- Private.safecall wraps the ticker callback so a runtime error in
    -- BuildInspectReport doesn't kill the ticker (Utility.lua:15 pattern).
    Private.safecall(DeferredSelfReport)
end

-- Trampoline matching Core.lua encounterFrame:OnEvent (Core.lua:107-110).
-- Methods use positional signatures, so the trampoline must NOT forward the
-- event name string -- just the WoW event payload args.
function observeFrame:OnEvent(event, ...)
    local handler = self[event]
    Private.safecall(handler, self, ...)
end

observeFrame:SetScript("OnEvent", observeFrame.OnEvent)
observeFrame:RegisterEvent("ENCOUNTER_START")

---------------------------------------------------------------------------
-- Comm handler -- registered at file-load time via Private:RegisterCommEvent
---------------------------------------------------------------------------

-- INSPECT handler: stores a report from another client into the current round
-- model. Only stores when the report belongs to the round we are collecting
-- (RoundMatches: late arrivals from a previous pull, e.g. a deferred self-report
-- that fired after the next ENCOUNTER_END already started a new round, are
-- silently dropped). Joins class/role/unit from SnapshotRaidStatus at collection
-- time, matching the self-report join. Validates a minimal numeric shape before
-- storing -- non-numeric fields are silently dropped.
--
-- WIRE SHAPE (order is the contract, LibSerialize sends positionally):
--   1-7 crest, combatPotions, utilityPotions, flasks, specID, totalEarned,
--       maxQuantity      -- the numbers the page renders
--   8   reportPullID     -- the SENDER's local pull ordinal. Kept only so a
--       pre-fix receiver still sees the field it expects in the slot it expects;
--       nothing here reads it for a decision any more. Droppable a release after
--       every raider has updated.
--   9   reportRoundStamp -- the shared round identity (added by this fix).
--       OPTIONAL by design: absent from a pre-fix sender, which is why it is
--       deliberately NOT in the mandatory type-check block below -- RoundMatches
--       owns its type handling.
--
-- Gate order, each a fail-closed silent return: ValidateCommSender is already
-- applied by DispatchCommEvent (Transmission.lua:86) before the handler is
-- looked up -- the sender has been proven to be in our raid/party. No
-- SenderHasCapability gate: INSPECT is unprivileged -- any raider must be able
-- to report (same discipline as SummonComm.lua's sender-ungated handlers).
--
-- Registered at file-load time (Private.commHandlers already exists via
-- Init.lua); the handler body only runs later, at comm-receive time, reached
-- exclusively through Transmission.lua's DispatchCommEvent chokepoint. Shape
-- matches FeastReminder.lua:219-225 and SummonComm.lua:309-312.
Private:RegisterCommEvent("INSPECT", function(sender, crest, combatPotions, utilityPotions, flasks, specID, totalEarned, maxQuantity, reportPullID, reportRoundStamp)
    -- Validate numeric shape: all eight required fields must be numbers.
    -- Non-numeric payloads are silently dropped (same hardening as
    -- SummonComm.lua:325-327). reportRoundStamp is optional and validated by
    -- RoundMatches instead -- see the wire-shape note above.
    if type(crest) ~= "number" then return end
    if type(combatPotions) ~= "number" then return end
    if type(utilityPotions) ~= "number" then return end
    if type(flasks) ~= "number" then return end
    if type(specID) ~= "number" then return end
    if type(totalEarned) ~= "number" then return end
    if type(maxQuantity) ~= "number" then return end
    if type(reportPullID) ~= "number" then return end

    -- Restore the persisted round before deciding we have none: after a /reload
    -- between pulls the round lives in SavedVars and nowhere else, and without
    -- this the bail below would silently drop every report until the next
    -- ENCOUNTER_END (INSPECT-11).
    RestoreStateIfNeeded()

    -- Late-arrival guard: only store when the report belongs to the round we are
    -- collecting. A report from a prior pull (stale deferral from a slow client)
    -- is silently dropped -- it would pollute the current round with old data.
    if not currentRound then return end
    if not RoundMatches(currentRound, reportRoundStamp) then return end

    local key = Private:CanonicalizeLiveName(sender)

    local report = {
        crest = crest,
        combatPotions = combatPotions,
        utilityPotions = utilityPotions,
        flasks = flasks,
        specID = specID,
        totalEarned = totalEarned,
        maxQuantity = maxQuantity,
        pullID = reportPullID,
    }

    -- Join class/role/unit from live raid roster at collection time (INSPECT-02).
    local raidStatus = Private:SnapshotRaidStatus()
    if raidStatus[key] then
        report.class = raidStatus[key].class
        report.role = raidStatus[key].role
        report.unit = raidStatus[key].unit
    end

    currentRound.reports[key] = report
    PersistRound()
end)

-- INSPECT_REQ handler (INSPECT-09, Phase 47): on-demand refresh -- any client
-- can request a fresh round of inspect reports without waiting for the next
-- ENCOUNTER_END. Every client that receives this re-reads its own consumable/crest
-- state and re-broadcasts an INSPECT report. Same collect-many auto-answer pattern
-- as SUMMON_REQ in SummonComm.lua:316-320 -- zero user action per answering
-- client, permanent registration (never UnregisterEvent).
--
-- Guard: the responder must be in a raid (GetNumGroupMembers() > 0), otherwise
-- there is nobody to report to and the broadcast is wasted.
--
-- The answer is tagged with the REQUESTER's round, not the responder's. A
-- responder that missed the pull (logged in late, joined the raid after
-- ENCOUNTER_END, was in a different instance) has no round of its own, or has a
-- different one -- answering with its own identity guaranteed the requester
-- discarded the reply, so the raiders most in need of a refresh were exactly the
-- ones a refresh could never reach. Its consumable data is read live, so tagging
-- it with the round that asked for it is honest: "here is my state now, for your
-- pull".
--
-- The old `if not currentRound then return end` bail is gone, but the reasoning
-- behind it is preserved structurally: this handler never CREATES a round, so it
-- still cannot manufacture the phantom "Pull #N" that bail existed to prevent.
-- It only reads state and broadcasts. (That bail was also ordered ABOVE
-- RestoreStateIfNeeded, which made the restore unreachable in the one scenario
-- its own comment named -- a refresh requested after a /reload.)
--
-- SenderHasCapability gate is NOT applied: INSPECT_REQ, like INSPECT itself, is
-- unprivileged -- any raider must be able to request a refresh (same discipline as
-- SummonComm.lua's sender-ungated SUMMON_REQ handler).
Private:RegisterCommEvent("INSPECT_REQ", function(sender, requestRoundStamp)
    -- Must be in a group to report (solo reports are wasted cycles).
    if (GetNumGroupMembers() or 0) == 0 then return end

    -- Ensure persisted state is loaded -- FIRST, before anything reads
    -- currentRound. After a /reload the round exists only in SavedVars.
    RestoreStateIfNeeded()

    -- Answer in the requester's round when it named one; fall back to our own
    -- for a pre-fix requester (which sends no stamp at all). With neither there
    -- is no round to tag the answer with, and an untagged answer is one nobody
    -- can place -- so stay silent, exactly as the old bail did.
    local answerStamp = requestRoundStamp
    if type(answerStamp) ~= "number" then
        answerStamp = currentRound and currentRound.roundStamp or nil
    end
    if type(answerStamp) ~= "number" then return end

    local report = BuildInspectReport()
    local selfKey = Private:GetSelfKey()

    -- Self-populate BEFORE broadcast (loopback is impossible per
    -- Transmission.lua:85 self-skip -- SummonComm.lua:125-131 precedent).
    -- Guarded: with no local round there is nothing to populate INTO, and
    -- creating one here is the phantom-round case this handler must not produce.
    -- The broadcast below still goes out -- answering is not conditional on
    -- having a round of our own.
    if currentRound then
        currentRound.reports[selfKey] = report
    end

    -- Join class/role/unit from live raid state at report time.
    local raidStatus = Private:SnapshotRaidStatus()
    if raidStatus[selfKey] then
        report.class = raidStatus[selfKey].class
        report.role = raidStatus[selfKey].role
        report.unit = raidStatus[selfKey].unit
    end

    -- Re-broadcast INSPECT so every other client stores this report. Field 8 is
    -- our own local ordinal (wire-compat only, see the INSPECT wire-shape note);
    -- field 9, the identity that actually decides acceptance, is the REQUESTER's
    -- round. currentRound may be nil here, so the ordinal falls back to the
    -- module-local counter rather than indexing a nil round.
    Private:Broadcast("INSPECT", "RAID", report.crest, report.combatPotions,
        report.utilityPotions, report.flasks, report.specID, report.totalEarned,
        report.maxQuantity, (currentRound and currentRound.pullID) or pullID, answerStamp)

    PersistRound()
end)

---------------------------------------------------------------------------
-- Public API on Private
---------------------------------------------------------------------------

-- Private:GetInspectRound(): public accessor for currentRound. currentRound is
-- module-local at line ~95, inaccessible from other files -- this is the data
-- bridge that the Raid Inspect page (Phase 47) reads. Returns the whole round
-- table { pullID, roundStamp, reports = { [key] = {...} } }, or nil when no
-- encounter has occurred and none is persisted.
--
-- Restores from SavedVars first: the page opens on whatever is in memory, and
-- after a /reload between pulls that was nil while the round sat intact in
-- SavedVars -- INSPECT-11's "survives /reload" failing purely on the read side.
function Private:GetInspectRound()
    RestoreStateIfNeeded()
    return currentRound
end

-- Private:GetInspectPullID(): public accessor for the pull counter. Returns the
-- current pullID (0 until the first ENCOUNTER_END), useful for displaying which
-- pull is being described (INSPECT-08, Phase 47). Restores first for the same
-- reason as GetInspectRound -- the header reads the two together and they must
-- not disagree about whether a round exists.
function Private:GetInspectPullID()
    RestoreStateIfNeeded()
    return pullID
end

-- Private:RequestInspectRefresh(): on-demand refresh entry point (INSPECT-09).
-- Broadcasts INSPECT_REQ to the RAID, triggering every client (including the
-- broadcaster, via the INSPECT_REQ comm handler above) to re-read and re-report
-- their consumable/crest state. Called by the "Refresh" button on the Raid
-- Inspect page (InspectScreen.lua).
--
-- Carries OUR round stamp so responders can answer in our round rather than
-- their own -- see the INSPECT_REQ handler. nil is a legitimate argument (we
-- have no round yet); responders then fall back to their own.
function Private:RequestInspectRefresh()
    RestoreStateIfNeeded()
    Private:Broadcast("INSPECT_REQ", "RAID", currentRound and currentRound.roundStamp or nil)
end

---------------------------------------------------------------------------
-- Dev harness on Private -- /wt inspecttest
---------------------------------------------------------------------------

-- Private:InspectTest(mode): dev-only, single-client proof of the self-report
-- pipeline. Reads live consumable/crest data via BuildInspectReport, prints it,
-- and when mode is "store", also copies it into the round model (simulating a
-- full self-report without broadcast). No comm round-trip needed -- single-client
-- comm loopback is impossible by construction (Transmission.lua:85 self-skip).
-- The /wt feasttest precedent (never put traffic on the live raid comm channel
-- for testing) applies identically here.
--
-- Task 3: mode "fixture" toggles the solo render-path harness that lives on
-- InspectScreen.lua's Private:SetInspectFixture. A runtime, existence-guarded
-- call -- never a file-top binding -- because InspectScreen.lua loads before
-- this file (house rules), so nothing here can assume the function exists at
-- file-load time.
--
-- Modes "ids" and "round" exist because the two defects behind the "raid inspect
-- broadcasting doesn't work" report were both invisible to every gate this repo
-- has: a stale itemID makes GetItemCount return 0 rather than error, and a
-- cross-client identity mismatch cannot be reproduced on one client at all.
-- Both modes execute the real code -- the shipped FLEETING_FLASK_IDS table and
-- the shipped RoundMatches predicate -- on a single client, so neither can be
-- "verified" without ever having run.
--   ids   -- per-itemID bag counts for every tracked consumable, with names, so
--            a table that has drifted past an expansion boundary shows up as a
--            row of zeroes against an item you are holding. Run once per tier.
--   round -- the current round identity plus RoundMatches' verdict on a spread
--            of candidate stamps (same pull, jitter, previous pull, legacy
--            sender with no stamp). This is the accept/reject decision the raid
--            makes, made visible without a raid.
function Private:InspectTest(mode)
    if mode == "fixture" then
        if Private.SetInspectFixture then
            Private:SetInspectFixture()
        else
            print("|cff99ff00Wild Things:|r inspecttest: fixture harness unavailable (InspectScreen not loaded)")
        end
        return
    end

    if mode == "ids" then
        -- GetItemInfo answers from the client's item cache and returns nil for an
        -- item this character has never seen -- which is the NORMAL case for a
        -- consumable you do not carry. A nil name is therefore not a failure and
        -- must not read like one; "(uncached)" says so out loud, and the count
        -- beside it is still authoritative because GetItemCount needs no cache.
        --
        -- The count is read TWICE on purpose. GetItemCount(id) counts bags only,
        -- which is the right number for the shipped report -- a flask in the bank
        -- cannot be drunk mid-pull -- but it makes "x0 against an item you own"
        -- ambiguous in exactly this mode: a stale itemID and a flask sitting in
        -- the bank produce the identical x0. The second read widens the search to
        -- bank/reagent bank and is printed only when it finds something the
        -- first did not, so a location question stops looking like a table bug.
        -- Four arguments exactly (itemInfo, includeBank, includeUses,
        -- includeReagentBank) -- a fifth for the warband bank does not exist in
        -- this API version and the checker flags it as redundant-parameter, so
        -- an item parked ONLY in the warband bank still reads x0 here.
        local function PrintIDLine(id)
            local name = GetItemInfo(id)
            local count = GetItemCount(id) or 0
            local stored = GetItemCount(id, true, false, true) or 0
            local elsewhere = stored - count
            print("|cff99ff00Wild Things:|r   " .. tostring(id)
                .. "  x" .. tostring(count)
                .. (elsewhere > 0 and ("  (+" .. tostring(elsewhere) .. " in bank)") or "")
                .. "  " .. tostring(name or "(uncached)"))
        end

        print("|cff99ff00Wild Things:|r inspecttest ids -- combat potions:")
        for id in pairs(COMBAT_POTION_IDS) do
            PrintIDLine(id)
        end
        print("|cff99ff00Wild Things:|r inspecttest ids -- fleeting flasks:")
        for _, id in ipairs(FLEETING_FLASK_IDS) do
            PrintIDLine(id)
        end
        print("|cff99ff00Wild Things:|r inspecttest ids: a flask/potion in your bags"
            .. " that shows x0 on every line above means its itemID is missing from the table.")
        return
    end

    if mode == "round" then
        RestoreStateIfNeeded()

        local stamp = currentRound and currentRound.roundStamp
        print("|cff99ff00Wild Things:|r inspecttest round -- serverTime=" .. tostring(GetServerTime())
            .. " pullID=" .. tostring(pullID)
            .. " roundStamp=" .. tostring(stamp)
            .. " tolerance=" .. tostring(ROUND_MATCH_TOLERANCE) .. "s")

        if not currentRound then
            print("|cff99ff00Wild Things:|r inspecttest round: no round yet -- every incoming"
                .. " report is dropped until an ENCOUNTER_END fires. Run '/wt inspecttest store' first.")
            return
        end

        -- Exercise the SHIPPED predicate, never a re-implementation of it: a
        -- copy would prove only that the copy works.
        local function RunCases(round, base)
            local cases = {
                { label = "same pull, identical stamp", value = base },
                { label = "same pull, 2s delivery jitter", value = base + 2 },
                { label = "previous pull, 60s earlier", value = base - 60 },
                { label = "next pull, 60s later", value = base + 60 },
                { label = "pre-fix sender, no stamp", value = nil },
            }
            for _, case in ipairs(cases) do
                local ok = RoundMatches(round, case.value)
                print("|cff99ff00Wild Things:|r   " .. (ok and "|cff40ff40ACCEPT|r" or "|cffff4040REJECT|r")
                    .. "  " .. case.label .. " (" .. tostring(case.value) .. ")")
            end
        end

        RunCases(currentRound, stamp or 0)

        if type(stamp) == "number" then
            print("|cff99ff00Wild Things:|r inspecttest round: expected ACCEPT, ACCEPT, REJECT, REJECT, ACCEPT.")
        else
            -- The round came out of SavedVars written by a pre-fix build, so it
            -- carries no stamp and RoundMatches accepts everything for this one
            -- pull by design.
            --
            -- Printing only that explanation is not enough, and this is the one
            -- mode whose entire job is to stop taking explanations on trust:
            -- EVERY client is in exactly this state on its first login after the
            -- fix, so the guard would stay unverifiable until the next boss pull
            -- -- the same "it will be fine later" that let the original defect
            -- ship. Re-run the SHIPPED predicate against a synthetic stamped
            -- round so the accept/reject logic is provable right now. The live
            -- round's all-ACCEPT block stays above it: its nil stamp is a real
            -- diagnostic about this character's SavedVars, not noise.
            print("|cff99ff00Wild Things:|r inspecttest round: the live round predates the"
                .. " roundStamp field, so every case above ACCEPTs by design.")
            print("|cff99ff00Wild Things:|r inspecttest round -- same predicate, synthetic stamped round:")
            local synthetic = GetServerTime()
            RunCases({ roundStamp = synthetic, reports = {} }, synthetic)
            print("|cff99ff00Wild Things:|r inspecttest round: expected ACCEPT, ACCEPT, REJECT, REJECT, ACCEPT."
                .. " The next ENCOUNTER_END stamps the live round properly.")
        end
        return
    end

    -- Ensure state is restored even if no encounter has fired yet (first-run).
    RestoreStateIfNeeded()

    local report = BuildInspectReport()

    print("|cff99ff00Wild Things:|r inspecttest -- pullID=" .. tostring(pullID))
    print("|cff99ff00Wild Things:|r   crest=" .. tostring(report.crest)
        .. " combatPotions=" .. tostring(report.combatPotions)
        .. " utilityPotions=" .. tostring(report.utilityPotions)
        .. " flasks=" .. tostring(report.flasks)
        .. " specID=" .. tostring(report.specID)
        .. " totalEarned=" .. tostring(report.totalEarned)
        .. " maxQuantity=" .. tostring(report.maxQuantity))

    if mode == "store" then
        -- Simulate a full self-report: create (or reuse) a round and store the
        -- report locally. This exercises the round model without any broadcast.
        if not currentRound then
            -- Bump pullID even though no encounter fired, so the round has a
            -- non-zero ID -- the real path increments on ENCOUNTER_END, but the
            -- test path lets the dev observe the store-persist cycle directly.
            pullID = pullID + 1
            currentRound = { pullID = pullID, roundStamp = GetServerTime(), reports = {} }
        end

        local selfKey = Private:GetSelfKey()
        currentRound.reports[selfKey] = report

        -- Join class/role/unit from live raid state.
        local raidStatus = Private:SnapshotRaidStatus()
        if raidStatus[selfKey] then
            report.class = raidStatus[selfKey].class
            report.role = raidStatus[selfKey].role
            report.unit = raidStatus[selfKey].unit
        end

        PersistRound()
        print("|cff99ff00Wild Things:|r inspecttest: stored self-report to currentRound (pullID="
            .. tostring(currentRound.pullID) .. ")"
            .. " class=" .. tostring(report.class)
            .. " role=" .. tostring(report.role)
            .. " unit=" .. tostring(report.unit))

        -- Print full round summary.
        local count = 0
        for _ in pairs(currentRound.reports) do
            count = count + 1
        end
        print("|cff99ff00Wild Things:|r inspecttest: round has " .. tostring(count) .. " report(s)")
    else
        print("|cff99ff00Wild Things:|r inspecttest: modes -- store (persist a self-report),"
            .. " ids (per-itemID bag counts), round (round identity + accept/reject), fixture (render harness)")
    end
end
