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

-- SUMMON-01..05: the collect-many comm core for the Summoning Window -- two new
-- WT_MSG events (SUMMON_REQ, a one-shot opener broadcast, and SUMMON_POS, an
-- auto-answer self-report from every client), a 3-second C_Timer.NewTimer collect
-- window that controls the initial seal, local self-population before broadcast (the inverse of
-- the FEAST pattern -- the opener MUST appear in their own classified list, and
-- loopback is impossible per Transmission.lua:85), and the full 7-state SnapshotRaidStatus-integrated classifier
-- (SUMMON-04, SUMMON-05) with classification priority order: Offline > No Addon >
-- In Combat > In Instance — Dead > Inside — Away > Inside — Present > Needs Summon.
-- This is one complete vertical slice -- comm handlers, session lifecycle,
-- classification, and dev harness -- proven end-to-end on a single client.
--
-- 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).
--
-- TOC slot is load-bearing, not cosmetic: this file registers two comm handlers 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. 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).

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

-- Collect window duration in seconds (D-03). Short enough that the raid lead
-- isn't waiting; long enough for all clients to respond over the WT_MSG pipeline.
local SUMMON_COLLECT_WINDOW = 3

-- Transient session table (Claude's Discretion: NEVER written to SavedVars).
-- Rebuilt fresh on each OpenSession call; a /reload clears it completely and
-- that is correct behaviour -- the 3-second collect window makes persistence
-- unnecessary and stale SavedVars data on reload would be misleading.
-- Fields:
--   open       (boolean) -- true while the collect window is active
--   replies    (table)   -- canonical-key -> {inInstance, inGroup, inCombat}
--   classified (table)   -- canonical-key -> {status} (populated by SealSession)
--   collectedAt(number)  -- GetTime() when the session was opened
local session = {}

-- 30-second SUMMON_REQ cooldown: prevents /wt summons spam (Claude's Discretion).
-- TIMELINE_REQ REPLY_COOLDOWN precedent at Timeline.lua:573-574.
local SUMMON_REQ_COOLDOWN = 30
local lastRequestAt = 0  -- GetTime() when the last SUMMON_REQ was broadcast

-- The C_Timer.NewTimer handle for the collect window. Nil when no session is
-- open. C_Timer.NewTimer (NOT C_Timer.After) is mandatory so a re-scan or
-- manual close can cancel it (Timeline.lua:548-549 precedent, Pitfall 2).
local collectTimer = nil

-- SUMMON-08: Portal tracking for Ritual of Summoning. Nil when no portal is
-- known to be active. Set to { casterKey, casterName, placedAt } from either of
-- the two entry points below (our own cast, or a PORTAL comm message). Portal
-- expires after 5 minutes (documented lifetime per CONTEXT and the
-- REQUIREMENTS.md out-of-scope section) -- GetActivePortalStatus returns nil
-- once exceeded.
local activePortal = nil

-- Ritual of Summoning. Named because it is the single gate on the cast handler.
local RITUAL_OF_SUMMONING = 698

-- SUMMON-13: who is currently targeting whom, as reported by the targeters
-- themselves. Shape: targetKey -> { [senderKey] = GetTime() }. The count of a
-- target's inner table is what the row renders ("Targeted (3)").
--
-- This is deliberately a BROADCAST, not an observation. The previous
-- implementation polled UnitGUID(unit .. "target") across the raid every second,
-- which fails exactly where it matters: a unit's target only resolves while that
-- unit is in unit-update range, and people standing at a summoning stone are
-- usually not. It also leans on cross-unit observation, which Midnight 12.x has
-- been steadily closing off (see the portalFrame comment below for the same
-- lesson learned the hard way). Every client reports its OWN target, which needs
-- no observation of anyone else and cannot be restricted away.
--
-- Targeting is the only available signal. A summon requires a target, and the
-- summon itself is unobservable: a warlock places the portal untargeted, and any
-- raider then summons through it by targeting a player and right-clicking the
-- portal -- an interaction that emits nothing an addon can see.
local targetClaims = {}

-- A claim older than this is dropped by the accessor. Purely a safety net for a
-- client that disconnects mid-claim: a live client always retracts its own claim
-- by broadcasting the change, so claims normally expire by replacement.
local TARGET_CLAIM_TTL = 90

-- The last target key this client told the raid about. Kept so the target-changed
-- handler only broadcasts on a genuine transition -- PLAYER_TARGET_CHANGED fires
-- constantly, and 20 raiders re-announcing the same target every tab-cycle would
-- be pointless traffic.
local lastBroadcastTargetKey = nil

-- Forward declaration, same reason as SealSession below: SealSession re-announces
-- our claim once it has classified data, and it is defined ABOVE the target
-- handlers. Without this the call site would compile as a global read and be nil
-- at runtime -- the bug class this file already shipped once.
local BroadcastCurrentTarget

-- Forward declaration. OpenSession's collect-window timer (below) calls
-- SealSession, but SealSession is defined further down -- without this local
-- the call site compiles as a GLOBAL lookup that is nil at runtime, so the
-- timer body errors and the session is never sealed (empty list, forever).
-- The repo's shipped forward-reference bug class; see CLAUDE.md "Gotchas".
local SealSession

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

-- Returns a table with three boolean fields describing this client's current
-- position. D-09: UnitIsVisible is deliberately NOT in the payload -- it is a
-- viewer-side proxy the opener computes from its own perspective, not a self-report
-- that travels over the wire. No zone names (string payloads bloat the channel;
-- coordinates are API-restricted indoors anyway).
local function BuildPositionReport()
    return {
        inInstance = IsInInstance() and true or false,
        inGroup = IsInGroup() and true or false,
        inCombat = UnitAffectingCombat("player") and true or false,
    }
end

---------------------------------------------------------------------------
-- Session lifecycle -- module-local functions
---------------------------------------------------------------------------

-- OpenSession() (D-02, D-03): initialises a fresh session, self-populates the
-- opener's own report BEFORE broadcasting SUMMON_REQ, then arms the 3-second
-- collect window.
--
-- Pitfall 1 / D-02: the opener MUST self-populate before the broadcast because
-- the opener's own SUMMON_POS can never arrive via loopback -- Transmission.lua:85
-- skips self-messages centrally (UnitIsUnit(sender, "player")). This is the
-- deliberate inverse of the FEAST pattern where self-suppression is desired;
-- the asymmetry is intentionally documented at FeastReminder.lua:207-209.
--
-- Pitfall 2: any existing collect timer is cancelled before arming a new one to
-- prevent a stale timer from sealing a fresh session prematurely.
local function OpenSession()
    -- Throttle: at most one SUMMON_REQ per SUMMON_REQ_COOLDOWN seconds (Claude's Discretion).
    -- The TIMELINE_REQ REPLY_COOLDOWN precedent at Timeline.lua:573-574.
    local now = GetTime()
    if now - lastRequestAt < SUMMON_REQ_COOLDOWN then
        print("|cff99ff00Wild Things:|r Summon session already opened recently -- please wait "
            .. math.ceil(SUMMON_REQ_COOLDOWN - (now - lastRequestAt)) .. " seconds.")
        return false, "throttled"
    end
    lastRequestAt = now

    -- Guard: no point collecting summon data when solo or in a party.
    local numMembers = GetNumGroupMembers() or 0
    if numMembers == 0 then
        print("|cff99ff00Wild Things:|r Summon session requires a raid group.")
        return false, "no_raid"
    end

    -- Cancel any outstanding collect timer before starting fresh (Pitfall 2,
    -- FeastReminder.lua:168-172 pattern).
    if collectTimer then
        collectTimer:Cancel()
        collectTimer = nil
    end

    -- Zero the session table and mark it open.
    session = { open = true, replies = {}, classified = nil, collectedAt = GetTime() }

    -- SUMMON-13: a fresh session invalidates every claim -- they were made against
    -- the previous session's classification, and the roster may have changed.
    -- lastBroadcastTargetKey resets with them so the first target change after the
    -- seal is treated as a genuine transition and re-announced.
    targetClaims = {}
    lastBroadcastTargetKey = nil

    -- Self-populate BEFORE broadcast (Pitfall 1 / D-02). The opener's own
    -- SUMMON_POS can never arrive via loopback (Transmission.lua:85 self-skip
    -- is applied before any comm handler lookup), so the opener's report must
    -- be inserted locally before the collect window closes.
    local myReport = BuildPositionReport()
    local myKey = Private:GetSelfKey()
    session.replies[myKey] = myReport

    -- Broadcast SUMMON_REQ to trigger auto-answers from every other client
    -- (D-05, SUMMON-03).
    Private:Broadcast("SUMMON_REQ", "RAID")

    -- Arm the 3-second collect window. C_Timer.NewTimer (not C_Timer.After) --
    -- cancellable for re-scan (D-04 / Timeline.lua:548-549 precedent).
    collectTimer = C_Timer.NewTimer(SUMMON_COLLECT_WINDOW, function()
        collectTimer = nil
        Private.safecall(function()
            SealSession()
        end)
    end)
    return true
end

-- ClassifyAll(raidStatus): classifies every known raider into exactly one of 7
-- states in priority order (first match wins). Takes the raid roster baseline
-- (from SnapshotRaidStatus) and reads session.replies for SUMMON_POS data.
-- Returns the classified table { [key] = { status, class, role, unit, reply } }.
-- Extracted as a module-local so the SummonTest classify mode can exercise the
-- full classification logic with fake fixture data, bypassing the live
-- SnapshotRaidStatus call.
--
-- Classification priority order (SUMMON-04, SUMMON-05):
--   1. Offline           -- member.online == false (D-11)
--   2. No Addon          -- online but no SUMMON_POS reply arrived (D-11)
--   3. In Combat         -- reply.inCombat == true (D-11)
--   4. In Instance — Dead -- reply.inInstance && UnitIsDeadOrGhost(unit) (D-13)
--   5. Inside — Away    -- reply.inInstance && member.unit
--                          && not UnitIsVisible(member.unit) (D-12, Q1)
--                          -- viewer-side proximity proxy (BuildPositionReport
--                          -- at SummonComm.lua:122-126: never on the wire)
--   6. Inside — Present -- reply.inInstance && reply.inGroup, alive, not in combat
--   7. Needs Summon      -- fallthrough: online, has addon, not in combat,
--                           not dead, not in instance (D-10)
local function ClassifyAll(raidStatus)
    local classified = {}
    for key, member in pairs(raidStatus) do
        local status

        -- 1. Offline (SUMMON-05, D-11). Offline players are NEVER summon-needy --
        --    they cannot accept a summon at all.
        if not member.online then
            status = "Offline"

        -- 2. No Addon (SUMMON-05, D-11). This player is online but their client
        --    never answered the SUMMON_REQ during the 3s collect window.
        elseif not session.replies[key] then
            status = "No Addon"

        else
            local reply = session.replies[key] -- guaranteed non-nil past the No-Addon check

            -- 3. In Combat (SUMMON-05, D-11). They cannot accept a summon right now.
            if reply.inCombat then
                status = "In Combat"

            -- 4. In Instance — Dead (D-13). They need a res, not a summon.
            --    UnitIsDeadOrGhost is a read-only unit query in the same family
            --    as UnitIsVisible, so it does not need a combat guard here.
            elseif reply.inInstance and member.unit and UnitIsDeadOrGhost(member.unit) then
                status = "In Instance — Dead"

            -- 5. Inside — Away (SUMMON-04, D-12). In the instance but loaded-out
            --    in the opener's world -- too far to reach on foot, still needs
            --    a summon. The unit-truthiness guard is defensive: RaidSnapshot
            --    sets unit for every enumerated roster slot today, but a future
            --    change there should degrade to "Needs Summon" rather than throw
            --    on UnitIsVisible(nil). UnitIsVisible is a read-only unit query
            --    in the same family as the UnitIsDeadOrGhost call above (which is
            --    invoked unguarded from a C_Timer callback), so no combat guard
            --    is needed.
            elseif reply.inInstance and member.unit and not UnitIsVisible(member.unit) then
                status = "Inside — Away"

            -- 6. Inside — Present. In instance, in group, alive, not in combat.
            elseif reply.inInstance and reply.inGroup then
                status = "Inside — Present"

            -- 7. Needs Summon (SUMMON-04, D-10). The ONLY state that means "needs
            --    summon" -- reached when NONE of the above matched.
            else
                status = "Needs Summon"
            end
        end

        classified[key] = {
            status = status,
            class = member.class or "WARRIOR",
            role = member.role or "DAMAGER",
            unit = member.unit or nil,
            reply = session.replies[key] or nil,  -- nil for offline/no-addon
        }
    end

    -- Also handle raiders WITH a SUMMON_POS reply but NOT in the raid roster.
    -- These are edge-case entries: someone who left the raid mid-session but sent
    -- a reply before leaving. Classified based solely on self-report data.
    --
    -- This cohort carries unit = nil unconditionally -- UnitIsVisible(nil) is a
    -- contract violation (the API argument is non-nilable), so the loop-1 tier-5
    -- predicate cannot run here. Without a unit token there is no proximity
    -- signal to consult, so the conservative default is "Needs Summon" --
    -- treating them as still needing a summon rather than silently dropping them
    -- or crashing on a nil argument.
    for key, reply in pairs(session.replies) do
        if not raidStatus[key] then
            local status
            if reply.inCombat then
                status = "In Combat"
            elseif reply.inInstance and reply.inGroup then
                status = "Inside — Present"
            else
                status = "Needs Summon"
            end

            classified[key] = {
                status = status,
                class = "WARRIOR",
                role = "DAMAGER",
                unit = nil,
                reply = reply,
            }
        end
    end

    return classified
end

-- Reports only classification changes that affect presentation: a status
-- transition, an appeared key, or a disappeared key. reply tables are reused or
-- rebuilt independently and are deliberately never compared by identity.
local function ClassificationChanged(previous, current)
    for key, currentEntry in pairs(current) do
        local previousEntry = previous[key]
        if not previousEntry or previousEntry.status ~= currentEntry.status then
            return true
        end
    end

    for key in pairs(previous) do
        if not current[key] then return true end
    end

    return false
end

-- D-10: silently re-broadcast only when the current roster contains a member
-- who has never replied. This must never call OpenSession, which would discard
-- collected replies and target claims, and must never print, because the 1s
-- ticker would spam chat on every throttled pass.
local function AutoReRequestMissingReplies(raidStatus)
    local hasMissingReply = false
    for key in pairs(raidStatus) do
        if not session.replies[key] then
            hasMissingReply = true
            break
        end
    end
    if not hasMissingReply then return end

    local now = GetTime()
    if now - lastRequestAt < SUMMON_REQ_COOLDOWN then
        return
    end

    lastRequestAt = now
    Private:Broadcast("SUMMON_REQ", "RAID")
end

-- SealSession(): marks the session sealed, calls SnapshotRaidStatus for the
-- live raid roster baseline, and classifies every known raider into exactly one
-- of 7 states in priority order (first match wins). The collect timer still
-- controls when this initial seal runs; later SUMMON_POS replies remain accepted
-- in the same session for the live re-classification path below.
--
-- Classification priority order (SUMMON-04, SUMMON-05):
--   1. Offline           -- member.online == false (D-11)
--   2. No Addon          -- online but no SUMMON_POS reply arrived (D-11)
--   3. In Combat         -- reply.inCombat == true (D-11)
--   4. In Instance — Dead -- reply.inInstance && UnitIsDeadOrGhost(unit) (D-13)
--   5. Inside — Away    -- reply.inInstance && member.unit
--                          && not UnitIsVisible(member.unit) (D-12, Q1)
--                          -- viewer-side proximity proxy (BuildPositionReport
--                          -- at SummonComm.lua:122-126: never on the wire)
--   6. Inside — Present -- reply.inInstance && reply.inGroup, alive, not in combat
--   7. Needs Summon      -- fallthrough: online, has addon, not in combat,
--                           not dead, not in instance (D-10)
--
-- Only players who are online, have the addon, are not in combat, are not dead,
-- and are not in the instance land in "Needs Summon" -- every other state is
-- a distinct non-summon classification.
function SealSession()
    if not session.open then return end

    session.open = false

    -- Step 1: Snapshot the live raid roster baseline (SUMMON-04, SUMMON-05).
    -- SnapshotRaidStatus returns an empty table when solo/not-in-raid, so the
    -- classifier handles that gracefully (zero members -> empty classified table).
    local raidStatus = Private:SnapshotRaidStatus()

    -- Step 2: Classify every raider (SUMMON-04, SUMMON-05).
    session.classified = ClassifyAll(raidStatus)

    -- Step 3: Print summary (D-10).
    local total = 0
    local needsSummon = 0
    for _, entry in pairs(session.classified) do
        total = total + 1
        if entry.status == "Needs Summon" then needsSummon = needsSummon + 1 end
    end
    print("|cff99ff00Wild Things:|r Summon session complete -- " .. total .. " raiders, " .. needsSummon .. " need summon")

    -- SUMMON-13: re-announce our claim now that there IS classified data. A
    -- target picked before the seal produced no broadcast, because
    -- ResolveClaimableTargetKey had nothing to validate against; this is the
    -- transition that makes it count.
    BroadcastCurrentTarget()

    -- SUMMON-01: signal "summon data is ready" to any UI/orchestration
    -- subscriber (auto-open, /wt summons, Re-Scan button). Runs inside the
    -- existing collectTimer safecall (see the OpenSession collect-timer
    -- callback below), so no new safecall is needed here -- an error thrown
    -- inside a subscriber propagates up through that wrapper.
    -- See .planning/v2.7-MILESTONE-AUDIT.md §1.
    if Private.FireCallback then Private:FireCallback("WT_SUMMON_SESSION_SEALED") end
end

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

-- Both handlers follow the FeastReminder.lua:219-225 shape exactly: 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.
--
-- Gate order, each a fail-closed silent return: ValidateCommSender is already
-- applied by DispatchCommEvent (Transmission.lua:86) before either handler is
-- looked up -- the sender has been proven to be in our raid/party. No
-- SenderHasCapability gate (Pitfall 4 / D-Claude): SUMMON_REQ and SUMMON_POS
-- are unprivileged -- any raider must be able to open a summon session and any
-- raider must be able to answer one.

-- SUMMON_REQ handler (D-05, SUMMON-03): auto-answer every incoming summon
-- request with this client's position report. Zero user action, permanent
-- registration (never UnregisterEvent), same discipline as FEAST's cast-event
-- registration (FeastReminder.lua:348-357). Every client with WildThings
-- installed answers SUMMON_REQ -- this is the core of the collect-many pattern.
Private:RegisterCommEvent("SUMMON_REQ", function(sender)
    local report = BuildPositionReport()
    Private:Broadcast("SUMMON_POS", "RAID", report.inInstance, report.inGroup, report.inCombat)
end)

-- SUMMON_POS handler: store a position report from another client. Only the
-- session opener stores replies -- non-openers silently drop SUMMON_POS because
-- they have no session.replies table. Sender identity is derived from the
-- AceComm sender argument (server-supplied, Transmission.lua:51-52), never
-- payload-embedded.
local function HandleSummonPosition(sender, inInstance, inGroup, inCombat)
    -- session.open means only "the original collect window is running". That is
    -- the correct guard for sealing and the wrong guard for accepting late or
    -- automatically re-requested data; replies live for the whole session.
    if not session.replies then return end

    -- Validate field types: all three must be boolean. Non-boolean payloads are
    -- silently dropped (SUMMON-05 hardening -- malformed comm data should never
    -- reach the classifier).
    if type(inInstance) ~= "boolean" then return end
    if type(inGroup) ~= "boolean" then return end
    if type(inCombat) ~= "boolean" then return end

    local key = Private:CanonicalizeLiveName(sender)
    session.replies[key] = {
        inInstance = inInstance,
        inGroup = inGroup,
        inCombat = inCombat,
    }
end

Private:RegisterCommEvent("SUMMON_POS", HandleSummonPosition)

---------------------------------------------------------------------------
-- SUMMON-08: portal tracking (Ritual of Summoning)
---------------------------------------------------------------------------

-- Shared by BOTH entry points below -- our own cast and an inbound PORTAL
-- message -- so the two can never drift apart on key derivation or lifetime.
local function SetActivePortal(casterKey, casterName)
    activePortal = {
        casterKey = casterKey,
        casterName = casterName,
        placedAt = GetTime(),
    }
end

-- This was originally a COMBAT_LOG_EVENT_UNFILTERED frame reading
-- CombatLogGetCurrentEventInfo for spellID 698. That architecture is dead on
-- Midnight 12.x: registering CLEU at all now throws ADDON_ACTION_FORBIDDEN
-- ('Frame:RegisterEvent()' is protected for that event), and the obvious
-- fallback -- UNIT_SPELLCAST_SUCCEEDED on a non-player unit -- is a secret value
-- under the same restriction. There is NO receiver-side way to observe another
-- player's cast. See 41-RESEARCH.md's State of the Art table, which recorded
-- exactly this before Phase 45 was written.
--
-- The only architecture left is the one FEAST already runs on
-- (FeastReminder.lua:348-382): the caster detects its OWN cast and fans the fact
-- out over WT_MSG. Unavoidable consequence, by design: a warlock must be running
-- WildThings for their portal to be visible to anyone else. Registered at
-- file-load time with no AceEvent dependency (SummonComm.lua does not use
-- wt:RegisterEvent), so the frame listens for the entire session.
local portalFrame = CreateFrame("Frame")
portalFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player")
portalFrame:SetScript("OnEvent", function(self, event, unitTarget, castGUID, spellID)
    if spellID ~= RITUAL_OF_SUMMONING then return end
    if not IsInGroup() then return end

    -- Self-population BEFORE the broadcast, the same inversion of the FEAST
    -- pattern OpenSession uses above: Transmission.lua:85 drops self-messages
    -- centrally, so our own PORTAL never comes back to us. Without this the
    -- caster would be the one client unable to see their own portal.
    SetActivePortal(Private:GetSelfKey(), UnitName("player"))

    Private:Broadcast("PORTAL", "RAID")
end)

---------------------------------------------------------------------------
-- SUMMON-13: targeting claims (who is being taken care of)
---------------------------------------------------------------------------

-- Records that senderKey is now targeting targetKey, or nobody when targetKey is
-- nil. A sender holds at most ONE claim: the old one is always cleared first, so
-- switching target moves the claim rather than accumulating claims. Shared by the
-- self-population path and the comm handler so the two cannot drift.
local function SetTargetClaim(senderKey, targetKey)
    if not senderKey then return end

    for key, senders in pairs(targetClaims) do
        senders[senderKey] = nil
        if not next(senders) then targetClaims[key] = nil end
    end

    if not targetKey then return end
    targetClaims[targetKey] = targetClaims[targetKey] or {}
    targetClaims[targetKey][senderKey] = GetTime()
end

-- Resolves this client's current target to a canonical key, but only when that
-- target is a player the sealed session says still needs a summon. Anything else
-- -- no target, an NPC, a raider who is already inside -- resolves to nil, which
-- is what retracts a previous claim.
--
-- Gating on "needs summon" is what keeps this quiet: outside a summon session
-- there is no classified data, so every target change resolves to nil, matches
-- lastBroadcastTargetKey, and sends nothing. A raid night's worth of combat
-- targeting produces zero traffic.
-- Resolution is by UNIT TOKEN, not by name. Midnight (12.x) returns
-- UnitFullName("target") as a secret string to tainted execution, and feeding
-- that to Private:CanonicalKey raised "attempt to index local 's' (a secret
-- string value)" -- the same crash SummonScreen's RefreshTargetRing hit. Every
-- classified entry sourced from the raid roster carries its snapshot-time unit
-- token, so scanning those with UnitIsUnit answers the same question without
-- ever reading a name.
--
-- Consequence, accepted: the "replied but no longer in the raid roster" cohort
-- (built below the main loop) carries unit = nil unconditionally and is
-- therefore no longer claimable by targeting. There is no token to match and no
-- readable name to fall back on, so the alternative is not a better answer --
-- it is the error.
local function ResolveClaimableTargetKey()
    if not session.classified then return nil end
    if not UnitExists("target") or not UnitIsPlayer("target") then return nil end

    for key, entry in pairs(session.classified) do
        if entry.status == "Needs Summon" and entry.unit and UnitIsUnit(entry.unit, "target") then
            return key
        end
    end
    return nil
end

-- Announces this client's current claim if it changed. Self-populates first, for
-- the same reason OpenSession and the portal frame do: Transmission.lua:85 drops
-- self-messages centrally, so our own SUMMON_TARGET never comes back to us, and
-- without this the one client unable to see its own claim would be the person
-- making it.
function BroadcastCurrentTarget()
    if not IsInGroup() then return end

    local key = ResolveClaimableTargetKey()
    if key == lastBroadcastTargetKey then return end
    lastBroadcastTargetKey = key

    SetTargetClaim(Private:GetSelfKey(), key)

    -- The wire carries "" for "I am no longer targeting anyone relevant".
    -- LibSerialize handles nil in a payload, but an explicit empty string keeps
    -- the handler's type check a single unconditional `type(key) ~= "string"`.
    Private:Broadcast("SUMMON_TARGET", "RAID", key or "")
end

-- Registered at file-load time with no AceEvent dependency, exactly like
-- portalFrame below -- this must work for every raider, including one who never
-- opens the Summoning window. Putting it on the window's own event frame would
-- silently exclude everyone with the window closed, which is most of the raid.
local targetFrame = CreateFrame("Frame")
targetFrame:RegisterEvent("PLAYER_TARGET_CHANGED")
targetFrame:SetScript("OnEvent", function()
    Private.safecall(BroadcastCurrentTarget)
end)

-- SUMMON_TARGET handler: another raider reports who they are now targeting.
-- Sender identity comes from AceComm's server-supplied sender argument
-- (Transmission.lua:51-52), never from the wire. Unprivileged like every other
-- summon message -- any raider may report their own target.
Private:RegisterCommEvent("SUMMON_TARGET", function(sender, targetKey)
    if type(targetKey) ~= "string" then return end
    SetTargetClaim(Private:CanonicalizeLiveName(sender), targetKey ~= "" and targetKey or nil)
end)

-- SUMMON-13: public accessor. Returns targetKey -> number of raiders currently
-- claiming that target. Expired claims are pruned on read, so there is no timer.
function Private:GetSummonTargetCounts()
    local now = GetTime()
    local counts = {}

    for targetKey, senders in pairs(targetClaims) do
        local n = 0
        for senderKey, claimedAt in pairs(senders) do
            if now - claimedAt > TARGET_CLAIM_TTL then
                senders[senderKey] = nil
            else
                n = n + 1
            end
        end
        if n > 0 then
            counts[targetKey] = n
        else
            targetClaims[targetKey] = nil
        end
    end

    return counts
end

-- PORTAL handler: a warlock elsewhere in the raid placed a portal. The payload is
-- deliberately EMPTY -- caster identity comes from AceComm's server-supplied
-- sender argument (Transmission.lua:51-52), never from the wire, so it cannot be
-- forged. Unprivileged like SUMMON_REQ/SUMMON_POS: any raider may report their
-- own portal, so there is no SenderHasCapability gate.
Private:RegisterCommEvent("PORTAL", function(sender)
    SetActivePortal(Private:CanonicalizeLiveName(sender), sender)
end)

-- SUMMON-08: Public accessor for the active portal status. Returns nil when no
-- portal is known to be active, or when the portal's 5-minute documented lifetime
-- has expired. SummonScreen.lua reads this on every 1s ticker poll to update the
-- footer indicator and warlock chips (SUMMON-09).
function Private:GetActivePortalStatus()
    if activePortal then
        -- 5-minute portal lifetime (documented in REQUIREMENTS.md out-of-scope)
        if GetTime() - activePortal.placedAt > 300 then
            activePortal = nil
            return nil
        end
        return activePortal
    end
    return nil
end

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

-- Private:OpenSummonSession(): the single public entry point for opening a
-- summon session. Called by /wt summons and by WildThings.lua's auto-open
-- roster-changed subscriber (SUMMON-01, moved out of this file per D-04 --
-- this file stays free of orchestration). No capability gate -- any raider
-- can open a summon session (SUMMON-02).
function Private:OpenSummonSession()
    return OpenSession()
end

-- Private:RescanSummonSession(): re-scan entry point (D-04). Sends a fresh SUMMON_REQ
-- and opens a new 3-second collect window, replacing the previous session's data
-- entirely. Called by Phase 43's Re-Scan button. Currently delegates directly to
-- OpenSummonSession (which cancels any existing timer and zeroes state before
-- broadcasting) -- the full implementation (reusing the same window, updating UI)
-- lives in Phase 43; this stub provides the correct public API surface.
function Private:RescanSummonSession()
    Private:OpenSummonSession()
end

-- Private:ReclassifySummonSession(): refreshes the sealed session from the live
-- raid roster without opening a new session. Returns true only when a key's
-- status changed or a key appeared/disappeared, so the UI can reuse its existing
-- rebuild gate instead of rebuilding the secure header every tick.
function Private:ReclassifySummonSession()
    local previousClassified = session.classified
    if not previousClassified then return false end

    local raidStatus = Private:SnapshotRaidStatus()
    local classified = ClassifyAll(raidStatus)
    local changed = ClassificationChanged(previousClassified, classified)
    session.classified = classified

    AutoReRequestMissingReplies(raidStatus)
    return changed
end

-- Private:GetSummonSessionResults(): public accessor for session.classified.
-- session is module-local at line 52, inaccessible from other files -- this is the
-- data bridge that SummonScreen.lua reads (SUMMON-06).
function Private:GetSummonSessionResults()
    return session.classified
end

-- Private:GetSummonSessionTimestamp(): public accessor for session.collectedAt.
-- Returns the GetTime() value from when OpenSession was called, or nil if no
-- session has ever run.
function Private:GetSummonSessionTimestamp()
    return session.collectedAt
end

---------------------------------------------------------------------------
-- Dev harness on Private -- /wt summonstest
---------------------------------------------------------------------------

-- Private:SummonTest(mode): dev-only, single-client proof of the session
-- lifecycle and classification with no second client and no comm round-trip.
-- Single-client comm loopback is impossible by construction (self-messages
-- are skipped centrally at Transmission.lua:85), so this is the only way the
-- full session pipeline can be observed on one client.
--
-- The plain (no-mode) path opens a real session (broadcasting SUMMON_REQ to
-- RAID) and injects fixture reports directly into session.replies so the
-- opener can see classification results including the fixture entries.
--
-- 'classify' mode builds a fake raidStatus table with entries covering all 7
-- classification states and populates session.replies with matching fixtures,
-- then runs ClassifyAll directly (bypassing SnapshotRaidStatus) to exercise
-- the full classifier in isolation.
--
-- Fixture injection bypasses Private:Broadcast entirely for the injected
-- entries -- the /wt feasttest precedent (never put traffic on the live raid
-- comm channel for testing).
function Private:SummonTest(mode)
    if mode == nil or mode == "" then
        -- Plain mode: open a real session (broadcasts SUMMON_REQ to RAID,
        -- self-populates, arms the 3s collect window) and inject fixture
        -- replies so all classification states are observable on a single
        -- client.
        local _, status = Private:OpenSummonSession()

        -- OpenSession refuses (and returns "no_raid" / "throttled") BEFORE it builds
        -- the session table at line 177, so on a refusal session.replies is still nil
        -- and the injection below would throw on the first indexed assignment. The
        -- harness has to honour the status rather than assume a session exists --
        -- solo is the common case for a dev running this, and it is exactly the case
        -- OpenSession refuses. `throttled` is survivable (the previous session's
        -- replies table is still there and is what the fixtures should join), so the
        -- guard is on the table itself, not on the status string.
        if not session.replies then
            print("|cff99ff00Wild Things:|r summonstest: no summon session ("
                .. tostring(status) .. ") -- the plain mode opens a real session and "
                .. "that needs a raid group. Use 'classify' mode to exercise the "
                .. "classifier solo.")
            return
        end

        -- Inject fixture reports directly into session.replies (NO broadcast --
        -- single-client comm loopback is impossible per Transmission.lua:85,
        -- so fixture injection is the only way to test classification states).
        session.replies["test-needy-realm"] = {
            inInstance = false, inGroup = false, inCombat = false
        }
        session.replies["test-inside-realm"] = {
            inInstance = true, inGroup = true, inCombat = false
        }
        session.replies["test-away-realm"] = {
            inInstance = true, inGroup = false, inCombat = false
        }
        session.replies["test-combat-realm"] = {
            inInstance = false, inGroup = false, inCombat = true
        }

        print("|cff99ff00Wild Things:|r summonstest: injected 4 fixture replies. Run /wt summonstest classify to see results.")
    elseif mode == "classify" then
        -- Classification-only: build a fake raidStatus table and fixture
        -- replies covering all 7 classification states, then call ClassifyAll
        -- directly (bypassing SnapshotRaidStatus -- the test must NOT depend
        -- on live raid state). No broadcast, no timer -- pure classification
        -- exercise.

        -- Build a fake raidStatus covering the states that require a roster
        -- baseline: Offline, No Addon, In Instance — Dead.
        local fakeRaidStatus = {
            ["test-offline-realm"] = {
                online = false,
                class = "ROGUE",
                role = "DAMAGER",
                unit = nil,
            },
            ["test-noaddon-realm"] = {
                online = true,
                class = "MAGE",
                role = "DAMAGER",
                unit = nil,
            },
            ["test-dead-realm"] = {
                online = true,
                class = "WARRIOR",
                role = "TANK",
                unit = "player",  -- UnitIsDeadOrGhost("player") check
            },
        }

        -- Populate session.replies with fixture entries for all states that
        -- need SUMMON_POS data (needy, inside, away, combat, dead).
        session.replies = {}
        session.replies["test-needy-realm"] = {
            inInstance = false, inGroup = false, inCombat = false
        }
        session.replies["test-inside-realm"] = {
            inInstance = true, inGroup = true, inCombat = false
        }
        session.replies["test-away-realm"] = {
            inInstance = true, inGroup = false, inCombat = false
        }
        session.replies["test-combat-realm"] = {
            inInstance = false, inGroup = false, inCombat = true
        }
        session.replies["test-dead-realm"] = {
            inInstance = true, inGroup = true, inCombat = false
        }
        -- test-leftraid-realm intentionally has NO entry in fakeRaidStatus
        -- so ClassifyAll's second cohort loop (raiders who left the raid
        -- mid-session) is exercised. With no unit token for this cohort the
        -- loop-1 tier-5 predicate (UnitIsVisible) cannot run, so the entry
        -- must fall through to "Needs Summon" rather than attempt a nil
        -- argument. Without this fixture the second loop is dead code that
        -- never runs in any test.
        session.replies["test-leftraid-realm"] = {
            inInstance = true, inGroup = false, inCombat = false
        }
        -- test-noaddon-realm intentionally has NO reply in session.replies
        -- test-offline-realm intentionally has NO reply in session.replies

        -- Run the classification directly with the fake raidStatus (bypass
        -- SnapshotRaidStatus -- test must not depend on live raid state).
        session.classified = ClassifyAll(fakeRaidStatus)

        -- Print each classified entry.
        for key, entry in pairs(session.classified) do
            print("|cff99ff00Wild Things:|r summonstest " .. tostring(key) .. " -> " .. tostring(entry.status))
        end

        -- Note: the dead-player check requires UnitIsDeadOrGhost("player")
        -- to be true. When the test runs on a live player, test-dead-realm
        -- shows as "Inside — Present" instead of "In Instance — Dead"
        -- (expected fixture limitation -- UnitIsDeadOrGhost cannot be mocked
        -- from Lua).
        --
        -- Note: test-away-realm cannot prove the tier-5 UnitIsVisible repair.
        -- Its fixture shape (inInstance=true, inGroup=false) satisfies BOTH
        -- the old `not reply.inGroup` predicate AND the repaired one, and a
        -- fixture key has no live unit so UnitIsVisible cannot run against it
        -- at all -- it would only prove the surrounding classify logic. The
        -- live half of SUMDESIGN-01 needs two real clients in an instance
        -- and is named in the in-game UAT script.
        print("|cff99ff00Wild Things:|r summonstest: dead-player check requires UnitIsDeadOrGhost(\"player\") to be true; when alive, test-dead-realm shows as \"Inside — Present\" instead (expected fixture limitation).")
    else
        print("|cff99ff00Wild Things:|r Usage: /wt summonstest [classify]")
    end
end
