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

-- Shipped (packaged-build) live-raid-state accessor (SNAP-01): the single source
-- of truth for "who is in the raid right now, and what are they". Byte-identical
-- relocation of RosterArrange.lua's CanonicalizeLiveName/SnapshotRaidStatus --
-- no new logic and no behavioral change beyond three additive fields (isAssist/
-- raidIndex/unit). Loads after RosterKeys.lua (whose CanonicalKey/GetSelfRealm it
-- derives every key through) and before the dev-only Roster stack, outside the
-- do-not-package boundary below, so every shipped file -- the Summoning Window
-- and Raid Inspect page included -- can read class/role/online/subgroup/lead
-- without depending on the battletag-privacy-scoped dev tier. Pure functions
-- plus one chat diagnostic only -- no Ace/UI/comm dependency, no SavedVars
-- read/write, and nothing from the fenced alt-data tables.
--
-- The official-WT-raid gate at the bottom of this file adds ONE cross-file
-- dependency beyond RosterKeys.lua: Private:IsWildThingsGuildInfo (Access.lua, the
-- TOC slot immediately above this one), resolved at CALL time, not load time. The
-- rest of the contract above is unchanged -- still pure live-API reads, still no
-- SavedVars, no Ace/UI/comm and nothing from the fenced alt-data tables.

-- Splits a live GetRaidRosterInfo name on the FIRST hyphen into (name, realm);
-- a bare same-realm name has no hyphen, so it falls back to the presser's own
-- realm. Guarded so a nil/empty rawName cannot error -- returns the
-- CanonicalKey (RosterKeys.lua) of an empty name/realm rather than raising.
-- Promoted from RosterArrange.lua's file-local so both the shipped tier and the
-- dev-only arrange engine share one implementation (SNAP-02).
function Private:CanonicalizeLiveName(rawName)
    -- Midnight secret values: Private:CanonicalKey (RosterKeys.lua:72-73) already
    -- maps a secret name onto the empty key, but that promise was unreachable
    -- through this wrapper -- `rawName == ""` compares and `rawName:match(...)`
    -- indexes, and both raise on a secret one line before CanonicalKey is ever
    -- called. The secret test goes first, ahead of the nil test too, so a secret
    -- of any type is handled.
    if Private.IsSecretValue(rawName) or not rawName or rawName == "" then
        return Private:CanonicalKey("", "")
    end
    local name, realm = rawName:match("^(.-)%-(.+)$")
    if not name then
        name, realm = rawName, Private:GetSelfRealm()
    end
    return Private:CanonicalKey(name, realm)
end

-- Live per-player raid-status accessor (SNAP-01). One fresh GetRaidRosterInfo
-- sweep, keyed by Private:CanonicalizeLiveName, carrying the fuller field set
-- consumers need to derive status badges/class-color/role-icon: fileName is the
-- 6th return, combatRole the 12th (confirmed wow_api_wiki.txt:6481) -- note that
-- the 10th return is the MAINTANK/MAINASSIST raid role, NOT the combat role, and
-- is deliberately not what `role` holds here. Do not "fix" it to the 10th. A
-- member absent from the returned map is, by definition, not in the raid --
-- consumers treat a missing key as not-in-raid and fall back to
-- RosterAlts.charInfo (DATA-02). Returns the raw fileName/combatRole strings;
-- color/icon resolution is the caller's job (Theme.GetClassColor/GetIconPath),
-- not this accessor's.
--
-- GetRaidRosterInfo's own `online` return is unreliable for connected-realm raid
-- members -- Blizzard's API can report it false for a player on a different
-- sub-realm within the same connected realm even though they are fully online
-- and in the raid (the exact "player card darkened despite being in the raid"
-- symptom). UnitIsConnected(unit) is the accurate signal, so it's OR'd in.
--
-- STALENESS CONTRACT: `raidIndex` and `unit` are snapshot-time values only.
-- SetRaidSubgroup/SwapRaidSubgroup re-enumerate raid indices as a side effect
-- (RosterArrange.lua's move primitives do exactly this), so a caller that
-- mutates the raid MUST re-snapshot rather than cache those two fields. Every
-- other field is safe to hold across a mutation.
--
-- The result is a MAP keyed by canonical key, not a list, and iteration over it
-- is unspecified Lua hash order -- any consumer needing a stable order must sort
-- for itself. Solo / not in a raid returns an EMPTY TABLE, never nil.
function Private:SnapshotRaidStatus()
    local status = {}
    local numMembers = GetNumGroupMembers() or 0
    for i = 1, numMembers do
        local name, rank, subgroup, _, _, fileName, _, online, _, _, _, combatRole = GetRaidRosterInfo(i)
        if name then
            local unit = "raid" .. i
            status[Private:CanonicalizeLiveName(name)] = {
                inRaid = true,
                online = (online or UnitIsConnected(unit)) and true or false,
                subgroup = subgroup,
                class = fileName,
                role = combatRole,
                isLead = rank == 2, -- GetRaidRosterInfo rank: 2 = leader, 1 = assistant, 0 = member
                isAssist = rank == 1,
                raidIndex = i, -- snapshot-time only; see the contract above
                unit = unit, -- snapshot-time only; see the contract above
            }
        end
    end
    return status
end

-- Dev diagnostic (SNAP-01 proof). Chat print only -- no frame, no widget, no
-- SavedVars, no comm, so this file's zero-dependency contract holds.
-- Deliberately available in packaged builds: proving the snapshot resolves with
-- the dev-only Roster stack absent is the whole point of the command.
--
-- Leads with a count line so a truncated chat frame still shows the total. The
-- per-member lines are sorted by raidIndex ascending -- that ordering is this
-- command's own choice, not a property of the returned map (which carries no
-- order guarantee at all); without it two dumps of the same unchanged raid can
-- print in different orders, which makes side-by-side comparison useless. Every
-- interpolated value goes through tostring() into a %s field -- a player name is
-- externally-influenced text and is never concatenated into a format string.
function Private:DumpRaidSnapshot()
    local status = Private:SnapshotRaidStatus()
    local keys = {}
    for key in pairs(status) do
        keys[#keys + 1] = key
    end
    table.sort(keys, function(a, b)
        return (status[a].raidIndex or 0) < (status[b].raidIndex or 0)
    end)
    print(("|cff99ff00Wild Things:|r raid snapshot -- %d member(s)"):format(#keys))
    for _, key in ipairs(keys) do
        local s = status[key]
        print(("|cff99ff00Wild Things:|r %s  class=%s role=%s online=%s grp=%s rank=%s idx=%s unit=%s")
            :format(tostring(key), tostring(s.class), tostring(s.role), tostring(s.online),
                tostring(s.subgroup),
                s.isLead and "LEAD" or (s.isAssist and "ASSIST" or "member"),
                tostring(s.raidIndex), tostring(s.unit)))
    end
end

-- ============================================================
-- Official-Wild-Things-raid gate
-- (debug session dashboard-kill-scope-and-reset, 2026-07-29)
--
-- Answers one question: "is the group I am in RIGHT NOW an official Wild Things
-- raid?" This is the precondition the Raid Night Dashboard's ENCOUNTER_END writer
-- never had. Private:HandleTimelineEncounterEnd (Timeline.lua) gated a kill on
-- CONTENT only -- a plan is stored, the boss is in it, the difficulty matches --
-- so a boss killed in a pug / alt / twink run flipped the Killed marker for a boss
-- the guild had not actually killed. Confirmed in the wild: Chimaerus, 2026-07-28.
--
-- Two conditions, BOTH required (user definition, 2026-07-29):
--   1. the raid LEADER is in Wild Things, on one of the cluster's realms, and
--   2. at least WT_RAID_MAJORITY of the raid group are Wild Things guild members.
--
-- Condition 2 is not redundant padding: condition 1 alone still passes for a WT
-- officer's own pug or alt run, where the leader is in the guild but the raid
-- plainly is not the guild's. It is the condition that actually distinguishes the
-- reported scenario.
--
-- Guild identity resolves through Private:IsWildThingsGuildInfo (Access.lua), so
-- the WT_GUILD_NAME / WT_GUILD_REALMS literals exist exactly once in the repo.
--
-- GetGuildInfo(unit) is the per-member guild source, deliberately NOT
-- UnitIsInMyGuild(unit). UnitIsInMyGuild answers "same guild as the VIEWER", which
-- silently reduces this gate to "am I personally in this guild" and would measure a
-- 0% share for a trial or a pug running the addon inside a genuine WT raid.
-- GetGuildInfo(unit) names the guild independently of who is asking, which is what
-- "official WT raid" has to mean. Do not "simplify" it to UnitIsInMyGuild.
-- ReadyCheck.lua's shipped wt_only leader check has relied on GetGuildInfo(unit)
-- resolving for raid units since 2026-07-26, so the precedent is proven in
-- production -- but see Private:IsOfficialWildThingsRaid's own note on what a
-- not-yet-resolved unit costs.
-- ============================================================

-- Share of the raid group that must be Wild Things guild members before the group
-- counts as the guild's own raid. 0.70 per the user's decision (2026-07-29,
-- "recommended >= 70%"), kept as a named constant on the same request. At 25
-- players that tolerates up to 7 non-guild bodies -- pugs, trials, cross-guild
-- friends -- and still records the kill, while a 4-guildie / 16-pug alt run does
-- not. Raise it toward 0.9 if pug-heavy nights start being recorded; lower it if a
-- legitimate raid is ever rejected (and check /wt wtraid first -- the measured
-- share it prints is the number this is compared against).
local WT_RAID_MAJORITY = 0.70

-- Iterates the raid roster once and returns true iff the player's raid leader is in
-- WT_GUILD_NAME on one of WT_GUILD_REALMS. Short-circuits on the first leader found
-- (raid size caps at 40 -- cheap). Returns false when not in a raid, when no leader
-- is reported, and when GetGuildInfo returns nil for the leader.
--
-- Relocated from ReadyCheck.lua's file-local of the same name (its wt_only mode now
-- calls this) so the leader test and the guild literals behind it exist once. The
-- logic is unchanged from that original beyond the `or 0` hardening on
-- GetNumGroupMembers, which matches SnapshotRaidStatus above.
function Private:RaidLeaderIsWildThings()
    if not IsInRaid() then return false end
    local numMembers = GetNumGroupMembers() or 0
    for i = 1, numMembers do
        local unit = "raid" .. i
        if UnitExists(unit) and UnitIsGroupLeader(unit) then
            local guildName, _, _, guildRealm = GetGuildInfo(unit)
            return Private:IsWildThingsGuildInfo(guildName, guildRealm)
        end
    end
    return false
end

-- Returns wtCount, total: how many members of the current raid are Wild Things
-- guild members, out of how many actually resolve right now. Solo / not in a raid
-- returns 0, 0 -- never nil.
--
-- `total` counts only units that pass UnitExists, so a roster caught mid-update
-- cannot inflate the denominator and depress the share below the threshold. A
-- member whose GetGuildInfo has not resolved yet still counts toward `total` while
-- contributing nothing to `wtCount` -- that direction is deliberate and
-- conservative (it can only ever reject, never wrongly accept), and it is exactly
-- the case Private:IsOfficialWildThingsRaid's caller reports on.
function Private:GetRaidWildThingsShare()
    local wtCount, total = 0, 0
    local numMembers = GetNumGroupMembers() or 0
    for i = 1, numMembers do
        local unit = "raid" .. i
        if UnitExists(unit) then
            total = total + 1
            local guildName, _, _, guildRealm = GetGuildInfo(unit)
            if Private:IsWildThingsGuildInfo(guildName, guildRealm) then
                wtCount = wtCount + 1
            end
        end
    end
    return wtCount, total
end

-- The composed gate. Returns ok (boolean) plus a short reason string -- naming the
-- condition that failed and, for the majority case, the measured share.
--
-- The reason string is not decoration. A rejected kill is otherwise a silent no-op,
-- byte-for-byte indistinguishable from the fail-closed no-op an unplanned boss
-- produces, and that silence is precisely why the missing gate shipped unnoticed
-- for a whole milestone. The caller prints it.
--
-- Known cost, accepted: GetGuildInfo(unit) can return nil for a raid member the
-- client has not resolved yet, which lowers wtCount and can reject a legitimate
-- kill. The fail direction is the safe one (a missing Killed marker is re-stamped
-- by the next kill and is visibly wrong; a false Killed marker hides a boss the
-- raid still has to pull), and the printed reason makes the rejection diagnosable
-- instead of silent. /wt wtraid dumps the same measurement on demand so the margin
-- can be checked without killing anything.
function Private:IsOfficialWildThingsRaid()
    if not IsInRaid() then return false, "not in a raid" end
    if not Private:RaidLeaderIsWildThings() then
        return false, "the raid leader is not in Wild Things"
    end

    local wtCount, total = Private:GetRaidWildThingsShare()
    if total == 0 then return false, "the raid roster has not resolved yet" end

    local needed = math.floor(WT_RAID_MAJORITY * 100 + 0.5)
    if wtCount < total * WT_RAID_MAJORITY then
        return false, ("only %d of %d raid members are in Wild Things (need %d%%)")
            :format(wtCount, total, needed)
    end

    return true, ("%d of %d raid members are in Wild Things"):format(wtCount, total)
end
