---@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

-- 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). Every Private.RosterAlts access below is
-- nil-guarded so this degrades cleanly to guild-only in a packaged build.
local function IsInviteEligible(rawName)
    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
    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

function inviteFrame:CHAT_MSG_WHISPER(event, msg, sender, ...)
    if not sender or sender == "" then return end
    if issecretvalue and issecretvalue(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

    if not IsInviteEligible(sender) 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, ...)
    if issecretvalue and issecretvalue(sender) then return end

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

    local bnSenderID = select(11, ...)
    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
            if IsKnownBattletag(accountInfo.battleTag) 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

-- 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

    -- CR-01 review fix: guild-member whisper eligibility (IsInviteEligible reads
    -- guildMemberKeys, populated only here) must not depend on autopromote being on.
    if cfg.by_whisper or cfg.autopromote 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
