-- ============================================================
-- Asylum Compagnon — PrivateMessages.lua
-- Capture, conservation et envoi des messages privés WoW/Battle.net.
-- L'interface est construite séparément dans PrivateMessagesUI.lua.
--
-- Midnight : suspension dure en clé M+ (événements live désinscrits, fenêtre
-- masquée) et verrou doux en encounter/JcJ (filtres retirés, chat Blizzard
-- prioritaire). Les valeurs secrètes sont rejetées avant toute opération.
-- ============================================================

local ADDON_NAME, NS = ...

local PM = {}
NS.PrivateMessages = PM

local SCHEMA_VERSION = 3
local WINDOW_LAYOUT_VERSION = 2
local CHAT_EVENTS = {
    "CHAT_MSG_WHISPER",
    "CHAT_MSG_WHISPER_INFORM",
    "CHAT_MSG_BN_WHISPER",
    "CHAT_MSG_BN_WHISPER_INFORM",
}
local BNET_REFRESH_EVENTS = {
    "BN_FRIEND_LIST_SIZE_CHANGED",
    "BN_FRIEND_INFO_CHANGED",
}
local SUSPENDABLE_LIFECYCLE_EVENTS = {
    "PLAYER_REGEN_DISABLED",
    "PLAYER_REGEN_ENABLED",
}
-- Enum.AddOnRestrictionType / Enum.AddOnRestrictionState (Retail 12.x).
-- Les valeurs de ADDON_RESTRICTION_STATE_CHANGED sont l'autorité pendant
-- l'événement : C_RestrictedActions peut encore répondre false pendant sa
-- propre notification.
local RESTRICTION = {
    Combat = 0,
    Encounter = 1,
    ChallengeMode = 2,
    PvPMatch = 3,
}
local RESTRICTION_INACTIVE = 0
local RESTRICTION_ACTIVATING = 1
local RESTRICTION_ACTIVE = 2
local MYTHIC_KEYSTONE_DIFFICULTY = 8
local DEFAULT_WINDOW = {
    point = "CENTER", relativePoint = "CENTER", x = 0, y = 0,
    width = 1120, height = 680,
}
local DEFAULT_ICON = {
    point = "CENTER", relativePoint = "CENTER", x = 310, y = -120,
}
local PRIVATE_MESSAGE_SOUND_ID = 3081 -- SOUNDKIT.TELL_MESSAGE sur Retail

local initialized = false
local chatEventsRegistered = false
local bnetRefreshEventsRegistered = false
local suspendableLifecycleRegistered = false
local filtersRegistered = false
local inEncounter = false
local inChallenge = false
local inCompetitive = false
local hardSuspended = false
local softBlocked = false
local windowWasVisibleBeforeSuspend = false
local popoutSuppressionActive = false
local pendingWhisperTabCleanup = false
local restrictionState = {}
local fallbackSequence = 0
local eventFrame = CreateFrame("Frame")
local whisperFilter, bnWhisperFilter

local function IsSecret(value)
    if value == nil then return false end
    if type(issecretvalue) == "function" then
        local ok, result = pcall(issecretvalue, value)
        if ok and result == true then return true end
    end
    -- Midnight peut exposer une chaîne secrète sans que toutes les versions
    -- de l'API la signalent de la même façon. Une simple comparaison protégée
    -- l'identifie sans faire de lower/concat/format sur sa valeur.
    if type(value) == "string" then
        local ok = pcall(function(candidate) return candidate == "" end, value)
        return not ok
    end
    return false
end

local function Plain(value, expectedType)
    if value == nil or IsSecret(value) then return nil end
    if expectedType and type(value) ~= expectedType then return nil end
    return value
end

local function Now()
    local fn = GetServerTime or time
    if type(fn) ~= "function" then return 0 end
    local ok, value = pcall(fn)
    value = ok and Plain(value, "number") or nil
    return value and math.max(0, math.floor(value)) or 0
end

local function CopyPosition(source, fallback)
    source = type(source) == "table" and source or {}
    return {
        point = Plain(source.point, "string") or fallback.point,
        relativePoint = Plain(source.relativePoint, "string") or fallback.relativePoint,
        x = Plain(source.x, "number") or fallback.x,
        y = Plain(source.y, "number") or fallback.y,
        width = Plain(source.width, "number") or fallback.width,
        height = Plain(source.height, "number") or fallback.height,
    }
end

local function CurrentProfileKey()
    local name, realm
    if type(UnitFullName) == "function" then
        local ok, n, r = pcall(UnitFullName, "player")
        if ok then name, realm = Plain(n, "string"), Plain(r, "string") end
    end
    if not name and type(UnitName) == "function" then
        local ok, value = pcall(UnitName, "player")
        if ok then name = Plain(value, "string") end
    end
    if not realm and type(GetNormalizedRealmName) == "function" then
        local ok, value = pcall(GetNormalizedRealmName)
        if ok then realm = Plain(value, "string") end
    end
    name = name or "player"
    realm = realm or "realm"
    local raw = name .. "-" .. realm
    local ok, lowered = pcall(string.lower, raw)
    return ok and lowered or raw
end

local function NormalizeName(name)
    name = Plain(name, "string")
    if not name or name == "" then return nil end
    local normalized = name
    if type(Ambiguate) == "function" then
        local ok, value = pcall(Ambiguate, name, "none")
        if ok and Plain(value, "string") and value ~= "" then normalized = value end
    end
    local ok, lowered = pcall(string.lower, normalized)
    return ok and lowered or normalized
end

local function AccountState()
    if not NS.db then return nil end
    local state = NS.db.privateMessages
    if type(state) ~= "table" then
        state = {}
        NS.db.privateMessages = state
    end
    state.schemaVersion = SCHEMA_VERSION
    if type(state.conversations) ~= "table" then state.conversations = {} end
    return state
end

local function CharacterState()
    if not NS.chardb then return nil end
    local state = NS.chardb.privateMessages
    if type(state) ~= "table" then
        state = {}
        NS.chardb.privateMessages = state
    end
    local storedWindow = type(state.window) == "table" and state.window or nil
    if (tonumber(state.windowLayoutVersion) or 0) < WINDOW_LAYOUT_VERSION then
        -- La 0.48.0 utilisait exactement 900×560. On agrandit seulement cette
        -- ancienne valeur par défaut ; une taille déjà personnalisée reste
        -- intacte et devient immédiatement la nouvelle préférence persistée.
        if not storedWindow
            or ((tonumber(storedWindow.width) or 900) == 900
                and (tonumber(storedWindow.height) or 560) == 560) then
            storedWindow = storedWindow or {}
            storedWindow.width = DEFAULT_WINDOW.width
            storedWindow.height = DEFAULT_WINDOW.height
        end
        state.windowLayoutVersion = WINDOW_LAYOUT_VERSION
    end
    state.window = CopyPosition(storedWindow, DEFAULT_WINDOW)
    state.icon = CopyPosition(state.icon, DEFAULT_ICON)
    state.activeKey = Plain(state.activeKey, "string")
    return state
end

local function IsCurrentProfile(conversation)
    return type(conversation) == "table"
        and conversation.localProfile == CurrentProfileKey()
end

local function MessageLimit()
    local value = NS.db and tonumber(NS.db.pmMaxMessages) or 200
    return math.max(50, math.min(500, math.floor(value or 200)))
end

local function ConversationLimit()
    local value = NS.db and tonumber(NS.db.pmMaxConversations) or 50
    return math.max(10, math.min(100, math.floor(value or 50)))
end

local function RetentionSeconds()
    local days = NS.db and tonumber(NS.db.pmRetentionDays) or 7
    days = math.max(1, math.min(30, math.floor(days or 7)))
    return days * 86400
end

local function TrimMessages(messages, limit)
    if type(messages) ~= "table" then return {} end
    limit = math.max(1, math.floor(tonumber(limit) or 1))
    local excess = #messages - limit
    if excess <= 0 then return messages end
    for index = 1, limit do messages[index] = messages[index + excess] end
    for index = limit + 1, #messages do messages[index] = nil end
    return messages
end

local function PruneCurrentProfile()
    local state = AccountState()
    if not state then return end
    local now, maxAge = Now(), RetentionSeconds()
    local current = {}
    for key, conversation in pairs(state.conversations) do
        if type(conversation) ~= "table" then
            state.conversations[key] = nil
        else
            conversation.messages = type(conversation.messages) == "table"
                and conversation.messages or {}
            for index = #conversation.messages, 1, -1 do
                local sentAt = Plain(conversation.messages[index].sentAt, "number") or 0
                if now - sentAt > maxAge then table.remove(conversation.messages, index) end
            end
            TrimMessages(conversation.messages, MessageLimit())
            if IsCurrentProfile(conversation) then
                if #conversation.messages == 0 then
                    state.conversations[key] = nil
                else
                    current[#current + 1] = { key = key, conversation = conversation }
                end
            end
        end
    end
    table.sort(current, function(left, right)
        return (left.conversation.lastActivityAt or 0)
            > (right.conversation.lastActivityAt or 0)
    end)
    for index = ConversationLimit() + 1, #current do
        state.conversations[current[index].key] = nil
    end
end

local function NotifyUI(reason, conversationKey)
    local ui = NS.PrivateMessagesUI
    if ui and type(ui.RefreshAll) == "function" then
        pcall(ui.RefreshAll, reason, conversationKey)
    end
end

local function ResolveClass(guid)
    guid = Plain(guid, "string")
    if not guid or type(GetPlayerInfoByGUID) ~= "function" then return nil end
    local ok, _, classFile = pcall(GetPlayerInfoByGUID, guid)
    return ok and Plain(classFile, "string") or nil
end

local function SafeField(source, key)
    if type(source) ~= "table" then return nil end
    local ok, value = pcall(function() return source[key] end)
    return ok and value or nil
end

local function NormalizeBattleTag(battleTag)
    battleTag = Plain(battleTag, "string")
    if not battleTag or battleTag == "" then return nil end
    local ok, lowered = pcall(string.lower, battleTag)
    return ok and lowered or battleTag
end

local function GetBNetAccountInfoByID(bnetAccountID)
    bnetAccountID = Plain(bnetAccountID, "number")
    local api = C_BattleNet
    if not bnetAccountID or type(api) ~= "table"
        or type(api.GetAccountInfoByID) ~= "function" then return nil end
    -- Ne jamais transmettre ici le GUID de personnage comme second argument :
    -- ce paramètre attend un GUID de compte WoW et peut sinon renvoyer une
    -- présence filtrée ou le mauvais personnage.
    local ok, info = pcall(api.GetAccountInfoByID, bnetAccountID)
    return ok and type(info) == "table" and info or nil
end

local function FindBNetFriendByBattleTag(battleTag)
    battleTag = Plain(battleTag, "string")
    local api = C_BattleNet
    if not battleTag or type(api) ~= "table"
        or type(api.GetNumFriends) ~= "function"
        or type(api.GetFriendAccountInfo) ~= "function" then return nil end
    local ok, count = pcall(api.GetNumFriends)
    count = ok and Plain(count, "number") or nil
    if not count then return nil end
    for index = 1, math.max(0, math.floor(count)) do
        local infoOK, info = pcall(api.GetFriendAccountInfo, index)
        local candidate = infoOK and Plain(SafeField(info, "battleTag"), "string") or nil
        if candidate == battleTag then return info end
    end
    return nil
end

local function ResolveBNetContact(bnetAccountID, eventGuid, expectedBattleTag,
        fallbackDisplayName)
    bnetAccountID = Plain(bnetAccountID, "number")
    eventGuid = Plain(eventGuid, "string")
    expectedBattleTag = Plain(expectedBattleTag, "string")
    fallbackDisplayName = Plain(fallbackDisplayName, "string")

    local info = GetBNetAccountInfoByID(bnetAccountID)
    local actualBattleTag = Plain(SafeField(info, "battleTag"), "string")
    if expectedBattleTag and actualBattleTag and actualBattleTag ~= expectedBattleTag then
        -- Les IDs Battle.net peuvent être réattribués entre deux sessions.
        info = FindBNetFriendByBattleTag(expectedBattleTag)
    elseif expectedBattleTag and not info then
        info = FindBNetFriendByBattleTag(expectedBattleTag)
    end

    if not info and eventGuid and type(C_BattleNet) == "table"
        and type(C_BattleNet.GetAccountInfoByGUID) == "function" then
        local ok, byGuid = pcall(C_BattleNet.GetAccountInfoByGUID, eventGuid)
        if ok and type(byGuid) == "table" then info = byGuid end
    end

    local gameInfo = SafeField(info, "gameAccountInfo")
    local battleTag = Plain(SafeField(info, "battleTag"), "string")
        or expectedBattleTag
    local resolvedID = Plain(SafeField(info, "bnetAccountID"), "number")
        or bnetAccountID
    local playerGuid = Plain(SafeField(gameInfo, "playerGuid"), "string")
        or eventGuid
    local characterName = Plain(SafeField(gameInfo, "characterName"), "string")
    local realmName = Plain(SafeField(gameInfo, "realmName"), "string")
        or Plain(SafeField(gameInfo, "realmDisplayName"), "string")
    local accountName = Plain(SafeField(info, "accountName"), "string")
    local gameAccountName = characterName and realmName
        and (characterName .. "-" .. realmName) or characterName or realmName
    return {
        bnetAccountID = resolvedID,
        battleTag = battleTag,
        accountName = accountName,
        gameAccountName = gameAccountName,
        guid = playerGuid,
        displayName = characterName or fallbackDisplayName or accountName or battleTag,
        classFile = ResolveClass(playerGuid),
    }
end

local function ConversationKey(channel, target, bnetAccountID, battleTag)
    local profile = CurrentProfileKey()
    if channel == "BN" then
        local stableTag = NormalizeBattleTag(battleTag)
        if stableTag then return profile .. "::BN::tag:" .. stableTag end
        bnetAccountID = Plain(bnetAccountID, "number")
        if not bnetAccountID then return nil end
        return profile .. "::BN::id:" .. string.format("%.0f", bnetAccountID)
    end
    local normalized = NormalizeName(target)
    if not normalized then return nil end
    return profile .. "::WOW::" .. normalized
end

local function MergeConversationKeys(oldKey, newKey)
    if not oldKey or not newKey or oldKey == newKey then return newKey end
    local state = AccountState()
    if not state then return newKey end
    local old, current = state.conversations[oldKey], state.conversations[newKey]
    if type(old) ~= "table" then return newKey end
    if type(current) ~= "table" then
        state.conversations[oldKey] = nil
        state.conversations[newKey] = old
        old.key = newKey
    else
        current.messages = type(current.messages) == "table" and current.messages or {}
        local known = {}
        for _, message in ipairs(current.messages) do
            if message.id then known[message.id] = true end
        end
        for _, message in ipairs(type(old.messages) == "table" and old.messages or {}) do
            if not message.id or not known[message.id] then
                current.messages[#current.messages + 1] = message
                if message.id then known[message.id] = true end
            end
        end
        table.sort(current.messages, function(left, right)
            return (tonumber(left.sentAt) or 0) < (tonumber(right.sentAt) or 0)
        end)
        TrimMessages(current.messages, MessageLimit())
        current.unreadCount = math.max(0, math.floor(tonumber(current.unreadCount) or 0))
            + math.max(0, math.floor(tonumber(old.unreadCount) or 0))
        if (tonumber(old.lastActivityAt) or 0) > (tonumber(current.lastActivityAt) or 0) then
            current.lastActivityAt = old.lastActivityAt
            current.lastPreview = old.lastPreview
        end
        for _, field in ipairs({ "displayName", "target", "guid", "bnetAccountID",
                "battleTag", "accountName", "gameAccountName", "classFile" }) do
            if current[field] == nil then current[field] = old[field] end
        end
        state.conversations[oldKey] = nil
    end
    local character = CharacterState()
    if character and character.activeKey == oldKey then character.activeKey = newKey end
    return newKey
end

local function FindExistingBNetKey(bnetAccountID, battleTag)
    local state = AccountState()
    if not state then return nil end
    local normalizedTag = NormalizeBattleTag(battleTag)
    for key, conversation in pairs(state.conversations) do
        if IsCurrentProfile(conversation) and conversation.channel == "BN" then
            local convTag = NormalizeBattleTag(conversation.battleTag)
            if (normalizedTag and convTag == normalizedTag)
                or (bnetAccountID and conversation.bnetAccountID == bnetAccountID
                    and (not normalizedTag or not convTag)) then
                return key
            end
        end
    end
    return nil
end

local function EnsureConversation(key, channel, displayName, target, guid,
        bnetAccountID, bnetContact)
    local state = AccountState()
    if not state or not key then return nil end
    local conversation = state.conversations[key]
    if type(conversation) ~= "table" then
        conversation = {
            key = key,
            localProfile = CurrentProfileKey(),
            channel = channel,
            messages = {},
            unreadCount = 0,
            lastActivityAt = 0,
        }
        state.conversations[key] = conversation
    end
    conversation.messages = type(conversation.messages) == "table"
        and conversation.messages or {}
    conversation.channel = channel or conversation.channel or "WOW"
    conversation.displayName = Plain(displayName, "string")
        or conversation.displayName or Plain(target, "string") or NS.L("Inconnu")
    conversation.target = Plain(target, "string") or conversation.target
    conversation.guid = Plain(guid, "string") or conversation.guid
    conversation.bnetAccountID = Plain(bnetAccountID, "number")
        or conversation.bnetAccountID
    if type(bnetContact) == "table" then
        conversation.battleTag = Plain(bnetContact.battleTag, "string")
            or conversation.battleTag
        conversation.accountName = Plain(bnetContact.accountName, "string")
            or conversation.accountName
        conversation.gameAccountName = Plain(bnetContact.gameAccountName, "string")
            or conversation.gameAccountName
        conversation.displayName = Plain(bnetContact.displayName, "string")
            or conversation.displayName
        conversation.classFile = Plain(bnetContact.classFile, "string")
            or conversation.classFile
    end
    conversation.classFile = ResolveClass(conversation.guid) or conversation.classFile
    conversation.unreadCount = math.max(0,
        math.floor(tonumber(conversation.unreadCount) or 0))
    conversation.hidden = conversation.hidden == true
    return conversation
end

local function DuplicateMessage(conversation, id)
    if not id then return false end
    local messages = conversation and conversation.messages or {}
    for index = #messages, math.max(1, #messages - 20), -1 do
        if messages[index] and messages[index].id == id then return true end
    end
    return false
end

local function IsConversationOpen(key)
    local character = CharacterState()
    local ui = NS.PrivateMessagesUI
    return character and character.activeKey == key
        and ui and type(ui.IsWindowShown) == "function"
        and ui.IsWindowShown() == true
end

local function AppendMessage(eventName, direction, channel, text, displayName,
        target, lineID, guid, bnetAccountID, bnetContact)
    text = Plain(text, "string")
    displayName = Plain(displayName, "string")
    lineID = Plain(lineID, "number")
    if not text or text == "" or not displayName then return nil, false end
    if channel == "BN" and type(bnetContact) == "table" then
        displayName = Plain(bnetContact.displayName, "string") or displayName
        guid = Plain(bnetContact.guid, "string") or guid
        bnetAccountID = Plain(bnetContact.bnetAccountID, "number") or bnetAccountID
    end
    local battleTag = type(bnetContact) == "table"
        and Plain(bnetContact.battleTag, "string") or nil
    local key = ConversationKey(channel, target or displayName, bnetAccountID, battleTag)
    if channel == "BN" then
        local oldKey = FindExistingBNetKey(bnetAccountID, battleTag)
        if oldKey and oldKey ~= key then key = MergeConversationKeys(oldKey, key) end
    end
    local conversation = EnsureConversation(key, channel, displayName,
        target or displayName, guid, bnetAccountID, bnetContact)
    if not conversation then return nil, false end
    -- Fermer un contact ne détruit jamais son fil. Seul un nouveau message
    -- réellement reçu le remet dans la colonne de gauche avec tout l'historique.
    if direction == "in" then conversation.hidden = false end

    fallbackSequence = fallbackSequence + 1
    local sentAt = Now()
    local id = lineID and (channel .. ":" .. string.format("%.0f", lineID))
        or (channel .. ":" .. sentAt .. ":" .. fallbackSequence)
    if DuplicateMessage(conversation, id) then return conversation, false end

    local senderName = displayName
    if direction == "out" and type(UnitName) == "function" then
        local ok, value = pcall(UnitName, "player")
        senderName = ok and Plain(value, "string") or NS.L("Moi")
    end
    conversation.messages[#conversation.messages + 1] = {
        id = id,
        eventName = eventName,
        direction = direction,
        channel = channel,
        text = text,
        sentAt = sentAt,
        lineID = lineID,
        senderName = senderName,
    }
    conversation.lastActivityAt = sentAt
    conversation.lastPreview = text
    TrimMessages(conversation.messages, MessageLimit())
    if direction == "in" and not IsConversationOpen(key) then
        conversation.unreadCount = conversation.unreadCount + 1
    elseif direction == "out" then
        conversation.unreadCount = 0
    end

    local character = CharacterState()
    if character and not character.activeKey then character.activeKey = key end
    PruneCurrentProfile()
    NotifyUI(direction == "in" and "incoming" or "outgoing", key)
    return conversation, true
end

local function SafeRegister(frame, event)
    if not frame or type(frame.RegisterEvent) ~= "function" then return false end
    return pcall(frame.RegisterEvent, frame, event)
end

local function SetChatEventsEnabled(enabled)
    if enabled and not chatEventsRegistered then
        local any = false
        for _, event in ipairs(CHAT_EVENTS) do
            if SafeRegister(eventFrame, event) then any = true end
        end
        chatEventsRegistered = any
    elseif not enabled and chatEventsRegistered then
        for _, event in ipairs(CHAT_EVENTS) do
            if type(eventFrame.UnregisterEvent) == "function" then
                pcall(eventFrame.UnregisterEvent, eventFrame, event)
            end
        end
        chatEventsRegistered = false
    end
end

local function SetBNetRefreshEventsEnabled(enabled)
    if enabled and not bnetRefreshEventsRegistered then
        local any = false
        for _, event in ipairs(BNET_REFRESH_EVENTS) do
            if SafeRegister(eventFrame, event) then any = true end
        end
        bnetRefreshEventsRegistered = any
    elseif not enabled and bnetRefreshEventsRegistered then
        for _, event in ipairs(BNET_REFRESH_EVENTS) do
            if type(eventFrame.UnregisterEvent) == "function" then
                pcall(eventFrame.UnregisterEvent, eventFrame, event)
            end
        end
        bnetRefreshEventsRegistered = false
    end
end

local function SetSuspendableLifecycleEnabled(enabled)
    if enabled and not suspendableLifecycleRegistered then
        local any = false
        for _, event in ipairs(SUSPENDABLE_LIFECYCLE_EVENTS) do
            if SafeRegister(eventFrame, event) then any = true end
        end
        suspendableLifecycleRegistered = any
    elseif not enabled and suspendableLifecycleRegistered then
        for _, event in ipairs(SUSPENDABLE_LIFECYCLE_EVENTS) do
            if type(eventFrame.UnregisterEvent) == "function" then
                pcall(eventFrame.UnregisterEvent, eventFrame, event)
            end
        end
        suspendableLifecycleRegistered = false
    end
end

local function SecureBlizzardCall(fn, ...)
    if type(fn) ~= "function" then return false end
    local secure = securecall
    if type(secure) == "function" then return pcall(secure, fn, ...) end
    return pcall(fn, ...)
end

local VALID_WHISPER_MODES = {
    inline = true,
    popout = true,
    popout_and_inline = true,
}

local function ReadWhisperMode()
    local getter = type(C_CVar) == "table" and type(C_CVar.GetCVar) == "function"
        and C_CVar.GetCVar or GetCVar
    if type(getter) ~= "function" then return nil end
    local ok, value = pcall(getter, "whisperMode")
    value = ok and Plain(value, "string") or nil
    return value and VALID_WHISPER_MODES[value] and value or nil
end

local function WriteWhisperMode(value)
    if not VALID_WHISPER_MODES[value] then return false end
    local setter = type(C_CVar) == "table" and type(C_CVar.SetCVar) == "function"
        and C_CVar.SetCVar or SetCVar
    if type(setter) ~= "function" then return false end
    local ok, result = pcall(setter, "whisperMode", value)
    return ok and result ~= false
end

local function CloseTemporaryWhisperTabs()
    -- FloatingChatFrameManager ouvre les onglets de chuchotement sans passer
    -- par ChatFrame_AddMessageEventFilter. On ne ferme que ses fenêtres
    -- temporaires dédiées, jamais un onglet personnalisé du joueur.
    if type(InCombatLockdown) == "function" and InCombatLockdown() == true then
        pendingWhisperTabCleanup = true
        return false
    end
    pendingWhisperTabCleanup = false
    if type(CHAT_FRAMES) ~= "table" or type(FCF_Close) ~= "function" then
        return false
    end

    local frames = {}
    for _, frameName in pairs(CHAT_FRAMES) do
        local frame = type(frameName) == "string" and _G[frameName] or nil
        if frame and frame.isTemporary and frame.inUse
            and (frame.chatType == "WHISPER" or frame.chatType == "BN_WHISPER") then
            frames[#frames + 1] = frame
        end
    end
    for _, frame in ipairs(frames) do
        SecureBlizzardCall(FCF_Close, frame)
    end
    return #frames > 0
end

local function ForceInlineWhispers()
    local state = AccountState()
    if not state then return end
    local current = ReadWhisperMode()
    if current and current ~= "inline" then
        -- Cette préférence est restaurée en M+, à la désactivation et au
        -- logout : l'addon ne remplace donc pas durablement le choix Blizzard.
        state.previousWhisperMode = current
    end
    if current and current ~= "inline" then WriteWhisperMode("inline") end
    CloseTemporaryWhisperTabs()
end

local function RestoreWhisperMode()
    local state = AccountState()
    local previous = state and Plain(state.previousWhisperMode, "string") or nil
    if state then state.previousWhisperMode = nil end
    if previous and VALID_WHISPER_MODES[previous] and ReadWhisperMode() ~= previous then
        WriteWhisperMode(previous)
    end
    pendingWhisperTabCleanup = false
end

local function SyncWhisperPresentation()
    -- Hors suspension M+, l'option « masquer dans le chat Blizzard » doit
    -- aussi neutraliser les onglets automatiques (mode New Tab/Both). Pendant
    -- un encounter ordinaire les messages Blizzard restent visibles en ligne,
    -- car les filtres sont retirés, mais aucun onglet parasite n'est créé.
    local shouldSuppressPopouts = initialized and NS.db
        and NS.db.tbPrivateMessages == true
        and NS.db.pmHideBlizzard ~= false and not hardSuspended
    if shouldSuppressPopouts then
        popoutSuppressionActive = true
        ForceInlineWhispers()
    else
        popoutSuppressionActive = false
        RestoreWhisperMode()
    end
end

local function RegisterFilters()
    if filtersRegistered or type(ChatFrame_AddMessageEventFilter) ~= "function" then return end
    whisperFilter = whisperFilter or function() return true end
    bnWhisperFilter = bnWhisperFilter or function() return true end
    local registered = false
    registered = SecureBlizzardCall(ChatFrame_AddMessageEventFilter,
        "CHAT_MSG_WHISPER", whisperFilter)
        or registered
    registered = SecureBlizzardCall(ChatFrame_AddMessageEventFilter,
        "CHAT_MSG_WHISPER_INFORM", whisperFilter) or registered
    registered = SecureBlizzardCall(ChatFrame_AddMessageEventFilter,
        "CHAT_MSG_BN_WHISPER", bnWhisperFilter) or registered
    registered = SecureBlizzardCall(ChatFrame_AddMessageEventFilter,
        "CHAT_MSG_BN_WHISPER_INFORM", bnWhisperFilter) or registered
    filtersRegistered = registered
end

local function UnregisterFilters()
    if not filtersRegistered then return end
    if type(ChatFrame_RemoveMessageEventFilter) == "function" then
        SecureBlizzardCall(ChatFrame_RemoveMessageEventFilter,
            "CHAT_MSG_WHISPER", whisperFilter)
        SecureBlizzardCall(ChatFrame_RemoveMessageEventFilter,
            "CHAT_MSG_WHISPER_INFORM", whisperFilter)
        SecureBlizzardCall(ChatFrame_RemoveMessageEventFilter,
            "CHAT_MSG_BN_WHISPER", bnWhisperFilter)
        SecureBlizzardCall(ChatFrame_RemoveMessageEventFilter,
            "CHAT_MSG_BN_WHISPER_INFORM", bnWhisperFilter)
    end
    filtersRegistered = false
end

local function InKey()
    if NS.Restrictions and type(NS.Restrictions.InKey) == "function" then
        return NS.Restrictions.InKey() == true
    end
    if C_ChallengeMode and type(C_ChallengeMode.IsChallengeModeActive) == "function" then
        local ok, active = pcall(C_ChallengeMode.IsChallengeModeActive)
        return ok and active == true
    end
    return false
end

local function RestrictionIsActive(restrictionType)
    local state = restrictionState[restrictionType]
    if state ~= nil then
        return state == RESTRICTION_ACTIVATING or state == RESTRICTION_ACTIVE
    end
    local api = C_RestrictedActions
    if type(api) == "table" and type(api.IsAddOnRestrictionActive) == "function" then
        local ok, active = pcall(api.IsAddOnRestrictionActive, restrictionType)
        return ok and active == true
    end
    return false
end

local function InstanceSafetyState()
    if type(GetInstanceInfo) ~= "function" then return false, false end
    local ok, _, instanceType, difficultyID = pcall(GetInstanceInfo)
    if not ok then return false, false end
    instanceType = Plain(instanceType, "string")
    difficultyID = Plain(difficultyID, "number")
    return difficultyID == MYTHIC_KEYSTONE_DIFFICULTY,
        instanceType == "pvp" or instanceType == "arena"
end

local function ChatMessagingLocked()
    if type(C_ChatInfo) == "table"
        and type(C_ChatInfo.InChatMessagingLockdown) == "function" then
        local ok, active = pcall(C_ChatInfo.InChatMessagingLockdown)
        if ok and active == true then return true end
    end
    return RestrictionIsActive(RESTRICTION.Combat)
end

local function SetMessengerWindowShown(shown)
    local ui = NS.PrivateMessagesUI
    if ui and type(ui.SetWindowShown) == "function" then
        pcall(ui.SetWindowShown, shown == true)
    end
end

local function SyncFilters()
    local shouldHide = initialized and NS.db and NS.db.tbPrivateMessages == true
        and NS.db.pmHideBlizzard ~= false and not hardSuspended and not softBlocked
    -- Les filtres retournent toujours true et ne consultent aucun état. Comme
    -- WhisperMessenger, on les active/désactive ici au lieu de brancher dans
    -- le callback, ce qui permet de les laisser installés en combat ordinaire.
    if shouldHide then RegisterFilters() else UnregisterFilters() end
    SyncWhisperPresentation()
end

local function RefreshSuspension()
    local mythicDifficulty, competitiveInstance = InstanceSafetyState()
    local nextHard = inChallenge
        or RestrictionIsActive(RESTRICTION.ChallengeMode)
        or InKey() or mythicDifficulty
    local nextSoft = not nextHard and (inEncounter or inCompetitive
        or RestrictionIsActive(RESTRICTION.Encounter)
        or RestrictionIsActive(RESTRICTION.PvPMatch)
        or competitiveInstance)
    local hardChanged = nextHard ~= hardSuspended
    local changed = hardChanged or nextSoft ~= softBlocked

    if nextHard and not hardSuspended then
        local ui = NS.PrivateMessagesUI
        windowWasVisibleBeforeSuspend = ui and type(ui.IsWindowShown) == "function"
            and ui.IsWindowShown() == true or false
    end
    hardSuspended, softBlocked = nextHard, nextSoft
    local enabled = initialized and NS.db and NS.db.tbPrivateMessages == true
    SetChatEventsEnabled(enabled and not hardSuspended)
    SetBNetRefreshEventsEnabled(enabled and not hardSuspended)
    SetSuspendableLifecycleEnabled(enabled and not hardSuspended)
    SyncFilters()

    if hardChanged and hardSuspended and enabled then
        SetMessengerWindowShown(false)
        if initialized and NS.Msg then
            NS.Msg(NS.L("Messagerie suspendue en Mythique+. Le chat Blizzard reprend la main."))
        end
    elseif hardChanged and enabled then
        -- Aucun appel à ChatEdit_SetLastTellTarget ici : même via securecall,
        -- cela peut contaminer l'état privé de /r après une valeur secrète.
        if initialized and NS.Msg then
            NS.Msg(NS.L("Messagerie réactivée après le contenu protégé."))
        end
        if windowWasVisibleBeforeSuspend then SetMessengerWindowShown(true) end
        windowWasVisibleBeforeSuspend = false
    elseif hardChanged then
        windowWasVisibleBeforeSuspend = false
    end
    if changed then NotifyUI("suspension") end
end

local function Init()
    if initialized then return true end
    if not NS.db or not NS.chardb then return false end
    AccountState()
    CharacterState()
    PruneCurrentProfile()
    initialized = true
    RefreshSuspension()
    if NS.PrivateMessagesUI and type(NS.PrivateMessagesUI.Initialize) == "function" then
        pcall(NS.PrivateMessagesUI.Initialize)
    end
    PM.ApplySettings()
    return true
end

function PM.IsEnabled()
    return NS.db and NS.db.tbPrivateMessages == true
end

function PM.IsSuspended()
    return hardSuspended or softBlocked
end

function PM.IsHardSuspended()
    return hardSuspended
end

function PM.IsSendLocked()
    if hardSuspended or softBlocked or ChatMessagingLocked() then return true end
    return type(InCombatLockdown) == "function" and InCombatLockdown() == true
end

function PM.GetCharacterState()
    return CharacterState()
end

function PM.GetConversations()
    if not initialized then Init() end
    local state, output = AccountState(), {}
    if not state then return output end
    for _, conversation in pairs(state.conversations) do
        if IsCurrentProfile(conversation) and conversation.hidden ~= true then
            output[#output + 1] = conversation
        end
    end
    table.sort(output, function(left, right)
        local la, ra = left.lastActivityAt or 0, right.lastActivityAt or 0
        if la ~= ra then return la > ra end
        return (left.displayName or "") < (right.displayName or "")
    end)
    return output
end

function PM.GetConversation(key)
    local state = AccountState()
    local conversation = state and state.conversations[key]
    return IsCurrentProfile(conversation) and conversation.hidden ~= true
        and conversation or nil
end

function PM.GetActiveKey()
    local character = CharacterState()
    return character and character.activeKey or nil
end

function PM.SetActiveKey(key)
    local character = CharacterState()
    if not character then return end
    if key ~= nil and not PM.GetConversation(key) then return end
    character.activeKey = key
    if key then PM.MarkRead(key) end
    NotifyUI("selection", key)
end

function PM.MarkRead(key)
    local conversation = PM.GetConversation(key)
    if conversation and (tonumber(conversation.unreadCount) or 0) > 0 then
        conversation.unreadCount = 0
        NotifyUI("read", key)
    end
end

function PM.HideConversation(key)
    local conversation = PM.GetConversation(key)
    if not conversation then return false end
    conversation.hidden = true
    conversation.unreadCount = 0

    local character = CharacterState()
    if character and character.activeKey == key then
        character.activeKey = nil
        local visible = PM.GetConversations()
        if visible[1] then
            character.activeKey = visible[1].key
            visible[1].unreadCount = 0
        end
    end
    NotifyUI("hidden", key)
    return true
end

function PM.GetUnreadCount()
    local total = 0
    for _, conversation in ipairs(PM.GetConversations()) do
        total = total + math.max(0, tonumber(conversation.unreadCount) or 0)
    end
    return total
end

local function RefreshBNetContacts()
    if hardSuspended then return end
    local state = AccountState()
    if not state then return end
    local keys = {}
    for key, conversation in pairs(state.conversations) do
        if IsCurrentProfile(conversation) and conversation.channel == "BN"
            and conversation.battleTag then keys[#keys + 1] = key end
    end
    local changed = false
    for _, oldKey in ipairs(keys) do
        local conversation = state.conversations[oldKey]
        if conversation then
            local contact = ResolveBNetContact(conversation.bnetAccountID,
                conversation.guid, conversation.battleTag, conversation.displayName)
            if contact and contact.bnetAccountID then
                local newKey = ConversationKey("BN", conversation.displayName,
                    contact.bnetAccountID, contact.battleTag)
                if newKey and newKey ~= oldKey then
                    newKey = MergeConversationKeys(oldKey, newKey)
                    conversation = state.conversations[newKey]
                end
                if conversation then
                    conversation.bnetAccountID = contact.bnetAccountID
                    conversation.battleTag = contact.battleTag or conversation.battleTag
                    conversation.guid = contact.guid or conversation.guid
                    conversation.displayName = contact.displayName or conversation.displayName
                    conversation.classFile = contact.classFile or conversation.classFile
                    changed = true
                end
            end
        end
    end
    if changed then NotifyUI("contacts") end
end

function PM.Send(key, text)
    if PM.IsSendLocked() then
        return false, NS.L("Envoi indisponible pendant le combat ou un contenu protégé.")
    end
    local conversation = PM.GetConversation(key)
    text = Plain(text, "string")
    if not conversation or not text or text:match("^%s*$") then
        return false, NS.L("Sélectionne une conversation et écris un message.")
    end
    if conversation.channel == "BN" then
        local contact = ResolveBNetContact(conversation.bnetAccountID,
            conversation.guid, conversation.battleTag, conversation.displayName)
        if contact and contact.bnetAccountID then
            conversation.bnetAccountID = contact.bnetAccountID
            conversation.guid = contact.guid or conversation.guid
            conversation.displayName = contact.displayName or conversation.displayName
            conversation.classFile = contact.classFile or conversation.classFile
        end
        local sender = type(C_BattleNet) == "table"
            and type(C_BattleNet.SendWhisper) == "function"
            and C_BattleNet.SendWhisper or BNSendWhisper
        if type(sender) ~= "function" or not conversation.bnetAccountID then
            return false, NS.L("Ce contact Battle.net n'est plus disponible.")
        end
        local ok, result = pcall(sender, conversation.bnetAccountID, text)
        if not ok or result == false then
            return false, NS.L("Le message n'a pas pu être envoyé.")
        end
    else
        local sender = type(C_ChatInfo) == "table"
            and type(C_ChatInfo.SendChatMessage) == "function"
            and C_ChatInfo.SendChatMessage or SendChatMessage
        if type(sender) ~= "function" or not conversation.target then
            return false, NS.L("Ce personnage n'est plus disponible.")
        end
        -- SendChatMessage dépend de la propagation de l'événement matériel.
        -- L'encapsuler dans pcall peut casser cette chaîne et produire
        -- ADDON_ACTION_FORBIDDEN ; le précontrôle ci-dessus est donc suivi
        -- d'un appel direct, comme dans WhisperMessenger.
        sender(text, "WHISPER", nil, conversation.target)
    end
    return true
end

function PM.SaveWindowPosition(position)
    local state = CharacterState()
    if state then state.window = CopyPosition(position, DEFAULT_WINDOW) end
end

function PM.SaveIconPosition(position)
    local state = CharacterState()
    if state then state.icon = CopyPosition(position, DEFAULT_ICON) end
end

function PM.ResetWindowPosition()
    local state = CharacterState()
    if state then
        state.window = CopyPosition(DEFAULT_WINDOW, DEFAULT_WINDOW)
        state.windowLayoutVersion = WINDOW_LAYOUT_VERSION
    end
    if NS.PrivateMessagesUI and NS.PrivateMessagesUI.ApplyWindowPosition then
        NS.PrivateMessagesUI.ApplyWindowPosition()
    end
end

function PM.ResetIconPosition()
    local state = CharacterState()
    if state then state.icon = CopyPosition(DEFAULT_ICON, DEFAULT_ICON) end
    if NS.PrivateMessagesUI and NS.PrivateMessagesUI.ApplyIconPosition then
        NS.PrivateMessagesUI.ApplyIconPosition()
    end
end

function PM.ClearHistory()
    local state = AccountState()
    if not state then return end
    local profile = CurrentProfileKey()
    for key, conversation in pairs(state.conversations) do
        if conversation.localProfile == profile then state.conversations[key] = nil end
    end
    local character = CharacterState()
    if character then character.activeKey = nil end
    NotifyUI("clear")
end

function PM.ApplySettings()
    if not initialized and not Init() then return end
    PruneCurrentProfile()
    RefreshSuspension()
    if not PM.IsEnabled() then
        SetChatEventsEnabled(false)
        SetBNetRefreshEventsEnabled(false)
        SetSuspendableLifecycleEnabled(false)
        UnregisterFilters()
    elseif not hardSuspended then
        RefreshBNetContacts()
    end
    if NS.PrivateMessagesUI and type(NS.PrivateMessagesUI.ApplySettings) == "function" then
        pcall(NS.PrivateMessagesUI.ApplySettings)
    end
    NotifyUI("settings")
end

function PM.ToggleWindow()
    if not PM.IsEnabled() then
        if NS.Msg then NS.Msg(NS.L("Active d'abord la messagerie privée dans la Boîte à outils.")) end
        return
    end
    if hardSuspended then
        if NS.Msg then
            NS.Msg(NS.L("Messagerie suspendue en Mythique+. Le chat Blizzard reprend la main."))
        end
        return
    end
    local ui = NS.PrivateMessagesUI
    if ui and type(ui.ToggleWindow) == "function" then ui.ToggleWindow() end
end

function PM.SelfTest()
    local messages = {}
    for index = 1, 10 do messages[index] = { sentAt = index } end
    TrimMessages(messages, 3)
    local normalized = NormalizeName("Testeur-Royaume")
    local position = CopyPosition({ x = 12, y = -8 }, DEFAULT_ICON)
    local stableBNetKey = ConversationKey("BN", "Test", 39, "Testeur#1234")
    local previousRestriction = restrictionState[RESTRICTION.ChallengeMode]
    restrictionState[RESTRICTION.ChallengeMode] = RESTRICTION_ACTIVE
    local eventStateIsAuthoritative = RestrictionIsActive(RESTRICTION.ChallengeMode)
    restrictionState[RESTRICTION.ChallengeMode] = previousRestriction
    return #messages == 3 and messages[1].sentAt == 8
        and normalized == "testeur-royaume"
        and position.point == "CENTER" and position.x == 12 and position.y == -8
        and stableBNetKey and stableBNetKey:find("::BN::tag:testeur#1234", 1, true) ~= nil
        and eventStateIsAuthoritative == true and IsSecret("texte normal") == false
        and VALID_WHISPER_MODES.inline == true and VALID_WHISPER_MODES.invalid == nil
end

local function HandleWhisper(event, ...)
    if hardSuspended or not PM.IsEnabled() then return end
    local text, playerName, _, _, _, _, _, _, _, _, lineID, guid, bnetAccountID = ...
    if IsSecret(text) or IsSecret(playerName) or IsSecret(guid) then return end
    text, playerName = Plain(text, "string"), Plain(playerName, "string")
    if not text or not playerName then return end
    local isBN = event == "CHAT_MSG_BN_WHISPER"
        or event == "CHAT_MSG_BN_WHISPER_INFORM"
    local direction = (event == "CHAT_MSG_WHISPER_INFORM"
        or event == "CHAT_MSG_BN_WHISPER_INFORM") and "out" or "in"
    local bnetContact
    if isBN then
        local existingKey = FindExistingBNetKey(Plain(bnetAccountID, "number"), nil)
        local state = AccountState()
        local existing = existingKey and state and state.conversations[existingKey] or nil
        bnetContact = ResolveBNetContact(bnetAccountID, guid,
            existing and existing.battleTag, playerName)
    end
    local _, appended = AppendMessage(event, direction, isBN and "BN" or "WOW", text,
        playerName, playerName, lineID, guid, bnetAccountID, bnetContact)
    -- Quand nos filtres remplacent réellement le chat Blizzard, ils retirent
    -- aussi son son natif. On rejoue donc le discret TELL_MESSAGE une seule
    -- fois, seulement pour un nouveau message entrant. En encounter/M+ les
    -- filtres sont absents et Blizzard garde son propre son, sans doublon.
    if direction == "in" and appended and filtersRegistered
        and NS.db and NS.db.pmMessageSound ~= false and type(PlaySound) == "function" then
        local soundID = type(SOUNDKIT) == "table" and SOUNDKIT.TELL_MESSAGE
            or PRIVATE_MESSAGE_SOUND_ID
        pcall(PlaySound, soundID or PRIVATE_MESSAGE_SOUND_ID, "Master")
    end
end

eventFrame:RegisterEvent("ADDON_LOADED")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("PLAYER_LOGOUT")
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
eventFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
eventFrame:RegisterEvent("CVAR_UPDATE")
eventFrame:RegisterEvent("ENCOUNTER_START")
eventFrame:RegisterEvent("ENCOUNTER_END")
pcall(eventFrame.RegisterEvent, eventFrame, "CHALLENGE_MODE_START")
pcall(eventFrame.RegisterEvent, eventFrame, "CHALLENGE_MODE_COMPLETED")
pcall(eventFrame.RegisterEvent, eventFrame, "CHALLENGE_MODE_RESET")
pcall(eventFrame.RegisterEvent, eventFrame, "ADDON_RESTRICTION_STATE_CHANGED")
eventFrame:SetScript("OnEvent", function(_, event, arg1, ...)
    if event == "ADDON_LOADED" and arg1 == ADDON_NAME then
        if not Init() and C_Timer and C_Timer.After then C_Timer.After(0, Init) end
    elseif event == "PLAYER_LOGIN" then
        Init()
    elseif event == "PLAYER_LOGOUT" then
        -- Un /reload comme une déconnexion rend exactement son réglage de
        -- nouveaux chuchotements au joueur avant la sauvegarde des variables.
        popoutSuppressionActive = false
        RestoreWhisperMode()
    elseif event == "CVAR_UPDATE" then
        local cvarName = Plain(arg1, "string")
        local ok, lowered = pcall(string.lower, cvarName or "")
        if ok and lowered:gsub("[^%a]", "") == "whispermode"
            and popoutSuppressionActive then
            -- Un autre réglage peut remettre New Tab/Both pendant la session.
            -- L'option de masquage reste l'autorité tant que la messagerie est
            -- active hors M+.
            ForceInlineWhispers()
        end
    elseif event == "ADDON_RESTRICTION_STATE_CHANGED" then
        local restrictionType = Plain(arg1, "number")
        local newState = Plain((...), "number")
        if restrictionType and newState then
            restrictionState[restrictionType] = newState
            local active = newState == RESTRICTION_ACTIVATING
                or newState == RESTRICTION_ACTIVE
            if restrictionType == RESTRICTION.ChallengeMode then
                inChallenge = active
            elseif restrictionType == RESTRICTION.Encounter then
                inEncounter = active
            elseif restrictionType == RESTRICTION.PvPMatch then
                inCompetitive = active
            end
            -- Ne pas rappeler IsAddOnRestrictionActive ici : le payload est
            -- la valeur faisant autorité pendant la notification.
            RefreshSuspension()
            if restrictionType == RESTRICTION.Combat then NotifyUI("lockdown") end
        end
    elseif event == "ENCOUNTER_START" then
        inEncounter = true
        RefreshSuspension()
    elseif event == "ENCOUNTER_END" then
        inEncounter = false
        RefreshSuspension()
    elseif event == "CHALLENGE_MODE_START" then
        -- Ne dépend pas d'un éventuel décalage d'une frame de
        -- C_ChallengeMode.IsChallengeModeActive : le verrou est immédiat.
        inChallenge = true
        RefreshSuspension()
    elseif event == "CHALLENGE_MODE_COMPLETED" or event == "CHALLENGE_MODE_RESET"
        or event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then
        inChallenge = false
        if event == "CHALLENGE_MODE_COMPLETED" or event == "CHALLENGE_MODE_RESET" then
            restrictionState[RESTRICTION.ChallengeMode] = RESTRICTION_INACTIVE
        else
            restrictionState[RESTRICTION.ChallengeMode] = nil
            restrictionState[RESTRICTION.Encounter] = nil
            restrictionState[RESTRICTION.PvPMatch] = nil
            inEncounter = false
            inCompetitive = false
        end
        if C_Timer and C_Timer.After then C_Timer.After(0.2, RefreshSuspension)
        else RefreshSuspension() end
    elseif event == "PLAYER_REGEN_DISABLED" or event == "PLAYER_REGEN_ENABLED" then
        if event == "PLAYER_REGEN_ENABLED" and pendingWhisperTabCleanup then
            SyncWhisperPresentation()
        end
        NotifyUI("lockdown")
    elseif event == "BN_FRIEND_LIST_SIZE_CHANGED" or event == "BN_FRIEND_INFO_CHANGED" then
        RefreshBNetContacts()
    elseif event == "CHAT_MSG_WHISPER" or event == "CHAT_MSG_WHISPER_INFORM"
        or event == "CHAT_MSG_BN_WHISPER" or event == "CHAT_MSG_BN_WHISPER_INFORM" then
        HandleWhisper(event, arg1, ...)
    end
end)
