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

-- Invite tools: keyword whisper
-- invites (guild members / RosterAlts-known battletags / Battle.net friends),
-- autopromote to Raid-Assistant (rank -> battletag -> name), and auto raid
-- difficulty (immediate + convert-to-raid re-apply). Mirrors Core.lua's mainFrame
-- OnEvent-trampoline shape exactly. Ships in packaged builds (outside
-- #@do-not-package@ in WildThings.toc) -- see the CanonicalKey note below.

---------------------------------------------------------------------------
-- Canonical key helper: identity comes from the shipped RosterKeys.lua
-- (Private:CanonicalKey/GetSelfRealm/GetSelfKey) and the split-on-first-hyphen/
-- own-realm-fallback wrapper now comes from the shipped RaidSnapshot.lua
-- (Private:CanonicalizeLiveName, SNAP-02) -- both load before this file and ship
-- in every build. What is left here is a local alias only, so the six call sites
-- below read unchanged; this file keeps no copy of the logic.
---------------------------------------------------------------------------

local function CanonicalizeLiveName(rawName)
    return Private:CanonicalizeLiveName(rawName)
end

---------------------------------------------------------------------------
-- Module-local, transient state -- rebuilt every session; never
-- persisted to WildThingsSavedVars beyond the user-editable config fields.
---------------------------------------------------------------------------

local keywordSet = {}          -- lowered whole-message keyword -> true
local guildMemberKeys = {}     -- canonical key -> true, refreshed on GUILD_ROSTER_UPDATE
local guildRankByKey = {}      -- canonical key -> rankIndex+1, refreshed on GUILD_ROSTER_UPDATE
local demotedPlayers = {}      -- canonical key -> true, never re-promoted this session
local reverseBattletagSet      -- battletag (lower) -> true, lazily built from RosterAlts
local demoteHooked = false
local promoteScheduled = false
local diffScheduled = false
local raidDiffSessionApplied = false

-- Splits space-separated free text into a lowercased word set. Shared by the
-- keyword builder and the promote-names/promote-battletags EditBox fields.
local function BuildWordSet(text)
    local set = {}
    for word in (text or ""):gmatch("%S+") do
        set[word:lower()] = true
    end
    return set
end

-- Reverse index of RosterAlts.charToBattletag: battletag (lower) -> true.
-- Nil-guarded -- absent entirely when Private.RosterAlts is stripped from a
-- packaged build (T-pj1-04). Built once and cached; RosterAlts is static
-- generated data that never changes at runtime.
local function GetReverseBattletagSet()
    if reverseBattletagSet then return reverseBattletagSet end
    reverseBattletagSet = {}
    if Private.RosterAlts and Private.RosterAlts.charToBattletag then
        for _, battleTag in pairs(Private.RosterAlts.charToBattletag) do
            if battleTag then
                reverseBattletagSet[battleTag:lower()] = true
            end
        end
    end
    return reverseBattletagSet
end

-- INVTOOL-02/06: true when any of the five LFG categories reports a mode.
-- Ported from the reference addon; used unconditionally by CanIssueInvites
-- (below), IsInviteEligible's whisper path is NOT gated here directly -- that
-- gate lives in CanIssueInvites -- and by the auto-accept path
-- (ShouldAutoAcceptInvite). Declared once, above every caller. All five
-- LE_LFG_CATEGORY_* constants are already in .luarc.json's diagnostics.globals,
-- so this adds no checker finding.
local function IsInLFGQueue()
    return (GetLFGMode(LE_LFG_CATEGORY_LFD) or GetLFGMode(LE_LFG_CATEGORY_LFR)
        or GetLFGMode(LE_LFG_CATEGORY_RF) or GetLFGMode(LE_LFG_CATEGORY_SCENARIO)
        or GetLFGMode(LE_LFG_CATEGORY_FLEXRAID)) and true or false
end

-- INVTOOL-04: GUID-exact friend test -- no name matching, no realm parsing, no
-- list scanning. Both PARTY_INVITE_REQUEST and the whisper events hand us the
-- sender/inviter GUID directly, so name matching would add cross-realm
-- collisions for nothing. The reference addon's own friend check is a
-- four-scan name matcher whose GUID comparison is an and/or chain that always
-- falls through to true -- the exact and/or-nil trap this project has already
-- shipped once (STATE.md, plan 55-01). Do not reproduce that shape. Both API
-- accesses are existence-guarded, so a client missing either degrades to
-- false instead of throwing.
local function IsFriendOfMine(guid)
    if not guid or guid == "" then return false end
    if C_FriendList and C_FriendList.IsFriend and C_FriendList.IsFriend(guid) then
        return true
    end
    if C_BattleNet and C_BattleNet.GetAccountInfoByGUID then
        local accountInfo = C_BattleNet.GetAccountInfoByGUID(guid)
        if accountInfo and (accountInfo.isFriend or accountInfo.isBattleTagFriend) then
            return true
        end
    end
    return false
end

-- INVTOOL-04/05/06: the auto-accept decision, evaluated top to bottom.
-- Returns (bool, reason) like AutoResAccept.lua's ShouldAutoAccept, because
-- that shape is what lets the solo /wt invtest harness (Task 2) print WHY a
-- decision went the way it did. Config is read through a single nil-guarded
-- local so a malformed SavedVars file disables the feature instead of
-- throwing inside an event handler. The guild branch reuses the existing
-- guildMemberKeys index -- no GetNumGuildMembers() loop, no IsGuildMember().
-- Note the two accept sources are independent (deliberate divergence from the
-- research sketch, PLAN.md "One deliberate divergence"): the feature is off
-- only when NEITHER toggle is on, so a guild-only configuration still works.
local function ShouldAutoAcceptInvite(name, guid)
    local cfg = WildThingsSavedVars and WildThingsSavedVars.invite
    if not cfg or not (cfg.accept_from_friends or cfg.accept_from_guild) then
        return false, "feature off"
    end
    if cfg.accept_only_when_solo and IsInGroup() then
        return false, "already in a group"
    end
    if IsInLFGQueue() then
        return false, "in an LFG queue"
    end
    if cfg.accept_from_friends and IsFriendOfMine(guid) then
        return true, "friend"
    end
    if cfg.accept_from_guild and guildMemberKeys[CanonicalizeLiveName(name)] then
        return true, "guild member"
    end
    return false, "not a friend or guild member"
end

-- INVTOOL-01/02: shared whisper-side gate, unconditional and NOT
-- user-configurable (D1, D3). Deliberately tested via UnitExists("party1"),
-- NOT IsInGroup() -- a solo player must still be able to invite, and that is
-- the common case, so IsInGroup() would wrongly block it.
local function CanIssueInvites()
    if UnitExists("party1") and not UnitIsGroupLeader("player") and not UnitIsGroupAssistant("player") then
        return false, "not raid leader or assistant"
    end
    if IsInLFGQueue() then
        return false, "in an LFG queue"
    end
    return true, "allowed"
end

-- Whisper/BN-whisper eligibility (T-pj1-01 mitigation): true when the sender's
-- canonical key is a known guild member OR (RosterAlts present AND the sender's
-- canonical key has a known battletag) OR (INVTOOL-03, opt-in, last so the
-- existing guild/battletag paths are unchanged in both behaviour and cost) the
-- sender is a Battle.net or character friend. Every Private.RosterAlts access
-- below is nil-guarded so this degrades cleanly to guild-only in a packaged
-- build. A friend list is a weaker signal than the guild roster -- anyone can
-- be on it -- which is exactly why the friend branch is opt-in and defaults
-- off rather than being folded into the existing eligibility silently.
local function IsInviteEligible(rawName, senderGUID)
    local key = CanonicalizeLiveName(rawName)
    if guildMemberKeys[key] then return true end
    if Private.RosterAlts and Private.RosterAlts.charToBattletag and Private.RosterAlts.charToBattletag[key] then
        return true
    end
    local cfg = WildThingsSavedVars and WildThingsSavedVars.invite
    if cfg and cfg.whisper_from_friends and IsFriendOfMine(senderGUID) then
        return true
    end
    return false
end

-- BN-whisper eligibility: the resolved Battle.net battleTag itself must be a
-- known battletag (reverse RosterAlts index). Nil-guarded via GetReverseBattletagSet.
local function IsKnownBattletag(battleTag)
    if not battleTag then return false end
    return GetReverseBattletagSet()[battleTag:lower()] == true
end

---------------------------------------------------------------------------
-- Event frame -- Core.lua mainFrame pattern
---------------------------------------------------------------------------

local inviteFrame = nil
if not inviteFrame then inviteFrame = CreateFrame("Frame") end

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

-- Convert-before-6th-invite: a 5-man party must become a raid
-- before a 6th unit can be invited into it.
local function ConvertToRaidIfNeeded()
    if not IsInRaid() and GetNumGroupMembers() == 5 then
        C_PartyInfo.ConvertToRaid()
    end
end

-- Midnight (12.x) secret values: a chat sender's identity is SECRET whenever the
-- sender is not the player, their pet/vehicle or a party/raid member -- in combat
-- and generally on instanced maps. Tainted code may store, pass and concatenate a
-- secret, but comparing it (`sender == ""`), indexing it (`msg:lower()`) or using
-- it as a table key (`keywordSet[msg]`) raises on the spot. The guard therefore
-- covers EVERY payload field this handler touches and runs before the first of
-- those uses: the previous per-field guards sat one line BELOW their own field's
-- first comparison and so could never fire. Do NOT gate this on
-- InCombatLockdown() -- combat is the common trigger, not the condition.
function inviteFrame:CHAT_MSG_WHISPER(event, msg, sender, ...)
    -- Chat payload's GUID is arg12 overall; the trampoline already consumed
    -- msg and sender as named params, so it is the 10th vararg here.
    local senderGUID = select(10, ...)
    if Private.HasSecretValue(msg, sender, senderGUID) then return end

    if not sender or sender == "" then return end
    if CanonicalizeLiveName(sender) == Private:GetSelfKey() then return end

    msg = tostring(msg or ""):lower():trim()
    if not keywordSet[msg] then return end

    -- INVTOOL-01/02: unconditional leader/assist/solo + LFG-queue guard.
    if not CanIssueInvites() then return end

    if not IsInviteEligible(sender, senderGUID) then return end

    ConvertToRaidIfNeeded()
    C_PartyInfo.InviteUnit(sender)
end

-- BN whisper payload: senderBnetIDAccount is positional arg 13 counting from msg=1; our trampoline
-- already consumes msg/sender as named params, so it is the 11th vararg here.
function inviteFrame:CHAT_MSG_BN_WHISPER(event, msg, sender, ...)
    -- Same whole-payload guard as CHAT_MSG_WHISPER above. bnSenderID needs it
    -- too: it is compared against accountInfo.bnetAccountID further down, which
    -- is just as forbidden on a secret as the sender comparison was.
    local bnSenderID = select(11, ...)
    if Private.HasSecretValue(msg, sender, bnSenderID) then return end

    msg = tostring(msg or ""):lower():trim()
    if not keywordSet[msg] then return end

    -- INVTOOL-01/02: unconditional leader/assist/solo + LFG-queue guard.
    if not CanIssueInvites() then return end

    if not bnSenderID then return end

    local _, bnCount = BNGetNumFriends()
    for friendIndex = 1, (bnCount or 0) do
        local accountInfo = C_BattleNet.GetFriendAccountInfo(friendIndex)
        if accountInfo and accountInfo.bnetAccountID == bnSenderID then
            -- INVTOOL-03: the loop already iterates the player's OWN Battle.net
            -- friend list, so a resolved match is a friend by construction --
            -- no GUID needed on this branch.
            local cfg = WildThingsSavedVars and WildThingsSavedVars.invite
            if IsKnownBattletag(accountInfo.battleTag) or (cfg and cfg.whisper_from_friends) then
                -- WR-01 review fix: only convert party->raid once eligibility is confirmed,
                -- consistent with CHAT_MSG_WHISPER's IsInviteEligible-before-convert order.
                ConvertToRaidIfNeeded()

                local numGameAccounts = C_BattleNet.GetFriendNumGameAccounts(friendIndex) or 0
                for accountIndex = 1, numGameAccounts do
                    local gameAccountInfo = C_BattleNet.GetFriendGameAccountInfo(friendIndex, accountIndex)
                    if gameAccountInfo
                        and gameAccountInfo.clientProgram == BNET_CLIENT_WOW
                        and gameAccountInfo.isInCurrentRegion
                        and gameAccountInfo.wowProjectID == WOW_PROJECT_MAINLINE then
                        BNInviteFriend(gameAccountInfo.gameAccountID)
                    end
                end
            end
            break
        end
    end
end

-- INVTOOL-04/05/06: auto-accepts an incoming party invite when
-- ShouldAutoAcceptInvite says yes, then dismisses the native popup.
-- PARTY_INVITE_REQUEST's payload is
-- name, isTank, isHealer, isDamage, isNativeRealm, allowMultipleRoles,
-- inviterGUID, questSessionActive; our trampoline already consumed name as
-- the named parameter, so the inviter's GUID is the 6th vararg.
function inviteFrame:PARTY_INVITE_REQUEST(event, name, ...)
    -- `name == ""` carried the identical ordering defect as CHAT_MSG_WHISPER's
    -- line 199 -- guard below its own field's first comparison. Unobserved only
    -- because auto-accept is off by default, not because this path was safe.
    local inviterGUID = select(6, ...)
    if Private.HasSecretValue(name, inviterGUID) then return end

    if not name or name == "" then return end

    if not ShouldAutoAcceptInvite(name, inviterGUID) then return end

    AcceptGroup()

    -- Pitfall 1, the sharpest part of this feature: StaticPopupDialogs'
    -- PARTY_INVITE/PARTY_INVITE_XREALM OnHide calls DeclineGroup() unless
    -- dialog.inviteAccepted is set first -- hiding without it cancels the
    -- accept made one line above and leaves the player in no group at all.
    -- Both dialogs must be handled: the same-realm dialog alone would leave a
    -- dangling popup for every cross-realm inviter, which is most of them.
    StaticPopup_ForEachShownDialog(function(dialog)
        if dialog.which == "PARTY_INVITE" or dialog.which == "PARTY_INVITE_XREALM" then
            dialog.inviteAccepted = 1
            StaticPopup_Hide(dialog.which)
        end
    end)
end

-- Rebuilds the guild-member and rank-by-key indexes used by whisper eligibility
-- and autopromote-by-rank. GetNumGuildMembers() can be 0 right after login before
-- the guild roster has streamed in; InitializeInvite requests a fresh roster via
-- C_GuildInfo.GuildRoster() and this handler simply rebuilds whenever it fires.
function inviteFrame:GUILD_ROSTER_UPDATE(event)
    wipe(guildMemberKeys)
    wipe(guildRankByKey)

    local numMembers = GetNumGuildMembers() or 0
    for i = 1, numMembers do
        local name, _, rankIndex = GetGuildRosterInfo(i)
        if name then
            local key = CanonicalizeLiveName(name)
            guildMemberKeys[key] = true
            guildRankByKey[key] = rankIndex + 1
        end
    end
end

function inviteFrame:GROUP_ROSTER_UPDATE(event)
    local cfg = WildThingsSavedVars.invite

    -- Coalesce rapid roster ticks into a single autopromote pass.
    if cfg.autopromote and not promoteScheduled then
        promoteScheduled = true
        wt:ScheduleTimer(function()
            promoteScheduled = false
            Private:RunAutopromote()
        end, 2)
    end

    -- Difficulty re-apply session gate: fires once per raid
    -- formation, not every roster tick.
    if cfg.auto_raid_diff and not diffScheduled then
        diffScheduled = true
        wt:ScheduleTimer(function()
            diffScheduled = false
            Private:AutoRaidSetup()
        end, 0.5)
    end
end

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

---------------------------------------------------------------------------
-- Public API (Private:*) -- called from InviteScreen.lua and WildThings.lua
---------------------------------------------------------------------------

-- Idempotent (un)register: unregisters everything first, then registers exactly
-- what the current master-toggle config requires. Safe to call repeatedly (the
-- screen calls this on every master-toggle change).
function Private:RefreshInviteEvents()
    inviteFrame:UnregisterAllEvents()

    local cfg = WildThingsSavedVars.invite

    if cfg.by_whisper then
        inviteFrame:RegisterEvent("CHAT_MSG_WHISPER")
        inviteFrame:RegisterEvent("CHAT_MSG_BN_WHISPER")
    end

    if cfg.autopromote or cfg.auto_raid_diff then
        inviteFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
    end

    -- INVTOOL-04/05: auto-accept registers under EITHER accept toggle (the two
    -- sources are independent -- see ShouldAutoAcceptInvite's comment).
    if cfg.accept_from_friends or cfg.accept_from_guild then
        inviteFrame:RegisterEvent("PARTY_INVITE_REQUEST")
    end

    -- CR-01 review fix: guild-member whisper eligibility (IsInviteEligible reads
    -- guildMemberKeys, populated only here) must not depend on autopromote being on.
    -- INVTOOL-05: extended with accept_from_guild -- the guild-accept branch in
    -- ShouldAutoAcceptInvite reads the same guildMemberKeys index, so a
    -- guild-only accept configuration is dead without this registration too.
    if cfg.by_whisper or cfg.autopromote or cfg.accept_from_guild then
        inviteFrame:RegisterEvent("GUILD_ROSTER_UPDATE")
    end
end

-- Rebuilds keywordSet from WildThingsSavedVars.invite.words. Called on init and
-- whenever the Keywords EditBox changes. Whole-message match: "inv" matches the
-- default word list, "inv me" does not.
function Private:RebuildInviteKeywords()
    wipe(keywordSet)
    for word in (WildThingsSavedVars.invite.words or ""):gmatch("%S+") do
        keywordSet[word:lower()] = true
    end
end

-- Ordered array of guild rank names (1-indexed, matching GetGuildRosterInfo's
-- rankIndex+1 convention). Returns an empty table -- never errors -- when guild
-- data is not yet available (Pitfall 3).
function Private:GetInviteGuildRanks()
    local ranks = {}
    if not IsInGuild() then return ranks end

    local numRanks = GuildControlGetNumRanks() or 0
    for i = 1, numRanks do
        ranks[i] = GuildControlGetRankName(i) or ""
    end
    return ranks
end

-- Persists the selection and applies it immediately when allowed: skipped inside
-- a raid instance (Pitfall 2), otherwise applied when solo or when the local
-- player is the raid leader. Always persists even when the live call is skipped
-- so the convert-to-raid re-apply (AutoRaidSetup) still has the right value.
function Private:SetInviteDifficulty(id)
    WildThingsSavedVars.invite.raid_diff = id

    local _, zoneType = IsInInstance()
    if zoneType == "raid" then return end

    if not IsInGroup() or UnitIsGroupLeader("player") then
        SetRaidDifficultyID(id)
    end
end

-- Autopromote to Raid-Assistant (T-pj1-03 mitigation): gated on being raid
-- leader; promotes rank-0 (plain member) raid members matching, in order, (a)
-- the promote-names list, (b) a selected guild rank, or (c) a listed battletag
-- (RosterAlts-resolved). demotedPlayers suppresses re-promoting a manually
-- demoted player for the rest of this session.
function Private:RunAutopromote()
    if not (IsInRaid() and UnitIsGroupLeader("player")) then return end

    local cfg = WildThingsSavedVars.invite
    local promoteNames = BuildWordSet(cfg.promote_names)
    local promoteBnets = BuildWordSet(cfg.promote_bnets)
    local promoteRanks = cfg.promote_ranks or {}
    local charToBattletag = Private.RosterAlts and Private.RosterAlts.charToBattletag

    local numMembers = GetNumGroupMembers() or 0
    for i = 1, numMembers do
        local name, rank = GetRaidRosterInfo(i)
        if name and rank == 0 then
            local key = CanonicalizeLiveName(name)
            if not demotedPlayers[key] then
                local shouldPromote = false

                -- (a) name match, tolerating both "Name" and "Name-Realm" forms.
                local bareName = name:match("^(.-)%-") or name
                if promoteNames[name:lower()] or promoteNames[bareName:lower()] then
                    shouldPromote = true
                end

                -- (b) guild rank match.
                if not shouldPromote then
                    local rankIndex1 = guildRankByKey[key]
                    if rankIndex1 and promoteRanks[rankIndex1] then
                        shouldPromote = true
                    end
                end

                -- (c) battletag match (nil-guarded RosterAlts read).
                if not shouldPromote and charToBattletag then
                    local battleTag = charToBattletag[key]
                    if battleTag and promoteBnets[battleTag:lower()] then
                        shouldPromote = true
                    end
                end

                if shouldPromote then
                    C_PartyInfo.PromoteToAssistant(name, true)
                end
            end
        end
    end
end

-- Difficulty re-apply session gate: fires exactly once per raid
-- formation (i.e. on convert-to-raid), not on every roster tick. Clears the
-- session flag whenever the raid disbands so the next raid re-applies too.
function Private:AutoRaidSetup()
    local inRaid = IsInRaid()
    local _, zoneType = IsInInstance()

    if zoneType == "raid" then return end

    if inRaid and not raidDiffSessionApplied then
        if UnitIsGroupLeader("player") then
            raidDiffSessionApplied = true
            SetRaidDifficultyID(WildThingsSavedVars.invite.raid_diff)
        end
    elseif not inRaid and raidDiffSessionApplied then
        raidDiffSessionApplied = false
    end
end

-- Builds the keyword set, installs the DemoteAssistant hook once, requests a
-- fresh guild roster, and registers whichever events the current config needs.
-- Called once from wt:OnInitialize after Private:InitializeTransmission().
function Private:InitializeInvite()
    Private:RebuildInviteKeywords()

    if not demoteHooked then
        demoteHooked = true
        -- C_PartyInfo.DemoteAssistant(name, exactMatch) -- name-based, matching this
        -- codebase's own convention (RosterArrange.lua:170). The legacy global
        -- DemoteAssistant(unit) takes a unit token instead; see the hook below.
        hooksecurefunc(C_PartyInfo, "DemoteAssistant", function(name)
            if not name or name == "" then return end
            demotedPlayers[CanonicalizeLiveName(name)] = true
        end)

        -- WR-03 review fix: Blizzard's stock raid-frame "Demote" button may still call the
        -- legacy unit-token global DemoteAssistant(unit) instead of the modern C_PartyInfo
        -- API. Nil-guarded -- absent entirely on clients where it has been fully removed.
        if DemoteAssistant then
            hooksecurefunc("DemoteAssistant", function(unit)
                if not unit then return end
                local name = UnitName(unit)
                if not name or name == "" then return end
                demotedPlayers[CanonicalizeLiveName(name)] = true
            end)
        end
    end

    if C_GuildInfo and C_GuildInfo.GuildRoster then
        C_GuildInfo.GuildRoster()
    end

    Private:RefreshInviteEvents()
end

-- Private:InviteToolsTest(unitOrName): INVTOOL-07 unadvertised /wt invtest
-- solo harness, the AutoResAcceptTest idiom. Invite.lua ships in every build
-- so this stays ungated; it only reads local state and prints -- crossing no
-- trust boundary and sending nothing. The optional argument is what makes the
-- friend/guild branches reachable without a second player: target a real
-- friend or guildmate and run `/wt invtest target` to evaluate every
-- predicate against a real GUID. Calls none of C_PartyInfo.InviteUnit,
-- BNInviteFriend or AcceptGroup.
function Private:InviteToolsTest(unitOrName)
    local target = unitOrName or "player"
    local name = UnitName(target) or target
    local guid = UnitGUID(target)

    local canIssue, canIssueReason = CanIssueInvites()
    local eligible = IsInviteEligible(name, guid)
    local accept, acceptReason = ShouldAutoAcceptInvite(name, guid)
    local isFriend = IsFriendOfMine(guid)
    local inLFGQueue = IsInLFGQueue()
    local inGroup = IsInGroup()
    local cfg = WildThingsSavedVars and WildThingsSavedVars.invite or {}

    print("|cff99ff00Wild Things:|r invtest -- name=" .. tostring(name)
        .. " guid=" .. tostring(guid)
        .. " inGroup=" .. tostring(inGroup)
        .. " inLFGQueue=" .. tostring(inLFGQueue)
        .. " isFriend=" .. tostring(isFriend))
    print("|cff99ff00Wild Things:|r invtest -- CanIssueInvites=" .. tostring(canIssue)
        .. " reason=" .. tostring(canIssueReason)
        .. " | IsInviteEligible=" .. tostring(eligible)
        .. " | ShouldAutoAcceptInvite=" .. tostring(accept)
        .. " reason=" .. tostring(acceptReason))
    print("|cff99ff00Wild Things:|r invtest -- whisper_from_friends=" .. tostring(cfg.whisper_from_friends)
        .. " accept_from_friends=" .. tostring(cfg.accept_from_friends)
        .. " accept_from_guild=" .. tostring(cfg.accept_from_guild)
        .. " accept_only_when_solo=" .. tostring(cfg.accept_only_when_solo))
end
