-- ============================================================
-- Asylum Compagnon — PrivateMessagesUI.lua
-- Fenêtre de conversations et icône déplaçable de la messagerie privée.
-- ============================================================

local ADDON_NAME, NS = ...

local UI = {}
NS.PrivateMessagesUI = UI

local PM = NS.PrivateMessages
local unpack = unpack or table.unpack
local WHITE = "Interface\\Buttons\\WHITE8x8"
local CIRCLE = "Interface\\CHARACTERFRAME\\TempPortraitAlphaMask"
local WHISPER_ICON = "Interface\\CHATFRAME\\UI-ChatWhisperIcon"
local CLASS_TEXTURE = "Interface\\GLUES\\CHARACTERCREATE\\UI-CHARACTERCREATE-CLASSES"
local FONT = "Fonts\\FRIZQT__.TTF"

local COLORS = {
    background = { 0.035, 0.039, 0.052, 0.985 },
    panel = { 0.058, 0.063, 0.083, 0.98 },
    panelAlt = { 0.075, 0.082, 0.108, 1 },
    field = { 0.050, 0.055, 0.073, 1 },
    line = { 1, 1, 1, 0.08 },
    accent = { 0.655, 0.545, 0.980, 1 },
    blue = { 0.13, 0.55, 0.95, 1 },
    blueDark = { 0.055, 0.29, 0.53, 1 },
    text = { 0.94, 0.94, 0.97, 1 },
    muted = { 0.56, 0.58, 0.66, 1 },
    red = { 0.92, 0.22, 0.28, 1 },
}

local window, icon
local contactsScroll, contactsChild, transcriptScroll, transcriptChild
local conversationHeader, conversationSubheader, composer, placeholder, sendButton
local contactRows, messageFrames = {}, {}
local pulseAnimation
local initialized = false

local function Solid(parent, layer, sublevel)
    local texture = parent:CreateTexture(nil, layer or "BACKGROUND", nil, sublevel)
    texture:SetTexture(WHITE)
    return texture
end

local function Font(parent, size, color, flags)
    local text = parent:CreateFontString(nil, "OVERLAY")
    size = size or 11
    local fontObject
    if not flags then
        if size >= 14 then
            fontObject = GameFontHighlightLarge
        elseif size >= 12 then
            fontObject = GameFontNormalLarge or GameFontHighlight
        elseif size <= 9 then
            fontObject = GameFontNormalSmall
        else
            fontObject = GameFontHighlight
        end
    end
    -- Les FontObjects Blizzard conservent leur chaîne de repli Unicode. Un
    -- SetFont brut peut faire disparaître les noms/messages grecs ou cyrilliques.
    if fontObject and type(text.SetFontObject) == "function" then
        text:SetFontObject(fontObject)
    else
        text:SetFont(FONT, size, flags or "")
    end
    text:SetTextColor(unpack(color or COLORS.text))
    return text
end

local function AddBorder(frame, color)
    for _, side in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do
        local line = Solid(frame, "OVERLAY", 7)
        line:SetVertexColor(unpack(color or COLORS.line))
        if side == "TOP" then
            line:SetPoint("TOPLEFT"); line:SetPoint("TOPRIGHT"); line:SetHeight(1)
        elseif side == "BOTTOM" then
            line:SetPoint("BOTTOMLEFT"); line:SetPoint("BOTTOMRIGHT"); line:SetHeight(1)
        elseif side == "LEFT" then
            line:SetPoint("TOPLEFT"); line:SetPoint("BOTTOMLEFT"); line:SetWidth(1)
        else
            line:SetPoint("TOPRIGHT"); line:SetPoint("BOTTOMRIGHT"); line:SetWidth(1)
        end
    end
end

local function MakeButton(parent, label, width, height)
    local button = CreateFrame("Button", nil, parent)
    button:SetSize(width or 80, height or 24)
    button.background = Solid(button)
    button.background:SetAllPoints()
    button.background:SetVertexColor(unpack(COLORS.field))
    AddBorder(button)
    button.label = Font(button, 11)
    button.label:SetPoint("CENTER")
    button.label:SetText(label or "")
    local highlight = Solid(button, "HIGHLIGHT")
    highlight:SetAllPoints()
    highlight:SetVertexColor(1, 1, 1, 0.06)
    return button
end

local function CapturePosition(frame, includeSize)
    local point, _, relativePoint, x, y = frame:GetPoint(1)
    local result = {
        point = point or "CENTER", relativePoint = relativePoint or point or "CENTER",
        x = x or 0, y = y or 0,
    }
    if includeSize then
        result.width = math.floor((frame:GetWidth() or 900) + 0.5)
        result.height = math.floor((frame:GetHeight() or 560) + 0.5)
    end
    return result
end

local function RelativeTime(timestamp)
    timestamp = tonumber(timestamp) or 0
    local elapsed = math.max(0, ((GetServerTime and GetServerTime()) or time()) - timestamp)
    if elapsed < 60 then return NS.L("à l'instant") end
    if elapsed < 3600 then return (NS.L("il y a %d min")):format(math.floor(elapsed / 60)) end
    if elapsed < 86400 then return (NS.L("il y a %d h")):format(math.floor(elapsed / 3600)) end
    return (NS.L("il y a %d j")):format(math.floor(elapsed / 86400))
end

local function Timestamp(timestamp)
    local ok, value = pcall(date, "%d/%m %H:%M", tonumber(timestamp) or 0)
    return ok and value or ""
end

local function ClassColor(classFile)
    local color = classFile and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFile]
    return color and { color.r, color.g, color.b, 1 } or COLORS.text
end

local function ApplyClassIcon(texture, classFile)
    local coords = classFile and CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS[classFile]
    if coords then
        texture:SetTexture(CLASS_TEXTURE)
        texture:SetTexCoord(coords[1], coords[2], coords[3], coords[4])
    else
        texture:SetTexture(WHISPER_ICON)
        texture:SetTexCoord(0, 1, 0, 1)
    end
end

local function CurrentConversation()
    return PM and PM.GetConversation and PM.GetConversation(PM.GetActiveKey()) or nil
end

local function EnsureActiveConversation()
    local active = CurrentConversation()
    if active then return active end
    local conversations = PM.GetConversations()
    if conversations[1] then
        PM.SetActiveKey(conversations[1].key)
        return conversations[1]
    end
end

local function UpdateComposerState()
    if not composer or not sendButton then return end
    local conversation = CurrentConversation()
    local sendLocked = PM.IsSendLocked and PM.IsSendLocked() or PM.IsSuspended()
    local canSend = conversation ~= nil and not sendLocked
    if canSend then composer:Enable() else composer:Disable() end
    composer:SetTextColor(unpack(canSend and COLORS.text or COLORS.muted))
    sendButton:EnableMouse(canSend)
    sendButton.background:SetVertexColor(unpack(canSend and COLORS.blueDark or COLORS.field))
    sendButton.label:SetTextColor(unpack(canSend and COLORS.text or COLORS.muted))
    if PM.IsHardSuspended and PM.IsHardSuspended() then
        placeholder:SetText(NS.L("Messagerie suspendue pendant ce contenu protégé."))
    elseif PM.IsSuspended() then
        placeholder:SetText(NS.L("Le chat Blizzard gère les messages pendant cette rencontre."))
    elseif sendLocked then
        placeholder:SetText(NS.L("Envoi indisponible pendant le combat."))
    elseif not conversation then
        placeholder:SetText(NS.L("Sélectionne une conversation."))
    else
        placeholder:SetText(NS.L("Écris un message et appuie sur Entrée"))
    end
    placeholder:SetShown(composer:GetText() == "")
end

local function SendCurrentMessage()
    if not composer then return end
    local text = composer:GetText() or ""
    local ok, errorText = PM.Send(PM.GetActiveKey(), text)
    if ok then
        composer:SetText("")
        composer:ClearFocus()
    elseif errorText and NS.Msg then
        NS.Msg(errorText)
    end
end

local function CreateIcon()
    if icon then return icon end
    icon = CreateFrame("Button", "AsylumCompagnonPrivateMessageIcon", UIParent)
    icon:SetFrameStrata("MEDIUM")
    icon:SetClampedToScreen(true)
    icon:SetMovable(true)
    icon:EnableMouse(true)
    icon:RegisterForClicks("LeftButtonUp")
    icon:RegisterForDrag("LeftButton")

    icon.background = icon:CreateTexture(nil, "BACKGROUND")
    icon.background:SetAllPoints()
    icon.background:SetTexture(CIRCLE)
    icon.background:SetVertexColor(0.035, 0.15, 0.28, 0.96)
    icon.ring = icon:CreateTexture(nil, "BORDER")
    icon.ring:SetPoint("TOPLEFT", -2, 2)
    icon.ring:SetPoint("BOTTOMRIGHT", 2, -2)
    icon.ring:SetTexture("Interface\\COMMON\\RingBorder")
    icon.ring:SetVertexColor(unpack(COLORS.blue))
    icon.glyph = icon:CreateTexture(nil, "ARTWORK")
    icon.glyph:SetTexture(WHISPER_ICON)
    icon.glyph:SetPoint("CENTER")
    icon.glyph:SetVertexColor(0.86, 0.93, 1, 1)
    icon.lock = icon:CreateTexture(nil, "OVERLAY")
    icon.lock:SetTexture("Interface\\LFGFrame\\UI-LFG-ICON-LOCK")
    icon.lock:SetPoint("BOTTOMLEFT", -4, -4)
    icon.lock:SetVertexColor(1, 0.82, 0, 1)
    icon.lock:Hide()

    icon.glow = CreateFrame("Frame", nil, icon)
    icon.glow:SetPoint("CENTER")
    icon.glow:SetFrameLevel(icon:GetFrameLevel() + 4)
    icon.glowTexture = icon.glow:CreateTexture(nil, "ARTWORK")
    icon.glowTexture:SetAllPoints()
    local atlasOK = pcall(icon.glowTexture.SetAtlas, icon.glowTexture,
        "GarrLanding-CircleGlow")
    if not atlasOK then icon.glowTexture:SetTexture(CIRCLE) end
    icon.glowTexture:SetBlendMode("ADD")
    icon.glowTexture:SetVertexColor(0.05, 0.55, 1, 1)
    icon.glow:SetAlpha(0)
    icon.glow:Hide()
    if icon.glow.CreateAnimationGroup then
        pulseAnimation = icon.glow:CreateAnimationGroup()
        pulseAnimation:SetLooping("REPEAT")
        local fadeIn = pulseAnimation:CreateAnimation("Alpha")
        fadeIn:SetFromAlpha(0); fadeIn:SetToAlpha(0.95)
        fadeIn:SetDuration(0.45); fadeIn:SetOrder(1)
        local fadeOut = pulseAnimation:CreateAnimation("Alpha")
        fadeOut:SetFromAlpha(0.95); fadeOut:SetToAlpha(0)
        fadeOut:SetDuration(0.75); fadeOut:SetOrder(2)
        pulseAnimation:SetScript("OnPlay", function() icon.glow:Show() end)
        pulseAnimation:SetScript("OnStop", function()
            icon.glow:SetAlpha(0); icon.glow:Hide()
        end)
    end

    icon.badge = CreateFrame("Frame", nil, icon)
    icon.badge:SetFrameLevel(icon:GetFrameLevel() + 8)
    icon.badgeBackground = icon.badge:CreateTexture(nil, "BACKGROUND")
    icon.badgeBackground:SetAllPoints()
    icon.badgeBackground:SetTexture(CIRCLE)
    icon.badgeBackground:SetVertexColor(unpack(COLORS.red))
    icon.badgeText = Font(icon.badge, 10, COLORS.text, "OUTLINE")
    icon.badgeText:SetPoint("CENTER", 0, 0)

    icon:SetScript("OnClick", function() PM.ToggleWindow() end)
    icon:SetScript("OnDragStart", function(self)
        if NS.db and NS.db.pmIconLocked == true then return end
        self:StartMoving()
    end)
    icon:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        PM.SaveIconPosition(CapturePosition(self, false))
    end)
    icon:SetScript("OnEnter", function(self)
        if not GameTooltip then return end
        GameTooltip:SetOwner(self, "ANCHOR_BOTTOM")
        GameTooltip:SetText(NS.L("Message privé"), 0.65, 0.82, 1)
        local unread = PM.GetUnreadCount()
        if unread > 0 then
            GameTooltip:AddLine((NS.L("%d message(s) non lu(s)")):format(unread), 1, 1, 1)
        else
            GameTooltip:AddLine(NS.L("Aucun nouveau message"), 0.7, 0.7, 0.75)
        end
        if PM.IsSuspended() then
            GameTooltip:AddLine(NS.L("En pause : le chat Blizzard reprend la main."), 1, 0.82, 0)
        end
        GameTooltip:AddLine(NS.L("Clic gauche : ouvrir / fermer"), 0.65, 0.65, 0.7)
        if not (NS.db and NS.db.pmIconLocked == true) then
            GameTooltip:AddLine(NS.L("Glisser : déplacer l'icône"), 0.65, 0.65, 0.7)
        end
        GameTooltip:Show()
    end)
    icon:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end)
    icon:Hide()
    return icon
end

local function RefreshIcon()
    if not icon then return end
    local enabled = PM.IsEnabled()
    icon:SetShown(enabled)
    if not enabled then return end
    local size = math.max(28, math.min(64,
        math.floor(tonumber(NS.db and NS.db.pmIconSize) or 28)))
    icon:SetSize(size, size)
    icon.glyph:SetSize(math.floor(size * 0.60), math.floor(size * 0.60))
    icon.lock:SetSize(math.max(12, math.floor(size * 0.38)),
        math.max(12, math.floor(size * 0.38)))
    icon.lock:SetShown(PM.IsSuspended() == true)
    icon.glow:SetSize(math.floor(size * 1.85), math.floor(size * 1.85))
    local badgeSize = math.max(16, math.floor(size * 0.45))
    icon.badge:SetSize(badgeSize, badgeSize)
    icon.badge:ClearAllPoints()
    icon.badge:SetPoint("TOPRIGHT", 5, 5)
    local unread = PM.GetUnreadCount()
    local showBadge = unread > 0 and not (NS.db and NS.db.pmShowUnreadBadge == false)
    icon.badge:SetShown(showBadge)
    icon.badgeText:SetText(unread > 99 and "99+" or tostring(unread))
    if unread > 0 then
        icon.background:SetVertexColor(0.035, 0.28, 0.55, 1)
        icon.ring:SetVertexColor(0.1, 0.65, 1, 1)
        if pulseAnimation and not pulseAnimation:IsPlaying() then pulseAnimation:Play() end
    else
        icon.background:SetVertexColor(0.035, 0.15, 0.28, 0.96)
        icon.ring:SetVertexColor(unpack(COLORS.blue))
        if pulseAnimation and pulseAnimation:IsPlaying() then pulseAnimation:Stop() end
    end
end

local function CreateContactRow(index)
    local row = CreateFrame("Button", nil, contactsChild)
    row:SetHeight(58)
    row.background = Solid(row)
    row.background:SetAllPoints()
    row.background:SetVertexColor(unpack(COLORS.panel))
    row.activeBar = Solid(row, "ARTWORK")
    row.activeBar:SetPoint("TOPLEFT"); row.activeBar:SetPoint("BOTTOMLEFT")
    row.activeBar:SetWidth(3); row.activeBar:SetVertexColor(unpack(COLORS.blue))
    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetSize(36, 36); row.icon:SetPoint("LEFT", 10, 0)
    row.name = Font(row, 12)
    row.name:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", 10, -5)
    row.name:SetPoint("RIGHT", -58, 0)
    row.name:SetJustifyH("LEFT"); row.name:SetWordWrap(false)
    row.preview = Font(row, 10, COLORS.muted)
    row.preview:SetPoint("BOTTOMLEFT", row.icon, "BOTTOMRIGHT", 10, 5)
    row.preview:SetPoint("RIGHT", -34, 0)
    row.preview:SetJustifyH("LEFT"); row.preview:SetWordWrap(false)
    row.time = Font(row, 9, COLORS.muted)
    row.time:SetPoint("TOPRIGHT", -8, -7)
    row.unread = CreateFrame("Frame", nil, row)
    row.unread:SetSize(20, 20); row.unread:SetPoint("RIGHT", -30, -7)
    row.unreadBg = row.unread:CreateTexture(nil, "BACKGROUND")
    row.unreadBg:SetAllPoints(); row.unreadBg:SetTexture(CIRCLE)
    row.unreadBg:SetVertexColor(unpack(COLORS.blue))
    row.unreadText = Font(row.unread, 9, COLORS.text, "OUTLINE")
    row.unreadText:SetPoint("CENTER")
    row.close = CreateFrame("Button", nil, row)
    row.close:SetSize(16, 16)
    row.close:SetPoint("BOTTOMRIGHT", -7, 4)
    row.close.label = Font(row.close, 10, COLORS.muted)
    row.close.label:SetPoint("CENTER", 0, 0)
    row.close.label:SetText("×")
    row.close.highlight = Solid(row.close, "HIGHLIGHT")
    row.close.highlight:SetAllPoints()
    row.close.highlight:SetVertexColor(1, 1, 1, 0.06)
    row.close:SetScript("OnClick", function(self)
        local owner = self:GetParent()
        if owner and owner.key then PM.HideConversation(owner.key) end
    end)
    row.close:SetScript("OnEnter", function(self)
        self.label:SetTextColor(unpack(COLORS.red))
        if GameTooltip then
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(NS.L("Fermer cette conversation"), 1, 1, 1)
            GameTooltip:AddLine(NS.L("L'historique réapparaîtra si ce contact répond."),
                0.65, 0.67, 0.74, true)
            GameTooltip:Show()
        end
    end)
    row.close:SetScript("OnLeave", function(self)
        self.label:SetTextColor(unpack(COLORS.muted))
        if GameTooltip then GameTooltip:Hide() end
    end)
    row:SetScript("OnClick", function(self)
        if self.key then PM.SetActiveKey(self.key) end
    end)
    local highlight = Solid(row, "HIGHLIGHT")
    highlight:SetAllPoints(); highlight:SetVertexColor(1, 1, 1, 0.04)
    contactRows[index] = row
    return row
end

local function RefreshContacts()
    if not contactsChild then return end
    local conversations = PM.GetConversations()
    local activeKey = PM.GetActiveKey()
    local width = math.max(220, (contactsScroll:GetWidth() or 270) - 2)
    for index, conversation in ipairs(conversations) do
        local row = contactRows[index] or CreateContactRow(index)
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", contactsChild, "TOPLEFT", 0, -(index - 1) * 60)
        row:SetWidth(width)
        row.key = conversation.key
        row.background:SetVertexColor(unpack(conversation.key == activeKey
            and COLORS.panelAlt or COLORS.panel))
        row.activeBar:SetShown(conversation.key == activeKey)
        ApplyClassIcon(row.icon, conversation.classFile)
        row.name:SetText(conversation.displayName or NS.L("Inconnu"))
        row.name:SetTextColor(unpack(ClassColor(conversation.classFile)))
        local preview = NS.db and NS.db.pmHidePreview == true
            and NS.L("Aperçu masqué") or (conversation.lastPreview or "")
        row.preview:SetText(preview)
        row.time:SetText(RelativeTime(conversation.lastActivityAt))
        local unread = math.floor(tonumber(conversation.unreadCount) or 0)
        row.unread:SetShown(unread > 0)
        row.unreadText:SetText(unread > 99 and "99+" or tostring(unread))
        row:Show()
    end
    for index = #conversations + 1, #contactRows do contactRows[index]:Hide() end
    contactsChild:SetHeight(math.max(1, #conversations * 60))
end

local function CreateMessageFrame(index)
    local bubble = CreateFrame("Frame", nil, transcriptChild)
    bubble.background = Solid(bubble)
    bubble.background:SetAllPoints()
    bubble.sender = Font(bubble, 10, COLORS.muted)
    bubble.body = Font(bubble, 13, COLORS.text)
    bubble.body:SetJustifyH("LEFT")
    bubble.body:SetJustifyV("TOP")
    bubble.body:SetWordWrap(true)
    if bubble.body.SetHyperlinksEnabled then bubble.body:SetHyperlinksEnabled(true) end
    messageFrames[index] = bubble
    return bubble
end

local function RefreshTranscript()
    if not transcriptChild then return end
    local conversation = EnsureActiveConversation()
    if not conversation then
        conversationHeader:SetText(NS.L("Aucune conversation"))
        conversationSubheader:SetText(NS.L("Les nouveaux messages apparaîtront ici."))
        for _, frame in ipairs(messageFrames) do frame:Hide() end
        transcriptChild:SetHeight(1)
        UpdateComposerState()
        return
    end
    conversationHeader:SetText(conversation.displayName or NS.L("Inconnu"))
    conversationHeader:SetTextColor(unpack(ClassColor(conversation.classFile)))
    conversationSubheader:SetText(conversation.channel == "BN"
        and NS.L("Message Battle.net") or NS.L("Message personnage"))

    local messages = conversation.messages or {}
    local contentWidth = math.max(360, (transcriptScroll:GetWidth() or 560) - 28)
    local maxBubbleWidth = math.floor(contentWidth * 0.72)
    local y = -12
    for index, message in ipairs(messages) do
        local bubble = messageFrames[index] or CreateMessageFrame(index)
        local outgoing = message.direction == "out"
        bubble:ClearAllPoints()
        bubble:SetPoint(outgoing and "TOPRIGHT" or "TOPLEFT", transcriptChild,
            outgoing and "TOPRIGHT" or "TOPLEFT", outgoing and -12 or 12, y)
        bubble:SetWidth(maxBubbleWidth)
        bubble.sender:ClearAllPoints()
        bubble.sender:SetPoint("TOPLEFT", 10, -7)
        bubble.sender:SetPoint("RIGHT", -10, 0)
        bubble.sender:SetJustifyH(outgoing and "RIGHT" or "LEFT")
        bubble.sender:SetText((outgoing and NS.L("Moi")
            or (message.senderName or conversation.displayName or NS.L("Inconnu")))
            .. "  " .. Timestamp(message.sentAt))
        bubble.body:ClearAllPoints()
        bubble.body:SetPoint("TOPLEFT", 10, -23)
        bubble.body:SetPoint("RIGHT", -10, 0)
        bubble.body:SetWidth(maxBubbleWidth - 20)
        bubble.body:SetText(message.text or "")
        local bodyHeight = math.max(16, math.ceil(bubble.body:GetStringHeight() or 16))
        local height = bodyHeight + 33
        bubble:SetHeight(height)
        bubble.background:SetVertexColor(unpack(outgoing and COLORS.blueDark or COLORS.panelAlt))
        bubble:Show()
        y = y - height - 10
    end
    for index = #messages + 1, #messageFrames do messageFrames[index]:Hide() end
    transcriptChild:SetHeight(math.max(1, -y + 4))
    UpdateComposerState()
    if C_Timer and C_Timer.After then
        C_Timer.After(0, function()
            if transcriptScroll and transcriptScroll:IsShown() then
                transcriptScroll:SetVerticalScroll(math.max(0,
                    transcriptChild:GetHeight() - transcriptScroll:GetHeight()))
            end
        end)
    end
end

local function CreateWindow()
    if window then return window end
    window = CreateFrame("Frame", "AsylumCompagnonPrivateMessagesFrame", UIParent,
        "BackdropTemplate")
    window:SetFrameStrata("DIALOG")
    window:SetClampedToScreen(true)
    window:SetMovable(true)
    window:SetResizable(true)
    window:EnableMouse(true)
    if window.SetResizeBounds then window:SetResizeBounds(760, 480, 1440, 900) end
    window:SetBackdrop({ bgFile = WHITE, edgeFile = WHITE, edgeSize = 1 })
    window:SetBackdropColor(unpack(COLORS.background))
    window:SetBackdropBorderColor(unpack(COLORS.line))

    local titleBar = CreateFrame("Frame", nil, window)
    titleBar:SetPoint("TOPLEFT"); titleBar:SetPoint("TOPRIGHT"); titleBar:SetHeight(42)
    local titleBg = Solid(titleBar); titleBg:SetAllPoints(); titleBg:SetVertexColor(unpack(COLORS.panel))
    local title = Font(titleBar, 15)
    title:SetPoint("LEFT", 16, 0); title:SetText(NS.L("Message privé"))
    titleBar:EnableMouse(true)
    titleBar:RegisterForDrag("LeftButton")
    titleBar:SetScript("OnDragStart", function() window:StartMoving() end)
    titleBar:SetScript("OnDragStop", function()
        window:StopMovingOrSizing()
        PM.SaveWindowPosition(CapturePosition(window, true))
    end)

    local close = MakeButton(titleBar, "×", 28, 24)
    close:SetPoint("RIGHT", -10, 0)
    if GameFontHighlightLarge and type(close.label.SetFontObject) == "function" then
        close.label:SetFontObject(GameFontHighlightLarge)
    else
        close.label:SetFont(FONT, 16, "")
    end
    close:SetScript("OnClick", function() window:Hide() end)

    local divider = Solid(window, "ARTWORK")
    divider:SetPoint("TOPLEFT", 278, -42); divider:SetPoint("BOTTOMLEFT", 278, 0)
    divider:SetWidth(1); divider:SetVertexColor(unpack(COLORS.line))

    local contactsTitle = Font(window, 11, COLORS.muted)
    contactsTitle:SetPoint("TOPLEFT", 14, -56)
    contactsTitle:SetText(NS.L("Conversations"))
    contactsScroll = CreateFrame("ScrollFrame", nil, window, "UIPanelScrollFrameTemplate")
    contactsScroll:SetPoint("TOPLEFT", 8, -76)
    contactsScroll:SetPoint("BOTTOMLEFT", 8, 12)
    contactsScroll:SetWidth(258)
    contactsChild = CreateFrame("Frame", nil, contactsScroll)
    contactsChild:SetSize(256, 1)
    contactsScroll:SetScrollChild(contactsChild)

    conversationHeader = Font(window, 14)
    conversationHeader:SetPoint("TOPLEFT", 298, -56)
    conversationHeader:SetPoint("RIGHT", -20, 0)
    conversationHeader:SetJustifyH("LEFT")
    conversationSubheader = Font(window, 9, COLORS.muted)
    conversationSubheader:SetPoint("TOPLEFT", conversationHeader, "BOTTOMLEFT", 0, -3)

    local composerFrame = CreateFrame("Frame", nil, window)
    composerFrame:SetPoint("BOTTOMLEFT", 292, 12)
    composerFrame:SetPoint("BOTTOMRIGHT", -12, 12)
    composerFrame:SetHeight(42)
    local composerBg = Solid(composerFrame); composerBg:SetAllPoints()
    composerBg:SetVertexColor(unpack(COLORS.field)); AddBorder(composerFrame)
    sendButton = MakeButton(composerFrame, NS.L("Envoyer"), 78, 28)
    sendButton:SetPoint("RIGHT", -7, 0)
    sendButton.background:SetVertexColor(unpack(COLORS.blueDark))
    sendButton:SetScript("OnClick", SendCurrentMessage)
    composer = CreateFrame("EditBox", nil, composerFrame)
    composer:SetPoint("LEFT", 12, 0); composer:SetPoint("RIGHT", sendButton, "LEFT", -10, 0)
    composer:SetHeight(28); composer:SetAutoFocus(false); composer:SetMaxLetters(255)
    if GameFontNormalLarge and type(composer.SetFontObject) == "function" then
        composer:SetFontObject(GameFontNormalLarge)
    else
        composer:SetFont(FONT, 13, "")
    end
    composer:SetTextColor(unpack(COLORS.text))
    composer:SetScript("OnEnterPressed", SendCurrentMessage)
    composer:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    placeholder = Font(composerFrame, 10, COLORS.muted)
    placeholder:SetPoint("LEFT", composer, "LEFT", 1, 0)
    placeholder:SetText(NS.L("Écris un message et appuie sur Entrée"))
    composer:SetScript("OnTextChanged", function(self)
        placeholder:SetShown(self:GetText() == "")
    end)

    transcriptScroll = CreateFrame("ScrollFrame", nil, window, "UIPanelScrollFrameTemplate")
    transcriptScroll:SetPoint("TOPLEFT", 286, -92)
    transcriptScroll:SetPoint("BOTTOMRIGHT", composerFrame, "TOPRIGHT", -18, 10)
    transcriptChild = CreateFrame("Frame", nil, transcriptScroll)
    transcriptChild:SetSize(560, 1)
    transcriptScroll:SetScrollChild(transcriptChild)
    transcriptScroll:SetScript("OnSizeChanged", function(_, width)
        transcriptChild:SetWidth(math.max(320, (width or 560) - 2))
        if window:IsShown() then RefreshTranscript() end
    end)

    local resize = CreateFrame("Button", nil, window)
    resize:SetSize(18, 18); resize:SetPoint("BOTTOMRIGHT", -1, 1)
    resize.texture = resize:CreateTexture(nil, "ARTWORK")
    resize.texture:SetAllPoints()
    resize.texture:SetTexture("Interface\\CHATFRAME\\UI-ChatIM-SizeGrabber-Up")
    resize:SetScript("OnMouseDown", function() window:StartSizing("BOTTOMRIGHT") end)
    resize:SetScript("OnMouseUp", function()
        window:StopMovingOrSizing()
        PM.SaveWindowPosition(CapturePosition(window, true))
        RefreshTranscript()
    end)

    window:SetScript("OnShow", function()
        EnsureActiveConversation()
        local key = PM.GetActiveKey()
        if key then PM.MarkRead(key) end
        RefreshContacts(); RefreshTranscript(); RefreshIcon()
    end)
    window:SetScript("OnHide", function() composer:ClearFocus(); RefreshIcon() end)
    window:Hide()
    UISpecialFrames[#UISpecialFrames + 1] = "AsylumCompagnonPrivateMessagesFrame"
    return window
end

function UI.ApplyWindowPosition()
    if not window then return end
    local state = PM.GetCharacterState()
    local position = state and state.window
    if not position then return end
    window:ClearAllPoints()
    window:SetPoint(position.point or "CENTER", UIParent,
        position.relativePoint or "CENTER", position.x or 0, position.y or 0)
    local parentWidth = UIParent and UIParent.GetWidth and UIParent:GetWidth() or 1480
    local parentHeight = UIParent and UIParent.GetHeight and UIParent:GetHeight() or 940
    local maxWidth = math.max(760, math.min(1440, parentWidth - 40))
    local maxHeight = math.max(480, math.min(900, parentHeight - 40))
    window:SetSize(math.min(maxWidth, math.max(760, position.width or 1120)),
        math.min(maxHeight, math.max(480, position.height or 680)))
end

function UI.ApplyIconPosition()
    if not icon then return end
    local state = PM.GetCharacterState()
    local position = state and state.icon
    if not position then return end
    icon:ClearAllPoints()
    icon:SetPoint(position.point or "CENTER", UIParent,
        position.relativePoint or "CENTER", position.x or 310, position.y or -120)
end

function UI.IsWindowShown()
    return window and window:IsShown() or false
end

function UI.SetWindowShown(shown)
    if not initialized then UI.Initialize() end
    if not window then return end
    if shown == true then window:Show() else window:Hide() end
end

function UI.ToggleWindow()
    if not initialized then UI.Initialize() end
    if window:IsShown() then window:Hide() else window:Show() end
end

function UI.ApplySettings()
    if not initialized then return end
    if not PM.IsEnabled() and window and window:IsShown() then window:Hide() end
    RefreshIcon()
    UpdateComposerState()
end

function UI.RefreshAll(reason, conversationKey)
    if not initialized then return end
    RefreshIcon()
    if not window or not window:IsShown() then return end
    if reason == "incoming" and conversationKey == PM.GetActiveKey() then
        PM.MarkRead(conversationKey)
    end
    RefreshContacts()
    RefreshTranscript()
end

function UI.Initialize()
    if initialized then return end
    if not PM or not PM.GetCharacterState or not PM.GetCharacterState() then return end
    CreateIcon()
    CreateWindow()
    UI.ApplyIconPosition()
    UI.ApplyWindowPosition()
    initialized = true
    UI.ApplySettings()
end
