local addonName, L = ...

local frame = CreateFrame("Frame")
local pendingCast = nil
local lastAcceptedRequest = nil
local recentMessages = {}
local recentAutoRequests = {}
local recentPIReceives = {}
local messageStampCounter = 0
local piAuraActive = false
local piAuraInstanceID = nil
local playerState = {
    guid = nil,
    name = nil,
    fullName = nil,
    classTag = nil,
}

local function DPrint(message)
    local db = L:GetDB()
    if db and db.debugExperimentalPI then
        L:Print("[PI] " .. tostring(message))
    end
end

local function GetFullPlayerName(unit)
    local name, realm = UnitName(unit)
    if not name then
        return nil
    end

    if realm and realm ~= "" then
        return name .. "-" .. realm
    end

    local myRealm = GetRealmName()
    if myRealm and myRealm ~= "" then
        myRealm = myRealm:gsub("%s+", "")
        return name .. "-" .. myRealm
    end

    return name
end

local function NormalizeName(name)
    if not name or name == "" then
        return nil
    end

    name = tostring(name)
    name = name:gsub("%s+", "")
    return string.lower(name)
end

local function NormalizeWhisperText(text)
    text = tostring(text or "")
    text = text:gsub("^%s+", ""):gsub("%s+$", "")
    return string.lower(text)
end

local function IsExperimentalPIEnabled()
    local db = L:GetDB()
    return db and db.enableExperimentalPI == true
end

local function GetPIConfig()
    return L:GetDB() or {}
end

local function IsPriest()
    return playerState.classTag == "PRIEST"
end

local function EscapeValue(value)
    local text = tostring(value or "")
    text = text:gsub("%%", "%%25")
    text = text:gsub(";", "%%3B")
    text = text:gsub("=", "%%3D")
    return text
end

local function UnescapeValue(value)
    local text = tostring(value or "")
    text = text:gsub("%%3D", "=")
    text = text:gsub("%%3B", ";")
    text = text:gsub("%%25", "%%")
    return text
end

local function BuildPayload(fields)
    -- Keep the transport format intentionally tiny/plain so it works over the
    -- addon-message channel without needing external serializers.
    local parts = {}
    for _, key in ipairs({ "kind", "senderName", "senderGUID", "targetGUID", "targetName", "stamp" }) do
        if fields[key] ~= nil then
            parts[#parts + 1] = key .. "=" .. EscapeValue(fields[key])
        end
    end
    return table.concat(parts, ";")
end

local function ParsePayload(message)
    if not message or message == "" then
        return nil
    end

    local data = {}
    for token in string.gmatch(message, "[^;]+") do
        local key, value = token:match("^([^=]+)=(.*)$")
        if key then
            data[key] = UnescapeValue(value)
        end
    end

    if data.kind ~= "PI" and data.kind ~= "PIREQ" then
        return nil
    end

    if data.kind == "PIREQ" then
        data.targetGUID = nil
        data.targetName = nil
    end

    return data
end

local function MakeDedupKey(kind, senderGUID, stamp)
    return tostring(kind or "?") .. ":" .. tostring(senderGUID or "?") .. ":" .. tostring(stamp or "?")
end

local function CleanupRecentMessages()
    local now = GetTime()
    for key, timestamp in pairs(recentMessages) do
        if now - timestamp > 10 then
            recentMessages[key] = nil
        end
    end
end

local function CleanupRecentAutoRequests()
    local now = GetTime()
    for key, timestamp in pairs(recentAutoRequests) do
        if now - timestamp > 2 then
            recentAutoRequests[key] = nil
        end
    end
end

local function CleanupRecentPIReceives()
    local now = GetTime()
    for key, timestamp in pairs(recentPIReceives) do
        if now - timestamp > 2 then
            recentPIReceives[key] = nil
        end
    end
end

local function BuildMessageStamp()
    -- Combine server time, precise local time, and a local counter so messages from
    -- the same second can still be deduplicated reliably on receipt.
    messageStampCounter = messageStampCounter + 1
    return string.format("%d:%d:%d", GetServerTime() or time(), math.floor((GetTimePreciseSec() or GetTime()) * 1000), messageStampCounter)
end

local function FindPlayerAuraBySpellID(spellID, filter)
    if C_UnitAuras and C_UnitAuras.GetPlayerAuraBySpellID and (filter == nil or filter == "HELPFUL") then
        local aura = C_UnitAuras.GetPlayerAuraBySpellID(spellID)
        if aura then
            local auraInstanceID = aura.auraInstanceID
            if auraInstanceID and C_Secrets and C_Secrets.ShouldUnitAuraInstanceBeSecret and C_Secrets.ShouldUnitAuraInstanceBeSecret("player", auraInstanceID) then
                return nil
            end
            return aura
        end
    end

    local index = 1
    while true do
        local aura = C_UnitAuras.GetAuraDataByIndex("player", index, filter or "HELPFUL")
        if not aura then
            return nil
        end

        local auraInstanceID = aura.auraInstanceID
        if auraInstanceID and C_Secrets and C_Secrets.ShouldUnitAuraInstanceBeSecret and C_Secrets.ShouldUnitAuraInstanceBeSecret("player", auraInstanceID) then
            index = index + 1
        elseif aura.spellId == spellID then
            return aura
        else
            index = index + 1
        end
    end
end

local function GetCurrentPIAura()
    return FindPlayerAuraBySpellID(L.PI_SPELL_ID, "HELPFUL")
end

local function SyncPIAuraState()
    local aura = GetCurrentPIAura()
    piAuraActive = aura ~= nil
    piAuraInstanceID = aura and aura.auraInstanceID or nil
    return aura
end

local function IsFreshPIAura(aura)
    if not aura then
        return false
    end

    local duration = tonumber(aura.duration)
    local expirationTime = tonumber(aura.expirationTime)
    if not duration or not expirationTime or duration <= 0 or expirationTime <= 0 then
        return false
    end

    local remaining = expirationTime - GetTime()
    local age = duration - remaining
    return age >= 0 and age <= 1.5
end

local function GetPIRequestTimeoutSeconds()
    local db = L:GetDB()
    local seconds = db and tonumber(db.piRequestTimeoutSeconds) or 8
    return math.max(1, seconds or 8)
end

local function GetPIGracePeriodSeconds()
    local db = L:GetDB()
    if not db or db.piRequestGracePeriodEnabled ~= true then
        return 0
    end

    return math.max(0, tonumber(db.piRequestGracePeriodSeconds) or 5)
end

local function GetSpellCooldownRemaining(spellID)
    if C_Spell and C_Spell.GetSpellCooldown then
        local info = C_Spell.GetSpellCooldown(spellID)
        if info then
            if info.isEnabled == false then
                return math.huge
            end

            local startTime = tonumber(info.startTime) or 0
            local duration = tonumber(info.duration) or 0
            local modRate = tonumber(info.modRate) or 1
            if startTime <= 0 or duration <= 0 then
                return 0
            end

            return math.max(0, (startTime + (duration / math.max(modRate, 0.0001))) - GetTime())
        end
    end

    local startTime, duration, enabled, modRate = GetSpellCooldown(spellID)
    if enabled == 0 then
        return math.huge
    end

    startTime = tonumber(startTime) or 0
    duration = tonumber(duration) or 0
    modRate = tonumber(modRate) or 1
    if startTime <= 0 or duration <= 0 then
        return 0
    end

    return math.max(0, (startTime + (duration / math.max(modRate, 0.0001))) - GetTime())
end

local function GetPICooldownRemaining()
    return GetSpellCooldownRemaining(L.PI_SPELL_ID)
end

local function CanPriestAcceptPIRequest()
    local db = L:GetDB()
    if not db or db.piRequestRespectCooldown == false then
        return true
    end

    local remaining = GetPICooldownRemaining()
    if remaining <= 0 then
        return true
    end

    local graceSeconds = GetPIGracePeriodSeconds()
    if graceSeconds > 0 and remaining <= graceSeconds then
        DPrint("PIREQ accepted during grace period. cooldownRemaining=" .. string.format("%.2f", remaining) .. " grace=" .. string.format("%.2f", graceSeconds))
        return true
    end

    DPrint("PIREQ ignored because PI is on cooldown. cooldownRemaining=" .. string.format("%.2f", remaining) .. " grace=" .. string.format("%.2f", graceSeconds))
    return false
end

local function GetActiveAcceptedRequest()
    if not lastAcceptedRequest then
        return nil
    end
    if GetTime() >= (lastAcceptedRequest.expiresAt or 0) then
        DPrint("Accepted PIREQ fallback expired.")
        lastAcceptedRequest = nil
        return nil
    end
    return lastAcceptedRequest
end

local function RememberAcceptedRequest(data, sender)
    -- Cache the accepted request briefly so a later PI cast can still be mapped back
    -- to the requester even if target resolution on cast success is incomplete.
    local requesterName = data.senderName
    if not requesterName or requesterName == "" then
        requesterName = sender or "Player"
    end

    lastAcceptedRequest = {
        senderGUID = data.senderGUID,
        senderName = requesterName,
        time = GetTime(),
        expiresAt = GetTime() + GetPIRequestTimeoutSeconds(),
    }

    DPrint("Remembered accepted PIREQ fallback requester=" .. tostring(lastAcceptedRequest.senderName) .. " guid=" .. tostring(lastAcceptedRequest.senderGUID))
end

local function RegisterPrefix()
    if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
        local ok = C_ChatInfo.RegisterAddonMessagePrefix(L.PI_COMM_PREFIX)
        if ok then
            DPrint("Registered addon prefix " .. tostring(L.PI_COMM_PREFIX))
        else
            L:Print("Power Infusion prefix registration failed.")
        end
    end
end

local function GetPreferredChannel()
    if IsInRaid() then
        return "RAID"
    end
    if IsInGroup() then
        return "PARTY"
    end
    return nil
end

local function SendMessageSafe(message, channel, target)
    if not (C_ChatInfo and C_ChatInfo.SendAddonMessage) then
        DPrint("SendAddonMessage unavailable.")
        return false
    end

    if C_ChatInfo.InChatMessagingLockdown and C_ChatInfo.InChatMessagingLockdown() then
        DPrint("Chat Messaging Lockdown active. Message skipped.")
        return false
    end

    local ok, err = pcall(C_ChatInfo.SendAddonMessage, L.PI_COMM_PREFIX, message, channel, target)
    if not ok then
        DPrint("SendAddonMessage failed: " .. tostring(err))
        return false
    end

    DPrint("Sent addon message via " .. tostring(channel) .. (target and (" to " .. tostring(target)) or "") .. ": " .. tostring(message))
    return true
end

local function InitPlayerState()
    playerState.guid = UnitGUID("player")
    playerState.name = UnitName("player")
    playerState.fullName = GetFullPlayerName("player")
    _, playerState.classTag = UnitClass("player")
    DPrint("Player state updated class=" .. tostring(playerState.classTag) .. " name=" .. tostring(playerState.fullName) .. " guid=" .. tostring(playerState.guid))
end

local function IsGUIDInCurrentGroup(guid)
    if not guid or guid == "" then
        return false
    end

    if UnitGUID("player") == guid then
        return true
    end

    if IsInRaid() then
        for index = 1, GetNumGroupMembers() do
            if UnitGUID("raid" .. index) == guid then
                return true
            end
        end
        return false
    end

    if IsInGroup() then
        for index = 1, GetNumSubgroupMembers() do
            if UnitGUID("party" .. index) == guid then
                return true
            end
        end
    end

    return false
end

local function DoesSenderGUIDMatchFocus(senderGUID)
    if not senderGUID or senderGUID == "" then
        return false
    end
    if not UnitExists("focus") then
        return false
    end
    return UnitGUID("focus") == senderGUID
end

local function IterateCurrentGroupUnits()
    local units = { "player" }

    if IsInRaid() then
        for index = 1, GetNumGroupMembers() do
            units[#units + 1] = "raid" .. index
        end
    elseif IsInGroup() then
        for index = 1, GetNumSubgroupMembers() do
            units[#units + 1] = "party" .. index
        end
    end

    return units
end

local function FindCurrentGroupUnit(senderGUID, senderName)
    local normalizedSender = NormalizeName(senderName)

    for _, unit in ipairs(IterateCurrentGroupUnits()) do
        if UnitExists(unit) then
            if senderGUID and senderGUID ~= "" and UnitGUID(unit) == senderGUID then
                return unit
            end

            local fullName = GetFullPlayerName(unit)
            local shortName = UnitName(unit)
            if normalizedSender and ((fullName and NormalizeName(fullName) == normalizedSender) or (shortName and NormalizeName(shortName) == normalizedSender)) then
                return unit
            end
        end
    end

    return nil
end

local function IsConfiguredRequesterUnit(unit)
    local db = GetPIConfig()
    if not unit or not UnitExists(unit) then
        return false
    end

    if db.piRequestAllowFocus ~= false and UnitExists("focus") and UnitIsUnit(unit, "focus") then
        return true
    end

    local configuredName = NormalizeName(db.piRequestAllowedPlayerName)
    if configuredName then
        local fullName = GetFullPlayerName(unit)
        local shortName = UnitName(unit)
        if (fullName and NormalizeName(fullName) == configuredName) or (shortName and NormalizeName(shortName) == configuredName) then
            return true
        end
    end

    return false
end

local function IsWhisperRequestEnabled()
    local db = GetPIConfig()
    return db.piRequestAcceptWhispers == true
end

local function IsSpellCastRequestEnabled()
    local db = GetPIConfig()
    return db.piRequestAcceptSpellCasts ~= false
end

local function GetConfiguredWhisperPhrases()
    local db = GetPIConfig()
    local phrase = tostring(db.piRequestWhisperPhrase or "")
    if phrase == "" then
        phrase = L.DEFAULT_PI_WHISPER_PHRASE
    end

    local phrases = {}
    local seen = {}
    for token in string.gmatch(phrase, "([^;]+)") do
        local normalized = NormalizeWhisperText(token)
        if normalized ~= "" and not seen[normalized] then
            seen[normalized] = true
            phrases[#phrases + 1] = normalized
        end
    end

    if #phrases == 0 then
        phrases[1] = NormalizeWhisperText(L.DEFAULT_PI_WHISPER_PHRASE)
    end

    return phrases
end

local function IsConfiguredWhisperPhraseMatch(message)
    local normalizedMessage = NormalizeWhisperText(message)
    if normalizedMessage == "" then
        return false
    end

    for _, phrase in ipairs(GetConfiguredWhisperPhrases()) do
        if normalizedMessage == phrase then
            return true
        end
    end

    return false
end

local function IsConfiguredAutoPIRequestSpell(spellID)
    if not spellID or spellID <= 0 then
        return false
    end

    for _, entry in ipairs(L:GetPIRequestSpellEntries()) do
        if entry.enabled ~= false and entry.spellID == spellID then
            return true
        end
    end

    return false
end

local function IsCooldownReplyEnabled()
    local db = GetPIConfig()
    return db.piRequestCooldownReplyEnabled == true
end

local function BuildCooldownReplyText(secondsRemaining)
    local db = GetPIConfig()
    local template = tostring(db.piRequestCooldownReplyText or "")
    if template == "" then
        template = "Power Infusion ready in {time_left}s."
    end

    local rounded = math.max(1, math.ceil(tonumber(secondsRemaining) or 0))
    return template:gsub("{time_left}", tostring(rounded))
end

local function SendWhisperToPlayer(targetName, message)
    if not targetName or targetName == "" or not message or message == "" then
        return false
    end

    local ok, err = pcall(SendChatMessage, message, "WHISPER", nil, targetName)
    if not ok then
        DPrint("Cooldown whisper failed for " .. tostring(targetName) .. ": " .. tostring(err))
        return false
    end

    DPrint("Cooldown whisper sent to " .. tostring(targetName) .. ": " .. tostring(message))
    return true
end

local function TrySendCooldownReply(targetName)
    if not IsCooldownReplyEnabled() then
        return false
    end

    local db = GetPIConfig()
    if db.piRequestRespectCooldown == false then
        return false
    end

    local remaining = GetPICooldownRemaining()
    local graceSeconds = GetPIGracePeriodSeconds()
    if remaining <= 0 or (graceSeconds > 0 and remaining <= graceSeconds) then
        return false
    end

    return SendWhisperToPlayer(targetName, BuildCooldownReplyText(remaining))
end

local function IsPartyOrRaidInstance()
    local inInstance, instanceType = IsInInstance()
    return inInstance == true and (instanceType == "party" or instanceType == "raid")
end

local function GetUnitFactionToken(unit)
    if not unit or not UnitExists(unit) then
        return nil
    end

    local _, token = UnitFactionGroup(unit)
    return token
end

local function IsCrossFactionPIBlocked(priestUnit, requesterUnit)
    local priestFaction = GetUnitFactionToken(priestUnit)
    local requesterFaction = GetUnitFactionToken(requesterUnit)

    if not priestFaction or not requesterFaction then
        return false
    end
    if priestFaction == requesterFaction then
        return false
    end
    if IsPartyOrRaidInstance() then
        return false
    end

    DPrint("Cross-faction PI blocked outside party/raid instance. priestFaction=" .. tostring(priestFaction) .. " requesterFaction=" .. tostring(requesterFaction))
    return true
end

local function IsFocusedPriestCrossFactionBlocked()
    if not UnitExists("focus") then
        return false
    end

    local _, focusClassTag = UnitClass("focus")
    if focusClassTag ~= "PRIEST" then
        return false
    end

    return IsCrossFactionPIBlocked("focus", "player")
end

local function ShouldTriggerPIForMe(data)
    if not data or data.kind ~= "PI" then
        return false
    end

    -- Self-cast PI already triggers a local receiver alert on cast success.
    -- Ignore our own echoed addon payload so the sound/text/image do not fire twice.
    if data.senderGUID and data.senderGUID ~= "" and playerState.guid and data.senderGUID == playerState.guid then
        if data.targetGUID and data.targetGUID ~= "" and data.targetGUID == playerState.guid then
            DPrint("PI self-payload ignored because local self-cast alert already ran.")
            return false
        end

        local myShort = NormalizeName(playerState.name)
        local myFull = NormalizeName(playerState.fullName)
        local targetName = NormalizeName(data.targetName)
        if targetName and (targetName == myShort or targetName == myFull) then
            DPrint("PI self-payload ignored by name because local self-cast alert already ran.")
            return false
        end
    end

    if data.targetGUID and data.targetGUID ~= "" and playerState.guid and data.targetGUID == playerState.guid then
        DPrint("PI matched by GUID.")
        return true
    end

    local myShort = NormalizeName(playerState.name)
    local myFull = NormalizeName(playerState.fullName)
    local targetName = NormalizeName(data.targetName)

    DPrint("PI match check targetGUID=" .. tostring(data.targetGUID) .. " myGUID=" .. tostring(playerState.guid) .. " targetName=" .. tostring(targetName) .. " myShort=" .. tostring(myShort) .. " myFull=" .. tostring(myFull))

    if targetName and (targetName == myShort or targetName == myFull) then
        DPrint("PI matched by name fallback.")
        return true
    end

    DPrint("PI did not match this player.")
    return false
end

local function ShouldTriggerPIRequestForPriest(data)
    -- Request alerts are intentionally conservative: only priests, only group members,
    -- only configured requesters, and only when the cast would actually be possible.
    if not data or data.kind ~= "PIREQ" then
        return false
    end
    if not IsPriest() then
        DPrint("PIREQ ignored because player is not priest.")
        return false
    end
    if not IsGUIDInCurrentGroup(data.senderGUID) then
        DPrint("PIREQ ignored: sender is not in current group.")
        return false
    end

    local requesterUnit = FindCurrentGroupUnit(data.senderGUID, data.senderName)
    if not requesterUnit then
        DPrint("PIREQ ignored: requester unit could not be resolved from group roster.")
        return false
    end
    if not IsConfiguredRequesterUnit(requesterUnit) then
        DPrint("PIREQ ignored: requester does not match focus or configured player. requester=" .. tostring(data.senderName))
        return false
    end
    if not CanPriestAcceptPIRequest() then
        TrySendCooldownReply(data.senderName)
        return false
    end
    if IsCrossFactionPIBlocked("player", requesterUnit) then
        DPrint("PIREQ ignored: cross-faction request outside dungeon/raid instance is not castable.")
        return false
    end
    DPrint("PIREQ accepted for configured requester " .. tostring(data.senderName))
    return true
end

local function BuildPIMessage(targetName, targetGUID)
    return BuildPayload({
        kind = "PI",
        senderName = playerState.fullName or playerState.name or "",
        senderGUID = playerState.guid or "",
        targetGUID = targetGUID or "",
        targetName = targetName or "",
        stamp = BuildMessageStamp(),
    })
end

local function BuildPIRequestMessage()
    return BuildPayload({
        kind = "PIREQ",
        senderName = playerState.fullName or playerState.name or "",
        senderGUID = playerState.guid or "",
        stamp = BuildMessageStamp(),
    })
end

local function TriggerPIReceived(sourceGUID, sourceName, fallbackName)
    CleanupRecentPIReceives()

    -- A successful cast, an addon message, and the UNIT_AURA fallback can all point
    -- to the same PI gain. Deduplicate locally so the receiver alert stays singular.
    local dedupKey = tostring(sourceGUID or NormalizeName(sourceName) or NormalizeName(fallbackName) or "unknown")
    if recentPIReceives[dedupKey] then
        DPrint("PI receiver alert deduped for key=" .. tostring(dedupKey))
        return
    end
    recentPIReceives[dedupKey] = GetTime()

    local giverName = sourceName
    if not giverName or giverName == "" then
        giverName = fallbackName or "Priest"
    end
    DPrint("Triggering local PI receiver alert from " .. tostring(giverName))
    L:TriggerAlert("piTrack", {
        piGiverName = giverName,
        senderName = giverName,
    })
end

local function TriggerPIRequest(data, sender)
    local requesterName = data.senderName
    if not requesterName or requesterName == "" then
        requesterName = sender or "Player"
    end
    DPrint("Triggering local priest request alert for " .. tostring(requesterName))
    RememberAcceptedRequest(data, sender)
    L:TriggerAlert("piRequest", {
        requesterName = requesterName,
    })
end

function L:SendPIRequest()
    if not IsExperimentalPIEnabled() then
        self:Print("Experimental Power Infusion bridge is disabled.")
        return false
    end

    local channel = GetPreferredChannel()
    if not channel then
        self:Print("You are not in a party or raid.")
        DPrint("Manual PIREQ aborted because no party/raid channel is available.")
        return false
    end
    if IsFocusedPriestCrossFactionBlocked() then
        self:Print("Power Infusion request blocked: your focused priest is the opposite faction outside of a dungeon or raid.")
        DPrint("Manual PIREQ aborted due to cross-faction priest focus outside party/raid instance.")
        return false
    end

    local message = BuildPIRequestMessage()
    DPrint("Sending manual PIREQ over " .. tostring(channel))
    local ok = SendMessageSafe(message, channel)
    if ok then
        self:Print("Power Infusion request sent over " .. channel .. ".")
    else
        self:Print("Power Infusion request could not be sent.")
    end

    return ok
end

local function TrySendAutoPIRequest(spellID)
    if not IsExperimentalPIEnabled() then
        return
    end
    if not IsSpellCastRequestEnabled() then
        return
    end
    if not IsConfiguredAutoPIRequestSpell(spellID) then
        return
    end

    local channel = GetPreferredChannel()
    if not channel then
        DPrint("Auto PIREQ skipped for spellID=" .. tostring(spellID) .. " because no party/raid channel is available.")
        return
    end
    if IsFocusedPriestCrossFactionBlocked() then
        DPrint("Auto PIREQ skipped for spellID=" .. tostring(spellID) .. " due to cross-faction priest focus outside party/raid instance.")
        return
    end

    CleanupRecentAutoRequests()

    local dedupKey = tostring(playerState.guid or "?") .. ":" .. tostring(spellID)
    if recentAutoRequests[dedupKey] then
        DPrint("Auto PIREQ deduped for spellID=" .. tostring(spellID))
        return
    end

    DPrint("Auto PIREQ triggered by spellID=" .. tostring(spellID) .. " over " .. tostring(channel))
    local message = BuildPIRequestMessage()
    if SendMessageSafe(message, channel) then
        recentAutoRequests[dedupKey] = GetTime()
        DPrint("Auto PIREQ sent successfully for spellID=" .. tostring(spellID))
    else
        DPrint("Auto PIREQ failed for spellID=" .. tostring(spellID))
    end
end

local function OnWhisperRequest(msg, sender, _languageName, _channelString, _target, _flags, _unknown, _channelNumber, _channelName, _unknown2, _lineID, senderGUID)
    if not IsExperimentalPIEnabled() or not IsWhisperRequestEnabled() or not IsPriest() then
        return
    end

    if not IsConfiguredWhisperPhraseMatch(msg) then
        return
    end

    local requesterUnit = FindCurrentGroupUnit(senderGUID, sender)
    if not requesterUnit then
        DPrint("Whisper PIREQ ignored: sender is not in current group. sender=" .. tostring(sender))
        return
    end
    if not IsConfiguredRequesterUnit(requesterUnit) then
        DPrint("Whisper PIREQ ignored: sender is not an allowed requester. sender=" .. tostring(sender))
        return
    end
    if not CanPriestAcceptPIRequest() then
        TrySendCooldownReply(GetFullPlayerName(requesterUnit) or sender)
        return
    end
    if IsCrossFactionPIBlocked("player", requesterUnit) then
        DPrint("Whisper PIREQ ignored: cross-faction request outside dungeon/raid instance is not castable.")
        return
    end

    TriggerPIRequest({
        senderGUID = UnitGUID(requesterUnit),
        senderName = GetFullPlayerName(requesterUnit) or sender,
    }, sender)
end

local function TrySendPICast(castData)
    if not castData then
        DPrint("PI send skipped because pendingCast is missing.")
        return false
    end

    local message = BuildPIMessage(castData.targetName, castData.targetGUID)
    local sentAny = false

    DPrint("Trying to send PI cast targetName=" .. tostring(castData.targetName) .. " targetGUID=" .. tostring(castData.targetGUID))

    -- Prefer whispering the receiver directly, but also broadcast to the group when
    -- available so name/GUID fallbacks still work across clients and edge cases.
    if castData.targetName and castData.targetName ~= "" then
        if SendMessageSafe(message, "WHISPER", castData.targetName) then
            sentAny = true
            DPrint("PI whisper sent to " .. tostring(castData.targetName))
        else
            DPrint("PI whisper failed for " .. tostring(castData.targetName))
        end
    else
        DPrint("PI whisper skipped because no target name was captured.")
    end

    local channel = GetPreferredChannel()
    if channel then
        if SendMessageSafe(message, channel) then
            sentAny = true
            DPrint("PI group message sent over " .. channel)
        else
            DPrint("PI group message failed over " .. tostring(channel))
        end
    else
        DPrint("PI group message skipped because no party/raid channel is available.")
    end

    if not sentAny then
        DPrint("No PI message sent for this cast.")
    end

    return sentAny
end

local function NotifyPriestSelfPI()
    DPrint("Triggering priest self PI receiver alert.")

    if L:IsTextOverlayShown("piRequest") then
        DPrint("Hiding priest request text after PI cast.")
        L:HideTextOverlay("piRequest")
    end

    local context = {
        piGiverName = playerState.fullName or playerState.name or "Priest",
        senderName = playerState.fullName or playerState.name or "Priest",
    }

    if not IsInGroup() then
        -- Solo self-casts have no receiver peer to notify, so keep the local alert
        -- lightweight and skip the extra PI track text countdown.
        DPrint("Priest self PI detected while solo. Playing audio plus optional image without text.")
        L:PlayCurrentAlert("piTrack", context)
        if L:GetProfileDBValue("piTrack", "useImageOverlay") == true then
            L:StartOverlayPlayback("full", false, "piTrack")
        end
        if L:IsTextOverlayShown("piTrack") then
            L:HideTextOverlay("piTrack")
        end
        return
    end

    L:TriggerAlert("piTrack", context)
end

local function OnSpellcastSent(unit, target, castGUID, spellID)
    if not IsExperimentalPIEnabled() then
        return
    end
    if unit ~= "player" or spellID ~= L.PI_SPELL_ID or not IsPriest() then
        return
    end

    local targetName = target
    local targetGUID = nil

    if UnitExists("target") then
        local currentTargetName = GetFullPlayerName("target")
        if currentTargetName and targetName and NormalizeName(currentTargetName) == NormalizeName(targetName) then
            targetGUID = UnitGUID("target")
            targetName = currentTargetName
            DPrint("PI SENT matched current target unit. targetName=" .. tostring(targetName) .. " targetGUID=" .. tostring(targetGUID))
        else
            DPrint("PI SENT current target unit did not match cast target. currentTarget=" .. tostring(currentTargetName) .. " castTarget=" .. tostring(targetName))
        end
    else
        DPrint("PI SENT had no current target unit. cast target=" .. tostring(targetName))
    end

    pendingCast = {
        castGUID = castGUID,
        targetName = targetName,
        targetGUID = targetGUID,
    }

    DPrint("Prepared PI cast for castGUID=" .. tostring(castGUID) .. " targetName=" .. tostring(targetName) .. " targetGUID=" .. tostring(targetGUID))
end

local function OnSpellcastSucceeded(unit, castGUID, spellID)
    if not IsExperimentalPIEnabled() then
        return
    end
    if unit ~= "player" then
        return
    end

    TrySendAutoPIRequest(spellID)

    if spellID ~= L.PI_SPELL_ID or not IsPriest() then
        return
    end

    DPrint("PI SUCCEEDED castGUID=" .. tostring(castGUID) .. " pendingCastGUID=" .. tostring(pendingCast and pendingCast.castGUID))

    local sent = false
    if pendingCast and pendingCast.castGUID == castGUID then
        sent = TrySendPICast(pendingCast)
    else
        DPrint("PI SUCCEEDED without matching pendingCast.")
    end

    if not sent then
        local fallbackRequest = GetActiveAcceptedRequest()
        if fallbackRequest then
            DPrint("Using accepted PIREQ fallback for PI response requester=" .. tostring(fallbackRequest.senderName) .. " guid=" .. tostring(fallbackRequest.senderGUID))
            sent = TrySendPICast({
                targetName = fallbackRequest.senderName,
                targetGUID = fallbackRequest.senderGUID,
            })
            if sent then
                lastAcceptedRequest = nil
            end
        else
            DPrint("No accepted PIREQ fallback available for PI response.")
        end
    end
    pendingCast = nil

    NotifyPriestSelfPI()
end

local function OnAddonMessage(prefix, text, channel, sender, target)
    if prefix ~= L.PI_COMM_PREFIX or not IsExperimentalPIEnabled() then
        return
    end

    CleanupRecentMessages()

    DPrint("Received addon message via " .. tostring(channel) .. " from " .. tostring(sender) .. ": " .. tostring(text))

    local data = ParsePayload(text)
    if not data then
        DPrint("Could not parse PI addon payload: " .. tostring(text))
        return
    end

    local dedupKey = MakeDedupKey(data.kind, data.senderGUID, data.stamp)
    if recentMessages[dedupKey] then
        DPrint("Ignored duplicate addon payload kind=" .. tostring(data.kind) .. " senderGUID=" .. tostring(data.senderGUID) .. " stamp=" .. tostring(data.stamp))
        return
    end
    recentMessages[dedupKey] = GetTime()

    if data.kind == "PI" then
        DPrint("Processing PI payload senderName=" .. tostring(data.senderName) .. " senderGUID=" .. tostring(data.senderGUID) .. " targetGUID=" .. tostring(data.targetGUID) .. " targetName=" .. tostring(data.targetName))
        if ShouldTriggerPIForMe(data) then
            TriggerPIReceived(data.senderGUID, data.senderName, sender)
        else
            DPrint("PI payload ignored because it does not target this player.")
        end
        return
    end

    if data.kind == "PIREQ" then
        DPrint("Processing PIREQ payload senderName=" .. tostring(data.senderName) .. " senderGUID=" .. tostring(data.senderGUID) .. " focusGUID=" .. tostring(UnitExists("focus") and UnitGUID("focus") or nil))
        if ShouldTriggerPIRequestForPriest(data) then
            TriggerPIRequest(data, sender)
        else
            DPrint("PIREQ payload ignored after priest validation.")
        end
    end
end

local function OnUnitAura(unit, updateInfo)
    if unit ~= "player" or not IsExperimentalPIEnabled() then
        return
    end

    if updateInfo and type(updateInfo) == "table" and type(updateInfo.addedAuras) == "table" then
        local currentPIAura = GetCurrentPIAura()
        for _, aura in ipairs(updateInfo.addedAuras) do
            if aura and currentPIAura and aura.auraInstanceID and aura.auraInstanceID == currentPIAura.auraInstanceID then
                local newInstanceID = aura.auraInstanceID
                local wasNewApplication = (not piAuraActive) or (newInstanceID and newInstanceID ~= piAuraInstanceID)
                piAuraActive = true
                piAuraInstanceID = newInstanceID or piAuraInstanceID

                if wasNewApplication and IsFreshPIAura(currentPIAura) then
                    DPrint("PI aura detected on player via UNIT_AURA addedAuras.")
                    TriggerPIReceived(nil, nil, "Priest")
                elseif wasNewApplication then
                    DPrint("PI aura matched via UNIT_AURA addedAuras but was no longer fresh; alert skipped.")
                end
                return
            end
        end
    end

    -- Fallback for clients or edge cases where addedAuras is not present or not
    -- populated reliably. This still detects PI locally without needing combat log
    -- or addon comms from the priest.
    local wasActive = piAuraActive
    local previousInstanceID = piAuraInstanceID
    local aura = SyncPIAuraState()
    if aura and ((not wasActive) or (aura.auraInstanceID and aura.auraInstanceID ~= previousInstanceID)) and IsFreshPIAura(aura) then
        DPrint("PI aura detected on player via UNIT_AURA fallback scan.")
        TriggerPIReceived(nil, nil, "Priest")
    elseif aura and ((not wasActive) or (aura.auraInstanceID and aura.auraInstanceID ~= previousInstanceID)) then
        DPrint("PI aura detected only after it was no longer fresh; alert skipped.")
    end
end

frame:RegisterEvent("PLAYER_LOGIN")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("GROUP_ROSTER_UPDATE")
frame:RegisterEvent("UNIT_SPELLCAST_SENT")
frame:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
frame:RegisterEvent("CHAT_MSG_ADDON")
frame:RegisterEvent("CHAT_MSG_WHISPER")
frame:RegisterEvent("UNIT_AURA")

frame:SetScript("OnEvent", function(_, event, ...)
    if event == "PLAYER_LOGIN" then
        InitPlayerState()
        SyncPIAuraState()
        RegisterPrefix()
        return
    end

    if event == "PLAYER_ENTERING_WORLD" or event == "GROUP_ROSTER_UPDATE" then
        InitPlayerState()
        SyncPIAuraState()
        return
    end

    if event == "UNIT_SPELLCAST_SENT" then
        OnSpellcastSent(...)
        return
    end

    if event == "UNIT_SPELLCAST_SUCCEEDED" then
        OnSpellcastSucceeded(...)
        return
    end

    if event == "UNIT_AURA" then
        OnUnitAura(...)
        return
    end

    if event == "CHAT_MSG_ADDON" then
        OnAddonMessage(...)
        return
    end

    if event == "CHAT_MSG_WHISPER" then
        OnWhisperRequest(...)
    end
end)

