---@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 (TWW 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.
local FLEETING_FLASK_IDS = {
    211503, -- Flask of Tempered Mastery
    211504, -- Flask of Tempered Swiftness
    211505, -- Flask of Tempered Versatility
    211506, -- Flask of Alchemical Chaos
}

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

---------------------------------------------------------------------------
-- 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)
--   reports  (table)   -- canonical-key -> {crest, potions, flasks, specID, class, role, unit}
local currentRound = nil

-- Pull counter, restored from SavedVars on load. Increments on each ENCOUNTER_END
-- so it survives /reload (INSPECT-11).
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 file load
-- (WildThingsSavedVars is guaranteed available at runtime, not file-load time --
-- this is called from the first ENCOUNTER_END handler, which always runs after
-- wt:OnInitialize() has ensured the SavedVars table exists).
local restoreStateCalled = false
local function RestoreStateIfNeeded()
    if restoreStateCalled then return end
    restoreStateCalled = true
    local sv = WildThingsSavedVars and WildThingsSavedVars.inspect
    if sv then
        pullID = sv.pullID or 0
        currentRound = sv.lastRound or nil
    end
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, 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.
            Private:Broadcast("INSPECT", "RAID", report.crest, report.combatPotions,
                report.utilityPotions, report.flasks, report.specID, report.totalEarned,
                report.maxQuantity, report.pullID)

            -- 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 (Broadcast("INSPECT", ...) still sends only the
    -- report's eight numeric fields), so this is not a protocol change.
    currentRound = {
        pullID = pullID,
        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 pullID matches the current round's pullID (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.
--
-- 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)
    -- Validate numeric shape: all eight fields must be numbers. Non-numeric
    -- payloads are silently dropped (same hardening as SummonComm.lua:325-327).
    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

    -- Late-arrival guard: only store when the pullID matches the current round.
    -- 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 reportPullID ~= currentRound.pullID 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 -- and on a fresh
-- client with no round (currentRound == nil), broadcasting would self-populate
-- into a round with no ENCOUNTER_END-triggered pullID increment (the SUMMON_REQ
-- pattern creates a session; INSPECT_REQ does not create a round -- it only
-- refreshes when an existing round is present).
--
-- 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)
    -- Bail out when there is no existing round -- INSPECT_REQ refreshes, it does
    -- not create. Without this guard, a solo client with no prior encounter would
    -- self-populate into a newly-created round with a pullID that was never
    -- incremented by ENCOUNTER_END, producing a phantom "Pull #N" display.
    if not currentRound then return end

    -- 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 ENCOUNTER_END may not have fired yet
    -- if the user manually requested a refresh after /reload before any pull).
    RestoreStateIfNeeded()

    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).
    currentRound.reports[selfKey] = report

    -- 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.
    Private:Broadcast("INSPECT", "RAID", report.crest, report.combatPotions,
        report.utilityPotions, report.flasks, report.specID, report.totalEarned,
        report.maxQuantity, currentRound.pullID)

    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, reports = { [key] = {...} } }, or nil when no encounter has
-- occurred this session.
function Private:GetInspectRound()
    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).
function Private:GetInspectPullID()
    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).
function Private:RequestInspectRefresh()
    Private:Broadcast("INSPECT_REQ", "RAID")
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.
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

    -- 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, 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: re-run with '/wt inspecttest store' to persist")
    end
end
