
-- ============================================================
-- FILE: StandaloneChat.lua
-- PURPOSE: Detached floating chat windows — lets the player tear
--   off any channel feed (Local, Guild, Group, World variants,
--   DMs, Communities) into an independent draggable frame outside
--   the main Messenger panel.
-- HOW IT WORKS:
--   CHANNEL_OPTIONS declares every tearable channel type.
--   CreateStandaloneWindow() builds a frame with a themed header,
--   close/gear/lock buttons, a ScrollingMessageFrame, and an
--   inline settings panel (scale/width/height/font/opacity).
--   On PLAYER_LOGIN a loader frame iterates MidnightUISettings.
--   StandaloneWindows and restores each saved config via pcall.
--   Incoming messages are pushed here by the global
--   MidnightUI_RouteToStandaloneWindows(), called from Messenger.lua
--   whenever it would add a message to the main panel.
--   The channel-picker dropdown (MidnightUI_OpenStandaloneChatMenu)
--   is triggered by a "+" button in the Messenger header.
-- DEPENDS ON:
--   MidnightUI_GetMessengerSettings  (Messenger.lua — chat settings)
--   MidnightUI_GetChatTheme          (Messenger.lua — colour table)
--   MidnightUI_FormatChatMessage     (Messenger.lua — message formatter)
--   MidnightUI_ResolveFontPath       (Messenger.lua — font path resolver)
--   MessengerDB.History              (SavedVariable — back-fill on open)
--   MidnightUISettings.StandaloneWindows  (SavedVariable array)
--   MidnightUISettings.StandaloneWindowNextID (SavedVariable counter)
--   UIParent, UIErrorsFrame (Blizzard globals)
-- EXPORTS:
--   _G.MidnightUI_RouteToStandaloneWindows(category, worldFilterKey, dataObject)
--     Push a new message to every open window matching category/filter.
--   _G.MidnightUI_OpenStandaloneChatMenu(anchorFrame)
--     Toggle the channel-picker dropdown anchored below anchorFrame.
-- OWNED SAVEDVARIABLES:
--   MidnightUISettings.StandaloneWindows      — array of config tables
--   MidnightUISettings.StandaloneWindowNextID — monotone ID counter
-- EVENTS REGISTERED:
--   PLAYER_LOGIN (file-scope loader frame, one-shot; restores windows)
-- FRAMES CREATED:
--   MidnightUI_StandaloneChat_<id>  per window (up to MAX_WINDOWS)
--   MidnightUI_StandaloneChatMenu   channel picker (singleton)
-- GOTCHAS:
--   Window creation and menu open are blocked during InCombatLockdown.
--   MAX_WINDOWS=3 is enforced at both creation time and menu open.
--   World channel worldFilter="ALL" passes every message; specific
--   values (TRADE, GENERAL, SERVICES, DEFENSE) match data.tag.
--   Config objects in StandaloneWindows are keyed by .id, not array
--   index — SaveConfig and RemoveConfig scan the array by id.
-- ============================================================

local _G = _G
local CreateFrame = CreateFrame
local InCombatLockdown = InCombatLockdown
local pcall = pcall
local math = math
local table = table
local tostring = tostring
local string = string

-- ============================================================
-- SECTION: Module Constants + Channel Options
-- MAX_WINDOWS caps simultaneous windows to avoid frame spam.
-- activeWindows is the live registry: id → frame, used by routing
-- and menu-open guard checks.
-- CHANNEL_OPTIONS drives both the picker menu and the history
-- back-fill on window creation. worldFilter maps to message data.tag
-- substrings for World channel sub-filtering.
-- READS:  nothing (pure declarations)
-- WRITES: activeWindows (populated by CreateStandaloneWindow)
-- FUNCTIONS: (none — data only)
-- ============================================================
local MAX_WINDOWS = 3
local activeWindows = {}


local CHANNEL_OPTIONS = {
    { label = "Local",              category = "Local",  worldFilter = nil },
    { label = "Guild",              category = "Guild",  worldFilter = nil },
    { label = "Group",              category = "Group",  worldFilter = nil },
    { label = "World - All",        category = "World",  worldFilter = "ALL" },
    { label = "World - Trade",      category = "World",  worldFilter = "TRADE" },
    { label = "World - General",    category = "World",  worldFilter = "GENERAL" },
    { label = "World - Services",   category = "World",  worldFilter = "SERVICES" },
    { label = "World - Defense",    category = "World",  worldFilter = "DEFENSE" },
    { label = "Direct Messages",    category = "Direct", worldFilter = nil },
    { label = "Communities",        category = "Comm",   worldFilter = nil },
}


-- ============================================================
-- SECTION: Shared Utilities — Border + Theme
-- CreateBorder builds a 1px RGBA pixel border from four textures.
-- GetTheme pulls live colour values from Messenger's theme system
-- with a fallback table so the module works even before Messenger
-- loads (unlikely but safe).
-- ApplyThemeToWindow repaints all coloured regions of an open window
-- when opacity or theme changes; called from the settings panel
-- slider and on initial window construction.
-- READS:  MidnightUI_GetMessengerSettings, MidnightUI_GetChatTheme,
--         f._config.opacity, f._bg, f._headerBg, f._headerSep,
--         f._titleText, f._border
-- WRITES: texture colours on f's sub-elements
-- FUNCTIONS:
--   CreateBorder(f, r, g, b, a) → border frame with .SetColor()
--   GetTheme()                  → theme colour table
--   ApplyThemeToWindow(f)       → repaints all window colours
-- ============================================================
local function CreateBorder(f, r, g, b, a)
    local border = CreateFrame("Frame", nil, f)
    border:SetAllPoints()
    border:SetFrameLevel(f:GetFrameLevel() + 3)
    border.top = border:CreateTexture(nil, "OVERLAY"); border.top:SetHeight(1); border.top:SetPoint("TOPLEFT"); border.top:SetPoint("TOPRIGHT")
    border.bot = border:CreateTexture(nil, "OVERLAY"); border.bot:SetHeight(1); border.bot:SetPoint("BOTTOMLEFT"); border.bot:SetPoint("BOTTOMRIGHT")
    border.left = border:CreateTexture(nil, "OVERLAY"); border.left:SetWidth(1); border.left:SetPoint("TOPLEFT"); border.left:SetPoint("BOTTOMLEFT")
    border.right = border:CreateTexture(nil, "OVERLAY"); border.right:SetWidth(1); border.right:SetPoint("TOPRIGHT"); border.right:SetPoint("BOTTOMRIGHT")
    local function SetColor(cr, cg, cb, ca)
        border.top:SetColorTexture(cr, cg, cb, ca)
        border.bot:SetColorTexture(cr, cg, cb, ca)
        border.left:SetColorTexture(cr, cg, cb, ca)
        border.right:SetColorTexture(cr, cg, cb, ca)
    end
    SetColor(r, g, b, a)
    border.SetColor = SetColor
    return border
end

local function GetTheme()
    local settings = MidnightUI_GetMessengerSettings and MidnightUI_GetMessengerSettings()
    local style = settings and settings.style or "Default"
    if _G.MidnightUI_GetChatTheme then
        return _G.MidnightUI_GetChatTheme(style)
    end
    return {
        chatBg = {0.09, 0.11, 0.18},
        separator = {0.16, 0.19, 0.25},
        accent = {0, 0.8, 1},
    }
end

local function ApplyThemeToWindow(f)
    local theme = GetTheme()
    local cfg = f._config
    local alpha = cfg and cfg.opacity or 0.85

    local bg = theme.chatBg
    if f._bg then
        f._bg:SetColorTexture(bg[1], bg[2], bg[3], alpha)
    end
    if f._headerBg then
        f._headerBg:SetColorTexture(bg[1] + 0.03, bg[2] + 0.03, bg[3] + 0.03, math.min(alpha + 0.1, 1))
    end
    if f._headerSep then
        local sep = theme.separator
        f._headerSep:SetColorTexture(sep[1], sep[2], sep[3], 1)
    end
    if f._titleText then
        local acc = theme.accent
        f._titleText:SetTextColor(acc[1], acc[2], acc[3])
    end
    if f._border then
        f._border.SetColor(0, 0, 0, 1)
    end
end


-- ============================================================
-- SECTION: Config Persistence
-- Configs are stored as plain tables in
-- MidnightUISettings.StandaloneWindows (array). Each entry has
-- an .id field that is used as the key for upserts and removals.
-- SaveConfig does an id-based upsert: update in-place if found,
-- append if new. RemoveConfig scans and removes by id.
-- SaveFramePosition captures the current GetPoint(1) result into
-- f._config and then calls SaveConfig — used on OnDragStop.
-- READS:  MidnightUISettings.StandaloneWindows, f._config, f:GetPoint
-- WRITES: MidnightUISettings.StandaloneWindows
-- FUNCTIONS:
--   SaveConfig(config)      — upsert config by .id
--   RemoveConfig(id)        — remove config by id
--   SaveFramePosition(f)    — snapshot drag position then SaveConfig
-- ============================================================
local function SaveConfig(config)
    if not MidnightUISettings.StandaloneWindows then MidnightUISettings.StandaloneWindows = {} end
    for i, c in ipairs(MidnightUISettings.StandaloneWindows) do
        if c.id == config.id then
            MidnightUISettings.StandaloneWindows[i] = config
            return
        end
    end
    table.insert(MidnightUISettings.StandaloneWindows, config)
end

local function RemoveConfig(id)
    if not MidnightUISettings.StandaloneWindows then return end
    for i, c in ipairs(MidnightUISettings.StandaloneWindows) do
        if c.id == id then
            table.remove(MidnightUISettings.StandaloneWindows, i)
            return
        end
    end
end

local function SaveFramePosition(f)
    if not f or not f._config then return end
    local point, _, relPoint, x, y = f:GetPoint(1)
    if point then
        f._config.point = point
        f._config.relPoint = relPoint
        f._config.x = x
        f._config.y = y
    end
    SaveConfig(f._config)
end


-- ============================================================
-- SECTION: Per-Window Settings Panel
-- A small floating FULLSCREEN_DIALOG frame that appears to the
-- left of the parent window when the gear button is clicked.
-- Contains five OptionsSliderTemplate sliders: Scale %, Width,
-- Height, Font Size, Opacity %. Each slider's OnValueChanged
-- immediately applies the change to the live window and calls
-- SaveConfig so the value persists across reloads.
-- The panel's OnShow repositions itself relative to the parent
-- using GetEffectiveScale correction so it lines up correctly
-- regardless of UIParent scale or parent window scale.
-- READS:  parent._config (cfg), parent._msgFrame, MidnightUI_ResolveFontPath
-- WRITES: cfg.scale/width/height/fontSize/opacity, parent frame properties
-- FUNCTIONS:
--   CreateSettingsPanel(parent) → panel frame (stored as parent._settingsPanel)
--   AddSlider(label, min, max, step, getter, setter) [upvalue] → slider frame
-- ============================================================
local function CreateSettingsPanel(parent)
    local panel = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    panel:SetSize(210, 240)
    panel:SetFrameStrata("FULLSCREEN_DIALOG")
    panel:SetClampedToScreen(true)
    panel:EnableMouse(true)
    panel:Hide()

    local theme = GetTheme()
    local bg = theme.chatBg
    local sep = theme.separator
    panel:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1,
                        insets = { left = 1, right = 1, top = 1, bottom = 1 } })
    panel:SetBackdropColor(bg[1], bg[2], bg[3], 0.96)
    panel:SetBackdropBorderColor(sep[1], sep[2], sep[3], 1)
    CreateBorder(panel, 0, 0, 0, 1)

    local panelTitle = panel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    panelTitle:SetPoint("TOPLEFT", 10, -8)
    panelTitle:SetText("Window Settings")
    panelTitle:SetTextColor(0.7, 0.75, 0.85)

    local yOff = -28
    local function AddSlider(label, min, max, step, getter, setter)
        local lbl = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        lbl:SetPoint("TOPLEFT", 10, yOff)
        lbl:SetText(label)
        yOff = yOff - 14

        local slider = CreateFrame("Slider", nil, panel, "OptionsSliderTemplate")
        slider:SetPoint("TOPLEFT", 14, yOff)
        slider:SetSize(178, 16)
        slider:SetMinMaxValues(min, max)
        slider:SetValueStep(step)
        slider:SetObeyStepOnDrag(true)
        slider:SetValue(getter())
        slider.Low:SetText("")
        slider.High:SetText("")
        slider.Text:SetFontObject("GameFontHighlightSmall")
        slider.Text:SetText(tostring(math.floor(getter() + 0.5)))
        slider:SetScript("OnValueChanged", function(self, val)
            val = math.floor(val / step + 0.5) * step
            self.Text:SetText(tostring(math.floor(val + 0.5)))
            setter(val)
        end)
        yOff = yOff - 30
        return slider
    end

    local cfg = parent._config

    AddSlider("Scale %", 50, 200, 5,
        function() return (cfg.scale or 1) * 100 end,
        function(v) cfg.scale = v / 100; parent:SetScale(cfg.scale); SaveConfig(cfg) end)

    AddSlider("Width", 200, 800, 10,
        function() return cfg.width or 400 end,
        function(v) cfg.width = v; parent:SetWidth(v); SaveConfig(cfg) end)

    AddSlider("Height", 120, 600, 10,
        function() return cfg.height or 250 end,
        function(v) cfg.height = v; parent:SetHeight(v); SaveConfig(cfg) end)

    AddSlider("Font Size", 8, 24, 1,
        function() return cfg.fontSize or 14 end,
        function(v)
            cfg.fontSize = v
            if parent._msgFrame then
                local fontPath = (_G.MidnightUI_ResolveFontPath and _G.MidnightUI_ResolveFontPath("Fonts\\ARIALN.TTF")) or "Fonts\\ARIALN.TTF"
                parent._msgFrame:SetFont(fontPath, v, "")
            end
            SaveConfig(cfg)
        end)

    AddSlider("Opacity %", 10, 100, 5,
        function() return (cfg.opacity or 0.85) * 100 end,
        function(v) cfg.opacity = v / 100; ApplyThemeToWindow(parent); SaveConfig(cfg) end)

    panel._parentWindow = parent
    panel:SetScript("OnShow", function(self)
        self:ClearAllPoints()
        local pw = self._parentWindow
        if pw then
            local scale = pw:GetEffectiveScale() / UIParent:GetEffectiveScale()
            local left = pw:GetLeft() * scale
            self:SetPoint("TOPRIGHT", UIParent, "BOTTOMLEFT", left - 4, pw:GetTop() * scale)
        end
    end)

    return panel
end


-- ============================================================
-- SECTION: Standalone Window Constructor
-- CreateStandaloneWindow() is the core factory. It:
--   1. Guards against duplicate creation via activeWindows[config.id].
--   2. Builds the frame: background, 1px border, header bar,
--      header separator, title text.
--   3. Adds three header buttons: close (×), gear (settings panel
--      toggle), lock (prevents drag when green).
--   4. Wires drag: OnDragStart checks config.locked; OnDragStop
--      calls SaveFramePosition.
--   5. Creates the ScrollingMessageFrame with 500-line history,
--      mouse-wheel scrolling, hyperlink forwarding to
--      ChatFrame_OnHyperlinkShow.
--   6. Back-fills from MessengerDB.History[config.category] with
--      World sub-filter logic (worldFilter string matched against
--      data.tag substrings).
--   7. Registers the frame in activeWindows[config.id].
-- config table fields: id, label, category, worldFilter, width,
-- height, scale, fontSize, opacity, locked, point, relPoint, x, y.
-- READS:  MessengerDB.History, MidnightUI_FormatChatMessage,
--         MidnightUI_ResolveFontPath, config fields
-- WRITES: activeWindows[config.id]
-- FUNCTIONS:
--   CreateStandaloneWindow(config) → window frame (or existing frame)
--   UpdateLockVisual() [upvalue]   → sync lock icon tint/desaturate
-- ============================================================
local function CreateStandaloneWindow(config)
    if activeWindows[config.id] then return activeWindows[config.id] end

    local HEADER_HEIGHT = 28

    local f = CreateFrame("Frame", "MidnightUI_StandaloneChat_" .. config.id, UIParent)
    f:SetSize(config.width or 400, config.height or 250)
    f:SetPoint(config.point or "CENTER", UIParent, config.relPoint or "CENTER", config.x or 0, config.y or 0)
    f:SetScale(config.scale or 1)
    f:SetFrameStrata("MEDIUM")
    f:SetFrameLevel(50)
    f:SetClampedToScreen(true)
    f:EnableMouse(true)
    f:SetMovable(true)
    f._config = config

    local bg = f:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    f._bg = bg

    f._border = CreateBorder(f, 0, 0, 0, 1)

    local headerBg = f:CreateTexture(nil, "ARTWORK")
    headerBg:SetPoint("TOPLEFT", 1, -1)
    headerBg:SetPoint("TOPRIGHT", -1, -1)
    headerBg:SetHeight(HEADER_HEIGHT)
    f._headerBg = headerBg

    local headerSep = f:CreateTexture(nil, "ARTWORK", nil, 1)
    headerSep:SetPoint("TOPLEFT", headerBg, "BOTTOMLEFT")
    headerSep:SetPoint("TOPRIGHT", headerBg, "BOTTOMRIGHT")
    headerSep:SetHeight(1)
    f._headerSep = headerSep

    local titleText = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    titleText:SetPoint("LEFT", headerBg, "LEFT", 10, 0)
    titleText:SetText(config.label or config.category or "Chat")
    f._titleText = titleText

    local closeBtn = CreateFrame("Button", nil, f)
    closeBtn:SetSize(20, 20)
    closeBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -6, -4)
    closeBtn:SetFrameLevel(f:GetFrameLevel() + 5)
    closeBtn.label = closeBtn:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    closeBtn.label:SetPoint("CENTER", 0, 0)
    closeBtn.label:SetText("\195\151")
    closeBtn.label:SetTextColor(0.5, 0.5, 0.5)
    closeBtn:SetScript("OnEnter", function(self) self.label:SetTextColor(1, 0.2, 0.2) end)
    closeBtn:SetScript("OnLeave", function(self) self.label:SetTextColor(0.5, 0.5, 0.5) end)
    closeBtn:SetScript("OnClick", function()
        activeWindows[config.id] = nil
        RemoveConfig(config.id)
        if f._settingsPanel then f._settingsPanel:Hide() end
        f:Hide()
        f:SetParent(nil)
    end)

    local gearBtn = CreateFrame("Button", nil, f)
    gearBtn:SetSize(20, 20)
    gearBtn:SetPoint("RIGHT", closeBtn, "LEFT", -2, 0)
    gearBtn:SetFrameLevel(f:GetFrameLevel() + 5)
    local gearIcon = gearBtn:CreateTexture(nil, "OVERLAY")
    gearIcon:SetSize(18, 18)
    gearIcon:SetPoint("CENTER")
    gearIcon:SetAtlas("GarrMission_MissionIcon-Engineering", false)
    gearIcon:SetSize(20, 20)
    gearIcon:SetVertexColor(0.6, 0.65, 0.75)
    gearBtn:SetScript("OnEnter", function() gearIcon:SetVertexColor(1, 1, 1) end)
    gearBtn:SetScript("OnLeave", function() gearIcon:SetVertexColor(0.6, 0.65, 0.75) end)

    local settingsPanel = CreateSettingsPanel(f)
    f._settingsPanel = settingsPanel
    gearBtn:SetScript("OnClick", function()
        if settingsPanel:IsShown() then settingsPanel:Hide() else settingsPanel:Show() end
    end)

    local lockBtn = CreateFrame("Button", nil, f)
    lockBtn:SetSize(20, 20)
    lockBtn:SetPoint("RIGHT", gearBtn, "LEFT", -6, 0)
    lockBtn:SetFrameLevel(f:GetFrameLevel() + 5)
    local lockIcon = lockBtn:CreateTexture(nil, "OVERLAY")
    lockIcon:SetPoint("CENTER")
    lockIcon:SetAtlas("UI-CharacterCreate-PadLock", false)
    lockIcon:SetSize(20, 20)

    local function UpdateLockVisual()
        if config.locked then
            lockIcon:SetVertexColor(0.4, 0.8, 0.4)
            lockIcon:SetDesaturated(false)
        else
            lockIcon:SetVertexColor(0.8, 0.6, 0.3)
            lockIcon:SetDesaturated(true)
        end
    end
    UpdateLockVisual()

    lockBtn:SetScript("OnEnter", function() lockIcon:SetVertexColor(1, 1, 1); lockIcon:SetDesaturated(false) end)
    lockBtn:SetScript("OnLeave", function() UpdateLockVisual() end)
    lockBtn:SetScript("OnClick", function()
        config.locked = not config.locked
        UpdateLockVisual()
        SaveConfig(config)
    end)

    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", function(self)
        if not config.locked then self:StartMoving() end
    end)
    f:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        SaveFramePosition(self)
    end)

    local msgFrame = CreateFrame("ScrollingMessageFrame", nil, f)
    msgFrame:SetPoint("TOPLEFT", 8, -(HEADER_HEIGHT + 4))
    msgFrame:SetPoint("BOTTOMRIGHT", -8, 6)
    do
        local fontPath = (_G.MidnightUI_ResolveFontPath and _G.MidnightUI_ResolveFontPath("Fonts\\ARIALN.TTF")) or "Fonts\\ARIALN.TTF"
        msgFrame:SetFont(fontPath, config.fontSize or 14, "")
    end
    msgFrame:SetJustifyH("LEFT")
    msgFrame:SetFading(false)
    msgFrame:SetMaxLines(500)
    msgFrame:SetSpacing(4)
    msgFrame:EnableMouseWheel(true)
    msgFrame:SetScript("OnMouseWheel", function(self, delta)
        if delta > 0 then self:ScrollUp() else self:ScrollDown() end
    end)
    msgFrame:SetHyperlinksEnabled(true)
    msgFrame:SetScript("OnHyperlinkClick", ChatFrame_OnHyperlinkShow)
    f._msgFrame = msgFrame

    ApplyThemeToWindow(f)

    local FormatMessage = _G.MidnightUI_FormatChatMessage
    if FormatMessage and MessengerDB and MessengerDB.History then
        local hist = MessengerDB.History[config.category]
        if hist and hist.messages then
            for _, data in ipairs(hist.messages) do
                local show = true
                if config.category == "World" and config.worldFilter and config.worldFilter ~= "ALL" then
                    if data.tag then
                        local tagUpper = data.tag:upper()
                        if config.worldFilter == "TRADE" and not tagUpper:find("TRADE") then show = false end
                        if config.worldFilter == "SERVICES" and not tagUpper:find("SERVICE") then show = false end
                        if config.worldFilter == "GENERAL" and not tagUpper:find("GENERAL") then show = false end
                        if config.worldFilter == "DEFENSE" and not tagUpper:find("DEFENSE") then show = false end
                    else
                        show = false
                    end
                end
                if show then
                    local formatted = FormatMessage(data)
                    if formatted and formatted ~= "" then
                        msgFrame:AddMessage(formatted)
                    end
                end
            end
        end
    end

    f:Show()
    activeWindows[config.id] = f
    return f
end


-- ============================================================
-- SECTION: Public API — Message Routing
-- Called by Messenger.lua every time it adds a message to the
-- main panel. Iterates all activeWindows and pushes the formatted
-- message to any window whose category matches. For World windows
-- with a specific worldFilter (not "ALL"), only messages whose
-- worldFilterKey matches cfg.worldFilter are shown.
-- MidnightUI_FormatChatMessage is re-resolved each call (not cached)
-- because Messenger.lua may not be loaded yet at file-scope time.
-- READS:  activeWindows, f._config, f._msgFrame,
--         MidnightUI_FormatChatMessage
-- WRITES: f._msgFrame (AddMessage)
-- FUNCTIONS:
--   MidnightUI_RouteToStandaloneWindows(category, worldFilterKey, dataObject)
-- ============================================================
_G.MidnightUI_RouteToStandaloneWindows = function(category, worldFilterKey, dataObject)
    local FormatMessage = _G.MidnightUI_FormatChatMessage
    if not FormatMessage then return end

    for id, f in pairs(activeWindows) do
        local cfg = f._config
        if cfg and cfg.category == category then
            local show = true
            if cfg.category == "World" and cfg.worldFilter and cfg.worldFilter ~= "ALL" then
                if worldFilterKey ~= cfg.worldFilter then show = false end
            end
            if show and f._msgFrame then
                local formatted = FormatMessage(dataObject)
                if formatted and formatted ~= "" then
                    f._msgFrame:AddMessage(formatted)
                end
            end
        end
    end
end


-- ============================================================
-- SECTION: Channel Picker Menu
-- A singleton FULLSCREEN_DIALOG dropdown that lists every entry
-- in CHANNEL_OPTIONS. Clicking an entry calls CreateChannelFromOption
-- which enforces InCombatLockdown and MAX_WINDOWS checks, builds
-- a config from current Messenger settings defaults, and calls
-- CreateStandaloneWindow.
-- BuildChannelMenu() lazily constructs the menu once; subsequent
-- calls return the cached frame.
-- MidnightUI_OpenStandaloneChatMenu() is the public entry point
-- triggered by the Messenger "+" button. It re-checks combat and
-- MAX_WINDOWS guards before showing the menu and anchors it below
-- the anchorFrame via GetEffectiveScale correction.
-- The menu handles ESCAPE via OnKeyDown with SetPropagateKeyboardInput
-- so ESCAPE closes the menu without also closing other UI panels.
-- READS:  activeWindows (count), MidnightUI_GetMessengerSettings,
--         MidnightUISettings.StandaloneWindowNextID
-- WRITES: MidnightUISettings.StandaloneWindowNextID,
--         activeWindows (via CreateStandaloneWindow),
--         channelMenu (singleton cache)
-- FUNCTIONS:
--   CreateChannelFromOption(opt)          — validate + build config + open window
--   BuildChannelMenu()                    → menu frame (lazy singleton)
--   MidnightUI_OpenStandaloneChatMenu(anchorFrame) — public toggle entry point
-- ============================================================
local channelMenu = nil

local function CreateChannelFromOption(opt)
    if InCombatLockdown() then
        if UIErrorsFrame then UIErrorsFrame:AddMessage("Cannot create chat windows during combat.", 1, 0.3, 0.3) end
        return
    end
    local count = 0
    for _ in pairs(activeWindows) do count = count + 1 end
    if count >= MAX_WINDOWS then
        if UIErrorsFrame then UIErrorsFrame:AddMessage("Maximum of " .. MAX_WINDOWS .. " standalone windows reached.", 1, 0.6, 0.2) end
        return
    end

    local msgrSettings = MidnightUI_GetMessengerSettings and MidnightUI_GetMessengerSettings()
    local defW = msgrSettings and msgrSettings.width or 400
    local defH = msgrSettings and msgrSettings.height or 250

    local id = MidnightUISettings.StandaloneWindowNextID or 1
    MidnightUISettings.StandaloneWindowNextID = id + 1

    local config = {
        id = id,
        label = opt.label,
        category = opt.category,
        worldFilter = opt.worldFilter,
        width = defW,
        height = defH,
        scale = 1.0,
        fontSize = msgrSettings and msgrSettings.fontSize or 14,
        opacity = msgrSettings and msgrSettings.alpha or 0.85,
        locked = false,
        point = "CENTER",
        relPoint = "CENTER",
        x = 50 * id,
        y = -30 * id,
    }
    SaveConfig(config)
    CreateStandaloneWindow(config)
end

local function BuildChannelMenu()
    if channelMenu then return channelMenu end

    local theme = GetTheme()
    local bg = theme.chatBg
    local acc = theme.accent

    local ITEM_HEIGHT = 22
    local MENU_WIDTH = 180
    local PADDING = 4

    local menu = CreateFrame("Frame", "MidnightUI_StandaloneChatMenu", UIParent)
    menu:SetSize(MENU_WIDTH, (#CHANNEL_OPTIONS * ITEM_HEIGHT) + (PADDING * 2))
    menu:SetFrameStrata("FULLSCREEN_DIALOG")
    menu:SetClampedToScreen(true)
    menu:EnableMouse(true)
    menu:Hide()

    local menuBg = menu:CreateTexture(nil, "BACKGROUND")
    menuBg:SetAllPoints()
    menuBg:SetColorTexture(bg[1], bg[2], bg[3], 0.95)

    CreateBorder(menu, 0, 0, 0, 1)

    for i, opt in ipairs(CHANNEL_OPTIONS) do
        local btn = CreateFrame("Button", nil, menu)
        btn:SetSize(MENU_WIDTH - (PADDING * 2), ITEM_HEIGHT)
        btn:SetPoint("TOPLEFT", PADDING, -PADDING - ((i - 1) * ITEM_HEIGHT))

        local hl = btn:CreateTexture(nil, "HIGHLIGHT")
        hl:SetAllPoints()
        hl:SetColorTexture(acc[1], acc[2], acc[3], 0.15)

        btn.text = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        btn.text:SetPoint("LEFT", 8, 0)
        btn.text:SetText(opt.label)
        btn.text:SetTextColor(0.85, 0.88, 0.95)

        btn:SetScript("OnClick", function()
            menu:Hide()
            CreateChannelFromOption(opt)
        end)
    end

    menu:SetScript("OnKeyDown", function(self, key)
        if key == "ESCAPE" then
            self:Hide()
            self:SetPropagateKeyboardInput(false)
        else
            self:SetPropagateKeyboardInput(true)
        end
    end)

    channelMenu = menu
    return menu
end

_G.MidnightUI_OpenStandaloneChatMenu = function(anchorFrame)
    if InCombatLockdown() then
        if UIErrorsFrame then UIErrorsFrame:AddMessage("Cannot create chat windows during combat.", 1, 0.3, 0.3) end
        return
    end

    local count = 0
    for _ in pairs(activeWindows) do count = count + 1 end
    if count >= MAX_WINDOWS then
        if UIErrorsFrame then UIErrorsFrame:AddMessage("Maximum of " .. MAX_WINDOWS .. " standalone windows reached.", 1, 0.6, 0.2) end
        return
    end

    local menu = BuildChannelMenu()
    if menu:IsShown() then
        menu:Hide()
        return
    end
    menu:ClearAllPoints()
    menu:SetPoint("TOPLEFT", anchorFrame, "BOTTOMLEFT", 0, -4)
    menu:Show()
    menu:Raise()
end


-- ============================================================
-- SECTION: Startup Loader — Restore Saved Windows
-- A one-shot PLAYER_LOGIN listener that iterates
-- MidnightUISettings.StandaloneWindows and calls
-- CreateStandaloneWindow for each saved config.
-- pcall wraps each call so a corrupt config entry cannot prevent
-- other windows from restoring. Unregisters itself after firing.
-- READS:  MidnightUISettings.StandaloneWindows
-- WRITES: activeWindows (via CreateStandaloneWindow)
-- FUNCTIONS: (inline OnEvent handler only)
-- ============================================================
local loader = CreateFrame("Frame")
loader:RegisterEvent("PLAYER_LOGIN")
loader:SetScript("OnEvent", function(self, event)
    self:UnregisterEvent("PLAYER_LOGIN")
    if not MidnightUISettings or not MidnightUISettings.StandaloneWindows then return end
    for _, config in ipairs(MidnightUISettings.StandaloneWindows) do
        pcall(CreateStandaloneWindow, config)
    end
end)
