-- A friends list replacement for World of Warcraft with Battle.net support
-- UI GLUE LAYER - XML Callbacks and thin wrappers for modular backend
-- Main business logic is in Modules/ folder

local ADDON_NAME, BFL = ...
local ADDON_VERSION = BFL.Version
local L = BFL.L

-- ========================================
-- MODULE ACCESSORS
-- ========================================
-- Get modules
local function GetDB()
	return BFL:GetModule("DB")
end
local function GetGroups()
	return BFL:GetModule("Groups")
end
local function GetFriendsList()
	return BFL and BFL:GetModule("FriendsList")
end
local function GetWhoFrame()
	return BFL and BFL:GetModule("WhoFrame")
end
local function GetIgnoreList()
	return BFL and BFL:GetModule("IgnoreList")
end
local function GetRecentAllies()
	return BFL and BFL:GetModule("RecentAllies")
end
local function GetQuickFilters()
	return BFL and BFL:GetModule("QuickFilters")
end
local function GetMenuSystem()
	return BFL and BFL:GetModule("MenuSystem")
end
local function GetRAF()
	return BFL and BFL:GetModule("RAF")
end
local FontManager = nil -- Will be initialized after modules load
local ColorManager = nil -- Will be initialized after modules load

local function SetSearchBoxInstruction(searchBox, text)
	if not searchBox or not searchBox.Instructions then
		return
	end

	local instructions = searchBox.Instructions
	instructions:SetText(text or SEARCH or "Search")
	if instructions.SetWordWrap then
		instructions:SetWordWrap(false)
	end
	if instructions.SetMaxLines then
		instructions:SetMaxLines(1)
	end
end

local function UpdateSearchBoxInstruction(searchBox, tabIndex)
	local locale = BFL.L or L or {}
	if tabIndex == 1 then
		SetSearchBoxInstruction(searchBox, locale.SEARCH_FRIENDS_INSTRUCTION)
	elseif tabIndex == 2 then
		SetSearchBoxInstruction(searchBox, locale.SEARCH_RECENT_ALLIES_INSTRUCTION)
	elseif tabIndex == 3 then
		SetSearchBoxInstruction(searchBox, locale.SEARCH_RAF_INSTRUCTION)
	elseif tabIndex == 4 then
		SetSearchBoxInstruction(searchBox, locale.GUILD_SEARCH_PLACEHOLDER)
	else
		SetSearchBoxInstruction(searchBox, SEARCH or "Search")
	end
end

-- Initialize manager references
local function InitializeManagers()
	if not FontManager then
		FontManager = BFL.FontManager
	end
	if not ColorManager then
		ColorManager = BFL.ColorManager
	end
end

-- Define color constants if they don't exist (used by Recent Allies)
-- Note: Blizzard uses FRIENDS_OFFLINE_BACKGROUND_COLOR (with S), not FRIEND_OFFLINE_BACKGROUND_COLOR
if not FRIENDS_WOW_BACKGROUND_COLOR then
	FRIENDS_WOW_BACKGROUND_COLOR = CreateColor(0.101961, 0.149020, 0.196078, 1)
end
if not FRIENDS_OFFLINE_BACKGROUND_COLOR then
	FRIENDS_OFFLINE_BACKGROUND_COLOR = CreateColor(0.35, 0.35, 0.35, 1)
end

-- ========================================
-- Global Functions (for XML OnClick handlers)
-- ========================================

-- Toggle group collapsed/expanded state (called from XML)
function BetterFriendsList_ToggleGroup(groupId)
	local FriendsList = BFL:GetModule("FriendsList")
	if FriendsList then
		FriendsList:ToggleGroup(groupId)
	end
end

-- Toggle friend in group (called from Drag & Drop system - IDENTICAL to old system)
function BetterFriendsList_ToggleFriendInGroup(friendUID, groupId)
	local Groups = BFL:GetModule("Groups")
	if Groups then
		Groups:ToggleFriendInGroup(friendUID, groupId)
	end
end

-- ========================================
-- Constants and Display State
-- ========================================

-- Constants
-- NUM_BUTTONS handled by ScrollBox

-- BFL-owned invite restriction values are produced by Modules/FriendsList.lua.
-- Blizzard's native invite restriction constants are local to FriendsFrame.lua.
local BFL_INVITE_RESTRICTION_NONE = 0
local BFL_INVITE_RESTRICTION_LEADER = 1
local BFL_INVITE_RESTRICTION_FACTION = 2
local BFL_INVITE_RESTRICTION_REALM = 3
local BFL_INVITE_RESTRICTION_INFO = 4
local BFL_INVITE_RESTRICTION_CLIENT = 5
local BFL_INVITE_RESTRICTION_WOW_PROJECT_ID = 6
local BFL_INVITE_RESTRICTION_WOW_PROJECT_MAINLINE = 7
local BFL_INVITE_RESTRICTION_WOW_PROJECT_CLASSIC = 8
local BFL_INVITE_RESTRICTION_MOBILE = 9
local BFL_INVITE_RESTRICTION_REGION = 10
local BFL_INVITE_RESTRICTION_QUEST_SESSION = 11
local BFL_INVITE_RESTRICTION_NO_GAME_ACCOUNTS = 12
local BFL_INVITE_RESTRICTION_GAME_MODE = 13

local function GetBFLInviteRestrictionText(restriction, locale)
	if restriction == nil or restriction == BFL_INVITE_RESTRICTION_NONE then
		return nil
	end

	local L = locale or BFL.L or {}
	if restriction == BFL_INVITE_RESTRICTION_LEADER then
		return ERR_TRAVEL_PASS_NOT_LEADER or L.TRAVEL_PASS_NOT_LEADER or "You are not the group leader"
	elseif restriction == BFL_INVITE_RESTRICTION_CLIENT then
		return ERR_TRAVEL_PASS_NOT_WOW or L.TRAVEL_PASS_NOT_WOW or "Not playing WoW"
	elseif restriction == BFL_INVITE_RESTRICTION_WOW_PROJECT_CLASSIC then
		return ERR_TRAVEL_PASS_WRONG_PROJECT_CLASSIC_OVERRIDE
			or L.TRAVEL_PASS_WOW_CLASSIC
			or "Different WoW version (Classic)"
	elseif restriction == BFL_INVITE_RESTRICTION_WOW_PROJECT_MAINLINE then
		return ERR_TRAVEL_PASS_WRONG_PROJECT_MAINLINE_OVERRIDE
			or L.TRAVEL_PASS_WOW_MAINLINE
			or "Different WoW version (Retail)"
	elseif restriction == BFL_INVITE_RESTRICTION_WOW_PROJECT_ID then
		return ERR_TRAVEL_PASS_WRONG_PROJECT or L.TRAVEL_PASS_DIFFERENT_VERSION or "Different WoW version"
	elseif restriction == BFL_INVITE_RESTRICTION_REALM then
		return ERR_TRAVEL_PASS_DIFFERENT_REALM or L.TRAVEL_PASS_DIFFERENT_REALM or "Different realm"
	elseif restriction == BFL_INVITE_RESTRICTION_INFO then
		return ERR_TRAVEL_PASS_NO_INFO or L.TRAVEL_PASS_NO_INFO or "No realm info"
	elseif restriction == BFL_INVITE_RESTRICTION_MOBILE then
		return ERR_TRAVEL_PASS_MOBILE or L.TRAVEL_PASS_MOBILE or "Cannot invite mobile friends"
	elseif restriction == BFL_INVITE_RESTRICTION_REGION then
		return ERR_TRAVEL_PASS_DIFFERENT_REGION or L.TRAVEL_PASS_DIFFERENT_REGION or "Different region"
	elseif restriction == BFL_INVITE_RESTRICTION_NO_GAME_ACCOUNTS then
		return L.TRAVEL_PASS_NO_GAME_ACCOUNTS or "No game accounts"
	elseif restriction == BFL_INVITE_RESTRICTION_FACTION then
		return L.TRAVEL_PASS_DIFFERENT_FACTION or "Different faction"
	elseif restriction == BFL_INVITE_RESTRICTION_QUEST_SESSION then
		return L.TRAVEL_PASS_QUEST_SESSION or "Quest Session"
	elseif restriction == BFL_INVITE_RESTRICTION_GAME_MODE then
		return ERR_TRAVEL_PASS_GAME_MODE or L.TRAVEL_PASS_GAME_MODE or "Different game mode"
	end
	return nil
end

-- Display state
-- friendsList handled by FriendsList module

-- Button management handled by ScrollBox Factory pattern

-- Performance optimization: Throttle updates to prevent lag
local lastUpdateTime = 0
local UPDATE_THROTTLE = 0.1 -- Only update every 0.1 seconds maximum
local pendingUpdate = false

-- Sort mode tracking
local currentSortMode = "status" -- Default: status, name, level, zone

-- Quick filter state (session-only, not persistent)
local filterMode = "all" -- Options: "all", "online", "wowonline", "wow", "bnet", "offline", "ingame"

-- Load filter mode from database
local function LoadFilterMode()
	local DB = BFL:GetModule("DB")
	if DB then
		local db = DB:Get()
		if db and db.quickFilter then
			filterMode = db.quickFilter
		end
	end
end

-- Button types (matching FriendsList module constants)
local BUTTON_TYPE_FRIEND = 1
local BUTTON_TYPE_GROUP_HEADER = 2

-- Visual settings helpers (wrappers for FontManager)
local function GetButtonHeight()
	InitializeManagers()
	return FontManager:GetButtonHeight()
end

local function GetFontSizeMultiplier()
	InitializeManagers()
	return FontManager:GetFontSizeMultiplier()
end

local function ApplyFontSize(fontString)
	InitializeManagers()
	FontManager:ApplyFontSize(fontString)
end

local function GetFontFlags(flags)
	InitializeManagers()
	if FontManager and FontManager.GetFontFlags then
		return FontManager:GetFontFlags(flags)
	end
	return flags
end

local function SafeSetFont(fontObject, fontPath, fontSize, flags)
	InitializeManagers()
	if FontManager and FontManager.SafeSetFont and FontManager:SafeSetFont(fontObject, fontPath, fontSize, flags) then
		return true
	end

	if fontObject and fontObject.SetFont and fontPath and fontSize then
		local ok, result = pcall(fontObject.SetFont, fontObject, fontPath, fontSize, flags)
		return ok and result ~= false
	end
	return false
end

local function GetTextWidth(fontString, fallback, text)
	InitializeManagers()
	if FontManager and FontManager.GetTextWidth then
		local width = FontManager:GetTextWidth(fontString, text)
		if width and width > 0 then
			return width
		end
	end
	return fallback or 0
end

-- Re-apply enforced width and font on a BFL tab AFTER Blizzard's PanelTemplates_TabResize
-- overwrites them. Call this directly after any PanelTemplates_TabResize on BFL tabs.
local function BFL_EnforceTabWidth(tab)
	if not tab or not tab.BFL_EnforcedWidth then
		return
	end
	tab.BFL_ResizeLock = true
	tab:SetWidth(tab.BFL_EnforcedWidth)
	-- Restore text fontstring width and font that Blizzard's TabResize overwrites.
	-- PanelTemplates_TabResize sets tab.Text:SetWidth(textWidth) at the end,
	-- which constrains text based on Blizzard's own width calculation.
	-- We must re-apply BFL's text area width and custom font.
	local fs = tab.Text or (tab.GetFontString and tab:GetFontString())
	if fs then
		if tab.BFL_TextWidth then
			fs:SetWidth(tab.BFL_TextWidth)
		end
		if tab.BFL_FontPath then
			SafeSetFont(fs, tab.BFL_FontPath, tab.BFL_FontSize, tab.BFL_FontOutline)
			fs:SetShadowOffset(0, 0)
		end
	end
	tab.BFL_ResizeLock = false
end

-- Install global hook ONLY on pre-12.0 Retail where secret values don't exist.
-- On 12.0.0+ (HasSecretValues), chat frame tabs also call PanelTemplates_TabResize,
-- and the hooksecurefunc callback taints the execution context. When Blizzard's
-- ChatHistory_GetToken then calls strlower() on a secret arg12/arg13, the taint
-- causes "attempt to perform string conversion on a secret string value".
-- Per-tab OnSizeChanged hooks (installed later in DistributeTabWidths) provide
-- equivalent width enforcement without the global taint vector.
-- NOTE: Use `issecretvalue` (WoW C global) directly instead of BFL.HasSecretValues,
-- because this code runs at file load time BEFORE ADDON_LOADED sets the flag.
if not BFL.IsClassic and PanelTemplates_TabResize and not issecretvalue then
	hooksecurefunc("PanelTemplates_TabResize", function(tab)
		BFL_EnforceTabWidth(tab)
	end)
end

local TAB_FONT_NORMAL = "BetterFriendlistTabFontNormal"
local TAB_FONT_HIGHLIGHT = "BetterFriendlistTabFontHighlight"
local TAB_FONT_DISABLED = "BetterFriendlistTabFontDisable"

local function GetColorRGBA(color, fallbackR, fallbackG, fallbackB, fallbackA)
	if color then
		if color.GetRGBA then
			return color:GetRGBA()
		elseif color.GetRGB then
			local r, g, b = color:GetRGB()
			return r, g, b, color.a or fallbackA or 1
		end

		return color.r or fallbackR, color.g or fallbackG, color.b or fallbackB, color.a or fallbackA or 1
	end

	return fallbackR, fallbackG, fallbackB, fallbackA or 1
end

local function AddChatMessage(message)
	if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
		DEFAULT_CHAT_FRAME:AddMessage(message)
	end
end

local function ApplyTabFontObjects(tab)
	if not tab or not tab.SetNormalFontObject then
		return
	end

	tab:SetNormalFontObject(TAB_FONT_NORMAL)
	tab:SetHighlightFontObject(TAB_FONT_HIGHLIGHT)
	tab:SetDisabledFontObject(TAB_FONT_DISABLED)
end

local function ApplyTabFontStringState(tab, fontObject, color, fallbackR, fallbackG, fallbackB)
	local fs = tab and (tab.Text or (tab.GetFontString and tab:GetFontString()))
	if not fs then
		return
	end

	if fs.SetFontObject then
		fs:SetFontObject(fontObject)
	end
	if fs.SetTextColor then
		fs:SetTextColor(GetColorRGBA(color, fallbackR, fallbackG, fallbackB, 1))
	end
end

function BFL:ApplyTabVisualState(tab, selected, disabled)
	if not tab then
		return
	end

	if disabled then
		if PanelTemplates_SetDisabledTabState then
			PanelTemplates_SetDisabledTabState(tab)
		end
		ApplyTabFontObjects(tab)
		ApplyTabFontStringState(tab, TAB_FONT_DISABLED, GRAY_FONT_COLOR, 0.5, 0.5, 0.5)
	elseif selected then
		if PanelTemplates_SelectTab then
			PanelTemplates_SelectTab(tab)
		end
		ApplyTabFontObjects(tab)
		if tab.SetDisabledFontObject then
			tab:SetDisabledFontObject(TAB_FONT_HIGHLIGHT)
		end
		ApplyTabFontStringState(tab, TAB_FONT_HIGHLIGHT, HIGHLIGHT_FONT_COLOR, 1, 1, 1)
	else
		if PanelTemplates_DeselectTab then
			PanelTemplates_DeselectTab(tab)
		end
		ApplyTabFontObjects(tab)
		local r, g, b = 1, 0.82, 0
		if BFL.GetThemeAccentColor then
			r, g, b = BFL:GetThemeAccentColor(r, g, b, 1)
		end
		ApplyTabFontStringState(tab, TAB_FONT_NORMAL, NORMAL_FONT_COLOR, r, g, b)
	end
end

function BFL:RefreshTopTabVisualState(selectedTabId)
	local frame = _G.BetterFriendsFrame
	local header = frame and frame.FriendsTabHeader
	if not header then
		return
	end

	selectedTabId = selectedTabId or (PanelTemplates_GetSelectedTab and PanelTemplates_GetSelectedTab(header)) or header.selectedTab
	for i = 1, header.numTabs or 4 do
		local tab = _G["BetterFriendsFrameTab" .. i]
		self:ApplyTabVisualState(tab, selectedTabId == i, tab and tab.isDisabled)
	end
end

function BFL:RefreshBottomTabVisualState(selectedTabId)
	local frame = _G.BetterFriendsFrame
	if not frame then
		return
	end

	selectedTabId = selectedTabId or (PanelTemplates_GetSelectedTab and PanelTemplates_GetSelectedTab(frame)) or frame.selectedTab
	for i = 1, frame.numTabs or 4 do
		local tab = _G["BetterFriendsFrameBottomTab" .. i]
		self:ApplyTabVisualState(tab, selectedTabId == i, tab and tab.isDisabled)
	end
end

function BFL:RefreshMainTabVisualState()
	self:RefreshTopTabVisualState()
	self:RefreshBottomTabVisualState()
end

function BFL:ApplyTabFonts()
	local db = BetterFriendlistDB
	if not db then
		return
	end

	-- Classic: Simple font application - just set font and let tabs auto-size
	if BFL.IsClassic then
		local defaultFontName, defaultFontSize = _G.GameFontNormalSmall:GetFont()
		local fontName = db.fontTabText or defaultFontName
		local fontSize = db.fontSizeTabText or defaultFontSize
		if fontSize > 19 then
			fontSize = 19
		end -- Classic max: prevent tabs from overflowing
		local fontOutline = db.fontOutlineTabText or "NORMAL"

		local outlineValue = GetFontFlags(fontOutline)

		local fontPath = BFL.FontManager:ResolveFontPath(fontName)

		local padding = 20
		local allTabs = {
			_G.BetterFriendsFrameBottomTab1,
			_G.BetterFriendsFrameBottomTab2,
			_G.BetterFriendsFrameBottomTab3,
			_G.BetterFriendsFrameBottomTab4,
			_G.BetterFriendsFrameTab1,
		}

		for _, tab in ipairs(allTabs) do
			if tab then
				local fs = tab.Text or (tab.GetFontString and tab:GetFontString())
				if fs then
					SafeSetFont(fs, fontPath, fontSize, outlineValue)
					fs:SetShadowOffset(0, 0)
					-- Measure text and size tab accordingly
					fs:SetWidth(0)
					fs:SetWordWrap(false)
					local textWidth = GetTextWidth(fs, 50)
					PanelTemplates_TabResize(tab, padding, nil, nil, nil)
				end
			end
		end

		local isClassicElvUISkinActive = BFL.IsThemeActive and BFL:IsThemeActive("elvui")
		if isClassicElvUISkinActive and BetterFriendsFrame then
			local frameWidth = BetterFriendsFrame:GetWidth()
			if not frameWidth or frameWidth <= 0 then
				frameWidth = 338
			end

			local tabs = {}
			for i = 1, 4 do
				local tab = _G["BetterFriendsFrameBottomTab" .. i]
				if tab and tab:IsShown() then
					table.insert(tabs, tab)
				end
			end

			local count = #tabs
			if count > 0 then
				-- Compact layout for Classic+ElvUI: no visual gaps between bottom tabs.
				local leftPadding = 5
				local rightPadding = 5
				local spacing = 0
				local available = frameWidth - leftPadding - rightPadding - ((count - 1) * spacing)
				local tabWidth = math.max(40, math.floor(available / count))
				-- Pull tabs closer to the frame so they don't look detached.
				local y = -26

				for i, tab in ipairs(tabs) do
					tab:SetWidth(tabWidth)
					tab:ClearAllPoints()
					if i == 1 then
						tab:SetPoint("BOTTOMLEFT", BetterFriendsFrame, "BOTTOMLEFT", leftPadding, y)
					else
						tab:SetPoint("LEFT", tabs[i - 1], "RIGHT", spacing, 0)
					end
				end
			end
		end
		self:RefreshMainTabVisualState()
		return
	end

	local frame = BetterFriendsFrame

	-- Determine default font props from game standard
	local defaultFontName, defaultFontSize, defaultFontOutline = _G.GameFontNormalSmall:GetFont()

	-- Default values: Fallback to standard game font if not set
	local fontName = db.fontTabText or defaultFontName
	local fontSize = db.fontSizeTabText or defaultFontSize
	local fontOutline = db.fontOutlineTabText or "NORMAL"

	-- Map outline values to API strings
	local outlineValue = GetFontFlags(fontOutline)

	-- Resolve font path using FontManager (locale-aware fallback)
	local fontPath = BFL.FontManager:ResolveFontPath(fontName)

	-- Update tab-only font objects (do not touch shared small fonts)
	if _G.BetterFriendlistTabFontNormal then
		SafeSetFont(_G.BetterFriendlistTabFontNormal, fontPath, fontSize, outlineValue)
		_G.BetterFriendlistTabFontNormal:SetShadowOffset(0, 0)
	end
	if _G.BetterFriendlistTabFontHighlight then
		SafeSetFont(_G.BetterFriendlistTabFontHighlight, fontPath, fontSize, outlineValue)
		_G.BetterFriendlistTabFontHighlight:SetShadowOffset(0, 0)
	end
	if _G.BetterFriendlistTabFontDisable then
		SafeSetFont(_G.BetterFriendlistTabFontDisable, fontPath, fontSize, outlineValue)
		_G.BetterFriendlistTabFontDisable:SetShadowOffset(0, 0)
	end

	local bottomTabs = {
		_G.BetterFriendsFrameBottomTab1,
		_G.BetterFriendsFrameBottomTab2,
		_G.BetterFriendsFrameBottomTab3,
		_G.BetterFriendsFrameBottomTab4,
	}
	local topTabs = {
		_G.BetterFriendsFrameTab1,
		_G.BetterFriendsFrameTab2,
		_G.BetterFriendsFrameTab3,
		_G.BetterFriendsFrameTab4,
	}

	local function GetTabSignature(tab)
		if not tab then
			return "0"
		end
		local text = ""
		if tab.GetText then
			text = tab:GetText() or ""
		end
		local isShown = "0"
		if tab.IsShown and tab:IsShown() then
			isShown = "1"
		end
		return text .. "|" .. isShown
	end

	-- Get frame dimensions (respect user width setting when available)
	local frameWidth
	if frame and frame.GetWidth then
		frameWidth = frame:GetWidth()
	end
	if not frameWidth or frameWidth <= 0 then
		local mainFrameSize = db.mainFrameSize and db.mainFrameSize.Default
		frameWidth = (mainFrameSize and mainFrameSize.width) or db.defaultFrameWidth or 415
	end

	local selectedTopTabId = frame and frame.FriendsTabHeader and PanelTemplates_GetSelectedTab(frame.FriendsTabHeader)
		or 0
	local selectedBottomTabId = frame and PanelTemplates_GetSelectedTab(frame) or 0
	local isFlatThemeActive = BFL.UsesFlatTheme and BFL:UsesFlatTheme()
	local isElvUISkinActive = BFL.IsThemeActive and BFL:IsThemeActive("elvui")
	local shouldCenterTopTabs = isElvUISkinActive or isFlatThemeActive
	local selectedTopLayoutId = shouldCenterTopTabs and 0 or selectedTopTabId
	local selectedBottomLayoutId = isFlatThemeActive and 0 or selectedBottomTabId
	local signature = table.concat({
		tostring(frameWidth),
		fontPath or "",
		tostring(fontSize),
		outlineValue,
		tostring(isFlatThemeActive),
		tostring(selectedTopLayoutId),
		tostring(selectedBottomLayoutId),
		GetTabSignature(bottomTabs[1]),
		GetTabSignature(bottomTabs[2]),
		GetTabSignature(bottomTabs[3]),
		GetTabSignature(bottomTabs[4]),
		GetTabSignature(topTabs[1]),
		GetTabSignature(topTabs[2]),
		GetTabSignature(topTabs[3]),
		GetTabSignature(topTabs[4]),
	}, "||")
	if self._tabFontCache and self._tabFontCache.signature == signature then
		self._tabFontCache.appliedThisCall = false
		return
	end
	self._tabFontCache = self._tabFontCache or {}
	self._tabFontCache.signature = signature
	self._tabFontCache.appliedThisCall = true

	local tabOverlap = 15 -- Tabs overlap by 15px in XML anchoring

	local function GetInsetBounds()
		if not frame or not frame.Inset or not frame.GetLeft or not frame.Inset.GetLeft then
			return nil
		end
		local frameLeft = frame:GetLeft()
		local insetLeft = frame.Inset:GetLeft()
		local insetRight = frame.Inset:GetRight()
		if not frameLeft or not insetLeft or not insetRight then
			return nil
		end
		return insetLeft - frameLeft, insetRight - frameLeft
	end

	local function GetTabOverlap(tabList, fallback)
		if #tabList < 2 then
			return fallback
		end
		for i = 2, #tabList do
			local tab = tabList[i].tab
			local prev = tabList[i - 1].tab
			local point, relativeTo, relativePoint, xOfs = tab:GetPoint(1)
			if point == "LEFT" and relativePoint == "RIGHT" and relativeTo == prev and xOfs then
				return -xOfs
			end
		end
		return fallback
	end

	-- Helper: BULLETPROOF tab sizing
	local function ProcessTabGroup(
		tabs,
		padding,
		startX,
		maxRightEdge,
		customWidths,
		textMode,
		extraHeight,
		textBias,
		textBiasScale,
		baseTextOffset,
		selectedTabId,
		selectedOffset,
		unselectedOffset,
		fixedHeight
	)
		-- Collect all tabs
		local tabList = {}
		for _, tab in ipairs(tabs) do
			if tab and tab:IsShown() then
				local fs = tab.Text or (tab.GetFontString and tab:GetFontString())
				if fs then
					SafeSetFont(fs, fontPath, fontSize, outlineValue)
					fs:SetShadowOffset(0, 0)
					tabList[#tabList + 1] = { tab = tab, fs = fs }
				end
			end
		end

		local numTabs = #tabList
		if numTabs == 0 then
			return 32
		end -- Return default height

		local resolvedOverlap = GetTabOverlap(tabList, tabOverlap)
		local totalOverlap = (numTabs - 1) * resolvedOverlap
		local availableWidth = maxRightEdge - startX + totalOverlap
		if availableWidth < 1 then
			availableWidth = 1
		end
		local maxTabWidth = math.floor(availableWidth / numTabs)

		-- DEBUG
		BFL:DebugPrint(
			string.format(
				"ProcessTabGroup: numTabs=%d, startX=%d, maxRightEdge=%d, overlap=%d, maxTabWidth=%d",
				numTabs,
				startX,
				maxRightEdge,
				resolvedOverlap,
				maxTabWidth
			)
		)

		local maxHeight = 32 -- Track max height for bottom tab positioning
		local heightPad = extraHeight or 0
		local bias = textBias or 0
		local biasScale = textBiasScale or 0.5
		local baseTextHeight = defaultFontSize or 12
		local baseOffset = baseTextOffset or 0
		-- Use Blizzard's standard TAB_SIDES_PADDING (20) for text area width,
		-- independent of the tab width padding. This prevents text truncation
		-- when padding > 20 (e.g. bottom tabs use 30 for wider tab backgrounds).
		local textPadding = 20
		local function ApplyTabWrap(info, width)
			local textAreaWidth = math.max(20, width - textPadding)
			info.fs:SetWidth(textAreaWidth)
			info.tab.BFL_TextWidth = textAreaWidth
			info.fs:SetWordWrap(false)
			local singleLineWidth = GetTextWidth(info.fs, 0)
			local needsWrap = singleLineWidth > textAreaWidth
			info.tab.BFL_ShowTooltip = needsWrap
			if needsWrap then
				info.fs:SetWordWrap(true)
			else
				info.fs:SetWordWrap(false)
			end
			local textHeight = info.fs:GetStringHeight() or fontSize
			local tabHeight = fixedHeight or math.max(32, textHeight + 20 + heightPad)
			local grow = fixedHeight and 0 or math.max(0, textHeight - baseTextHeight)
			local tabId = info.tab.GetID and info.tab:GetID() or nil
			local isSelected = selectedTabId and tabId and tabId == selectedTabId
			local offsetBase = baseOffset
			if isSelected and selectedOffset ~= nil then
				offsetBase = selectedOffset
			elseif (not isSelected) and unselectedOffset ~= nil then
				offsetBase = unselectedOffset
			end
			local yOffset = offsetBase + (bias * math.floor(grow * biasScale + 0.5))
			if bias ~= 0 then
				info.fs:ClearAllPoints()
				info.fs:SetPoint("CENTER", info.tab, "CENTER", 0, yOffset)
				info.fs:SetJustifyV("MIDDLE")
			end
			info.tab:SetHeight(tabHeight)
			maxHeight = math.max(maxHeight, tabHeight)
		end

		-- Apply to each tab
		for i, info in ipairs(tabList) do
			-- Measure ideal width (without constraints)
			info.fs:SetWidth(0)
			info.fs:SetWordWrap(false)
			local idealTextWidth = GetTextWidth(info.fs, 50)
			local idealTabWidth = idealTextWidth + padding

			-- Use custom width if provided; otherwise clamp by max allowed
			local customWidth = customWidths and customWidths[info.tab] or nil
			local finalWidth
			if customWidth then
				finalWidth = customWidth
			else
				finalWidth = math.min(idealTabWidth, maxTabWidth)
			end
			finalWidth = math.max(40, finalWidth)

			local textAreaWidth = math.max(20, finalWidth - textPadding)

			-- DEBUG
			local tabName = info.tab:GetName() or "unknown"
			BFL:DebugPrint(
				string.format(
					"  Tab %d (%s): ideal=%d, max=%d, final=%d",
					i,
					tabName,
					math.floor(idealTabWidth),
					maxTabWidth,
					finalWidth
				)
			)

			-- Store enforced width and text area width for hooksecurefunc restoration.
			-- textAreaWidth now uses Blizzard's standard 20px padding, so storing
			-- it for all tabs (top and bottom) keeps sizing consistent across
			-- live font changes and frame reopening.
			info.tab.BFL_EnforcedWidth = finalWidth
			info.tab.BFL_TextWidth = textAreaWidth
			info.tab.BFL_FontPath = fontPath
			info.tab.BFL_FontSize = fontSize
			info.tab.BFL_FontOutline = outlineValue

			-- Apply width
			info.tab:SetWidth(finalWidth)
			info.fs:SetWidth(textAreaWidth)
			if textMode == "center" then
				if not info.tab.BFL_OriginalTextPoints then
					info.tab.BFL_OriginalTextPoints = { info.fs:GetPoint(1) }
				end
				info.tab.BFL_UseTextCenter = true
				info.fs:ClearAllPoints()
				info.fs:SetPoint("CENTER", info.tab, "CENTER", 0, 0)
				info.fs:SetJustifyV("MIDDLE")
				if not info.tab.BFL_TopTextHooked then
					info.tab:HookScript("OnShow", function(self)
						if not self.BFL_UseTextCenter then
							return
						end
						local fs = self.Text or (self.GetFontString and self:GetFontString())
						if not fs then
							return
						end
						fs:ClearAllPoints()
						fs:SetPoint("CENTER", self, "CENTER", 0, 0)
						fs:SetJustifyV("MIDDLE")
					end)
					info.tab:HookScript("OnSizeChanged", function(self)
						if not self.BFL_UseTextCenter then
							return
						end
						local fs = self.Text or (self.GetFontString and self:GetFontString())
						if not fs then
							return
						end
						fs:ClearAllPoints()
						fs:SetPoint("CENTER", self, "CENTER", 0, 0)
						fs:SetJustifyV("MIDDLE")
					end)
					info.tab.BFL_TopTextHooked = true
				end
			else
				info.tab.BFL_UseTextCenter = false
				local original = info.tab.BFL_OriginalTextPoints
				if original and original[1] then
					info.fs:ClearAllPoints()
					info.fs:SetPoint(unpack(original))
				end
			end

			-- Hook OnSizeChanged to FORCE our width
			if not info.tab.BFL_SizeHooked then
				info.tab:HookScript("OnSizeChanged", function(self, width, height)
					if self.BFL_ResizeLock then
						return
					end
					if self.BFL_EnforcedWidth and math.abs(width - self.BFL_EnforcedWidth) > 1 then
						self.BFL_ResizeLock = true
						self:SetWidth(self.BFL_EnforcedWidth)
						self.BFL_ResizeLock = false
					end
					-- On 12.0.0+ the global PanelTemplates_TabResize hook is disabled
					-- to avoid tainting the chat execution path. Restore text width
					-- and font here instead when an external resize is detected.
					if BFL.HasSecretValues and self.BFL_EnforcedWidth then
						BFL_EnforceTabWidth(self)
					end
				end)
				info.tab.BFL_SizeHooked = true
			end

			-- Check if text needs wrapping
			info.fs:SetWordWrap(false)
			local singleLineWidth = GetTextWidth(info.fs, 0)
			local needsWrap = singleLineWidth > textAreaWidth
			info.tab.BFL_ShowTooltip = needsWrap

			if needsWrap then
				info.fs:SetWordWrap(true)

				-- Add tooltip for truncated text
				if not info.tab.BFL_TooltipHooked then
					local originalOnEnter = info.tab:GetScript("OnEnter")
					local originalOnLeave = info.tab:GetScript("OnLeave")

					info.tab:SetScript("OnEnter", function(self)
						self.BFL_NativeTooltipShown = false
						if self.BFL_ShowTooltip then
							BFL_Tooltip:SetOwner(self, "ANCHOR_TOP")
							BFL_Tooltip:ClearLines()
							BFL_Tooltip:SetText(self:GetText() or "", 1, 1, 1, 1, true)
							BFL_Tooltip:Show()
							return
						end
						if originalOnEnter then
							originalOnEnter(self)
							self.BFL_NativeTooltipShown = true
						end
					end)

					info.tab:SetScript("OnLeave", function(self)
						BFL_Tooltip:Hide()
						if self.BFL_NativeTooltipShown and originalOnLeave then
							originalOnLeave(self)
						elseif GameTooltip and GameTooltip.GetOwner and GameTooltip:GetOwner() == self then
							GameTooltip:Hide()
						end
						self.BFL_NativeTooltipShown = nil
					end)

					info.tab.BFL_TooltipHooked = true
				end
			else
				info.fs:SetWordWrap(false)
			end

			-- Calculate height for text
			local textHeight = info.fs:GetStringHeight() or fontSize
			local tabHeight = fixedHeight or math.max(32, textHeight + 20 + heightPad)
			local grow = fixedHeight and 0 or math.max(0, textHeight - baseTextHeight)
			local tabId = info.tab.GetID and info.tab:GetID() or nil
			local isSelected = selectedTabId and tabId and tabId == selectedTabId
			local offsetBase = baseOffset
			if isSelected and selectedOffset ~= nil then
				offsetBase = selectedOffset
			elseif (not isSelected) and unselectedOffset ~= nil then
				offsetBase = unselectedOffset
			end
			local yOffset = offsetBase + (bias * math.floor(grow * biasScale + 0.5))
			if bias ~= 0 then
				info.fs:ClearAllPoints()
				info.fs:SetPoint("CENTER", info.tab, "CENTER", 0, yOffset)
				info.fs:SetJustifyV("MIDDLE")
			end

			info.tab:SetHeight(tabHeight)
			maxHeight = math.max(maxHeight, tabHeight)
		end

		local lastTab = tabList[#tabList].tab
		if frame and lastTab and lastTab.GetRight and frame.GetLeft then
			local right = lastTab:GetRight()
			local left = frame:GetLeft()
			if right and left then
				local rightEdge = right - left
				local slack = maxRightEdge - rightEdge
				if slack > 0 then
					local targetInfo = nil
					for i = #tabList, 1, -1 do
						local info = tabList[i]
						if info.tab.BFL_ShowTooltip then
							targetInfo = info
							break
						end
					end
					if not targetInfo then
						targetInfo = tabList[#tabList]
					end
					local currentWidth = targetInfo.tab:GetWidth() or 0
					local newWidth = currentWidth + slack
					targetInfo.tab.BFL_EnforcedWidth = newWidth
					targetInfo.tab:SetWidth(newWidth)
					ApplyTabWrap(targetInfo, newWidth)
					right = lastTab:GetRight()
					if right then
						rightEdge = right - left
					end
				end
				local overflow = rightEdge - maxRightEdge
				if overflow > 0 then
					local remaining = overflow
					for i = #tabList, 1, -1 do
						if remaining <= 0 then
							break
						end
						local info = tabList[i]
						local currentWidth = info.tab:GetWidth() or 0
						local minWidth = 40
						if currentWidth > minWidth then
							local cut = math.min(currentWidth - minWidth, remaining)
							local newWidth = currentWidth - cut
							info.tab.BFL_EnforcedWidth = newWidth
							info.tab:SetWidth(newWidth)
							ApplyTabWrap(info, newWidth)
							remaining = remaining - cut
						end
					end
					right = lastTab:GetRight()
					if right then
						rightEdge = right - left
					end
				end
				BFL:DebugPrint(
					string.format("ProcessTabGroup: rightEdge=%d (limit=%d)", math.floor(rightEdge + 0.5), maxRightEdge)
				)
			end
		end

		return maxHeight
	end

	local function ComputeBottomTabWidths(startX, maxRightEdge)
		local desired = {
			{ tab = _G.BetterFriendsFrameBottomTab1, min = 80, weight = 0.6 }, -- Contacts
			{ tab = _G.BetterFriendsFrameBottomTab2, min = 60, weight = 0.6 }, -- Who
			{ tab = _G.BetterFriendsFrameBottomTab3, min = 60, weight = 0.6 }, -- Raid
			{ tab = _G.BetterFriendsFrameBottomTab4, min = 120, weight = 2.0 }, -- Quick Join
		}

		local active = {}
		for _, info in ipairs(desired) do
			if info.tab and info.tab:IsShown() then
				active[#active + 1] = info
			end
		end
		if #active == 0 then
			return nil
		end

		local totalOverlap = (#active - 1) * tabOverlap
		local availableTotal = maxRightEdge - startX + totalOverlap
		local sumMin, sumWeights = 0, 0
		for _, info in ipairs(active) do
			sumMin = sumMin + info.min
			sumWeights = sumWeights + info.weight
		end

		local widths = {}
		if availableTotal <= sumMin then
			local scale = availableTotal / sumMin
			local allocated = 0
			for _, info in ipairs(active) do
				local w = math.max(40, math.floor(info.min * scale))
				widths[info.tab] = w
				allocated = allocated + w
			end
			local overflow = allocated - availableTotal
			if overflow > 0 then
				for _, info in ipairs(active) do
					if overflow <= 0 then
						break
					end
					local w = widths[info.tab]
					if w > 40 then
						local cut = math.min(w - 40, overflow)
						widths[info.tab] = w - cut
						overflow = overflow - cut
					end
				end
			end
			return widths
		end

		local remaining = availableTotal - sumMin
		local allocated = 0
		for _, info in ipairs(active) do
			local extra = math.floor(remaining * (info.weight / sumWeights))
			widths[info.tab] = info.min + extra
			allocated = allocated + widths[info.tab]
		end

		local leftover = availableTotal - allocated
		table.sort(active, function(a, b)
			return a.weight > b.weight
		end)
		local idx = 1
		while leftover > 0 do
			local info = active[idx]
			widths[info.tab] = widths[info.tab] + 1
			leftover = leftover - 1
			idx = idx + 1
			if idx > #active then
				idx = 1
			end
		end

		return widths
	end

	-- DEBUG
	BFL:DebugPrint(string.format("ApplyTabFonts: frameWidth=%d", frameWidth))

	local insetLeft, insetRight = GetInsetBounds()

	local bottomStartX = 5
	local bottomMaxRightEdge = frameWidth - 5
	if insetLeft and insetRight then
		local rightPadding = math.max(0, frameWidth - insetRight)
		bottomMaxRightEdge = math.max(bottomStartX + 1, frameWidth - 5 - rightPadding)
	end

	-- Process bottom tabs (start at x=5, must fit within frame bounds)
	-- padding=30 gives more base width for short tabs like "Who", "Raid"
	local bottomWidths = ComputeBottomTabWidths(bottomStartX, bottomMaxRightEdge)
	local bottomTextMode = isFlatThemeActive and "center" or nil
	local bottomTextBias = isFlatThemeActive and 0 or 1
	local bottomTabHeight =
		ProcessTabGroup(bottomTabs, 30, bottomStartX, bottomMaxRightEdge, bottomWidths, bottomTextMode, nil, bottomTextBias, 0.5)

	-- Adjust bottom tab Y position based on height (default y=-30 for 32px tabs)
	local bottomTab1 = _G.BetterFriendsFrameBottomTab1
	if bottomTab1 and BetterFriendsFrame then
		local yOffset = -30 - (bottomTabHeight - 32) -- Move down as tabs get taller
		bottomTab1:ClearAllPoints()
		bottomTab1:SetPoint("BOTTOMLEFT", BetterFriendsFrame, "BOTTOMLEFT", 5, yOffset)
	end

	local topStartX = 7
	local topMaxRightEdge = frameWidth - 5
	if insetLeft and insetRight then
		topStartX = math.floor(insetLeft + 4 + 0.5)
		topMaxRightEdge = math.floor(insetRight - 2 + 0.5)
	end

	-- Process top tabs (start aligned to Inset left edge, clamp at Inset right edge)
	-- NOTE: topExtraHeight is intentionally 0. The Inset is anchored to Tab1's BOTTOMLEFT,
	-- so increasing tab height would push the entire content area down.
	-- Flat themes center tab text at (0,0) on every Select/Deselect.
	-- Use matching 0 offsets so ProcessTabGroup and theme hooks produce identical results.
	local topTextMode = shouldCenterTopTabs and "center" or nil
	local topTextBias = shouldCenterTopTabs and 0 or -1
	local topBaseOffset = shouldCenterTopTabs and 0 or -5
	local topSelectedOffset = shouldCenterTopTabs and 0 or -5
	local topUnselectedOffset = shouldCenterTopTabs and 0 or -8
	ProcessTabGroup(
		topTabs,
		20,
		topStartX,
		topMaxRightEdge,
		nil,
		topTextMode,
		0,
		topTextBias,
		0.8,
		topBaseOffset,
		selectedTopTabId,
		topSelectedOffset,
		topUnselectedOffset,
		32
	)

	-- Reposition first top tab to align with Inset
	-- Simple Mode uses -60 (no SearchBox row in header), Normal Mode uses -95
	local topTab1 = _G.BetterFriendsFrameTab1
	if topTab1 and BetterFriendsFrame then
		local db = BetterFriendlistDB
		local isSimple = db and db.simpleMode
		local topTabY = isSimple and -60 or -95
		topTab1:ClearAllPoints()
		topTab1:SetPoint("TOPLEFT", BetterFriendsFrame, "TOPLEFT", topStartX, topTabY)
	end
	self:RefreshMainTabVisualState()
end

-- Helper: Format last online time
local function GetLastOnlineTime(lastOnlineTime)
	local currentTime = time()
	local timeDifference = currentTime - lastOnlineTime

	if timeDifference < 60 then
		return LASTONLINE_SECONDS
	elseif timeDifference >= 60 and timeDifference < 3600 then
		return string.format(LASTONLINE_MINUTES, math.floor(timeDifference / 60))
	elseif timeDifference >= 3600 and timeDifference < 86400 then
		return string.format(LASTONLINE_HOURS, math.floor(timeDifference / 3600))
	elseif timeDifference >= 86400 and timeDifference < 2592000 then
		return string.format(LASTONLINE_DAYS, math.floor(timeDifference / 86400))
	elseif timeDifference >= 2592000 and timeDifference < 31536000 then
		return string.format(LASTONLINE_MONTHS, math.floor(timeDifference / 2592000))
	else
		return string.format(LASTONLINE_YEARS, math.floor(timeDifference / 31536000))
	end
end

local function GetLastOnlineText(accountInfo)
	if not accountInfo or not accountInfo.lastOnlineTime or accountInfo.lastOnlineTime == 0 then
		return FRIENDS_LIST_OFFLINE
	else
		return string.format(BNET_LAST_ONLINE_TIME, GetLastOnlineTime(accountInfo.lastOnlineTime))
	end
end

-- Search filter
local searchText = ""

-- Localization Function (Call on PLAYER_LOGIN)
local function LocalizeUI()
	local frame = BetterFriendsFrame
	if not frame then
		return
	end

	-- Ensure Global Strings for XML usage (Literals in XML)
	_G.RAF_NEXT_REWARD_HELP_TEXT = L.RAF_NEXT_REWARD_HELP
	_G.WHO_LIST_SEARCH_INSTRUCTIONS = L.WHO_LIST_SEARCH_INSTRUCTIONS
	_G.WHO_LIST_LEVEL_TOOLTIP = L.WHO_LEVEL_FORMAT

	-- Tabs (Main Frame)
	if frame.FriendsTabHeader then
		if frame.FriendsTabHeader.Tab1 then
			frame.FriendsTabHeader.Tab1:SetText(L.TAB_FRIENDS or FRIENDS)
		end
		if frame.FriendsTabHeader.Tab2 then
			frame.FriendsTabHeader.Tab2:SetText(L.CONTACTS_RECENT_ALLIES_TAB_NAME)
		end
		if frame.FriendsTabHeader.Tab3 then
			frame.FriendsTabHeader.Tab3:SetText(L.RECRUIT_A_FRIEND or RECRUIT_A_FRIEND)
			-- Check if RAF is enabled
			if not BFL.IsClassic and C_RecruitAFriend and C_RecruitAFriend.IsEnabled then
				if not C_RecruitAFriend.IsEnabled() then
					frame.FriendsTabHeader.Tab3:Hide()
				end
			elseif not BFL.IsClassic then
				frame.FriendsTabHeader.Tab3:Hide()
			end
		end
	end

	-- Search Box
	if
		frame.FriendsTabHeader
		and frame.FriendsTabHeader.SearchBox
		and frame.FriendsTabHeader.SearchBox.Instructions
	then
		UpdateSearchBoxInstruction(frame.FriendsTabHeader.SearchBox, 1)
	end

	-- Broadcast Frame
	if frame.FriendsTabHeader and frame.FriendsTabHeader.BattlenetFrame then
		local bnet = frame.FriendsTabHeader.BattlenetFrame
		if bnet.UnavailableLabel then
			bnet.UnavailableLabel:SetText(L.BATTLENET_UNAVAILABLE)
		end
		if bnet.BroadcastFrame then
			if bnet.BroadcastFrame.PromptText then
				bnet.BroadcastFrame.PromptText:SetText(L.FRIENDS_LIST_ENTER_TEXT)
			end
			if bnet.BroadcastFrame.UpdateButton then
				bnet.BroadcastFrame.UpdateButton:SetText(UPDATE)
			end
			if bnet.BroadcastFrame.CancelButton then
				bnet.BroadcastFrame.CancelButton:SetText(CANCEL)
			end
		end
	end

	-- Bottom Buttons
	if frame.AddFriendButton then
		frame.AddFriendButton:SetText(ADD_FRIEND)
	end
	if frame.SendMessageButton then
		frame.SendMessageButton:SetText(SEND_MESSAGE)
	end
	if frame.RecruitmentButton then
		frame.RecruitmentButton:SetText(L.RAF_RECRUITMENT)
	end

	-- Who Frame Columns (Classic/Retail)
	if frame.WhoFrame then
		if frame.WhoFrame.NameHeader then
			frame.WhoFrame.NameHeader:SetText(NAME)
		end
		if frame.WhoFrame.LevelHeader then
			frame.WhoFrame.LevelHeader:SetText(LEVEL_ABBR)
		end
		if frame.WhoFrame.ClassHeader then
			frame.WhoFrame.ClassHeader:SetText(CLASS)
		end
		if frame.WhoFrame.WhoButton then
			frame.WhoFrame.WhoButton:SetText(REFRESH)
		end
		if frame.WhoFrame.AddFriendButton then
			frame.WhoFrame.AddFriendButton:SetText(ADD_FRIEND)
		end
		if frame.WhoFrame.GroupInviteButton then
			frame.WhoFrame.GroupInviteButton:SetText(GROUP_INVITE)
		end
	end

	-- Settings Frame
	if BetterFriendlistSettingsFrame then
		local sFrame = BetterFriendlistSettingsFrame
		if sFrame.TitleContainer and sFrame.TitleContainer.TitleText then
			sFrame.TitleContainer.TitleText:SetText(L.SETTINGS_TITLE)
		end
		if sFrame.Tab1 then
			sFrame.Tab1:SetText(L.SETTINGS_TAB_GENERAL)
		end
		if sFrame.Tab2 then
			sFrame.Tab2:SetText(L.SETTINGS_TAB_GROUPS)
		end
		if sFrame.Tab3 then
			sFrame.Tab3:SetText(L.SETTINGS_TAB_ADVANCED)
		end
		if sFrame.Tab4 then
			sFrame.Tab4:SetText(L.SETTINGS_TAB_DATABROKER)
		end
		if sFrame.Tab6 then
			sFrame.Tab6:SetText(L.SETTINGS_TAB_GLOBAL_SYNC)
		end
	end
end

-- Initialize the addon
local frame = CreateFrame("Frame")
frame:RegisterEvent("ADDON_LOADED")
frame:RegisterEvent("FRIENDLIST_UPDATE")
frame:RegisterEvent("BN_FRIEND_LIST_SIZE_CHANGED")
frame:RegisterEvent("BN_FRIEND_ACCOUNT_ONLINE")
frame:RegisterEvent("BN_FRIEND_ACCOUNT_OFFLINE")
frame:RegisterEvent("BN_FRIEND_INFO_CHANGED")
frame:RegisterEvent("PLAYER_LOGIN")
frame:RegisterEvent("SOCIAL_QUEUE_UPDATE")
frame:RegisterEvent("GROUP_LEFT")
frame:RegisterEvent("GROUP_JOINED")
frame:RegisterEvent("GROUP_ROSTER_UPDATE")
frame:RegisterEvent("RAID_ROSTER_UPDATE")
frame:RegisterEvent("PLAYER_ROLES_ASSIGNED")
frame:RegisterEvent("PLAYER_REGEN_DISABLED") -- Entering combat
frame:RegisterEvent("PLAYER_REGEN_ENABLED") -- Leaving combat
frame:RegisterEvent("WHO_LIST_UPDATE")

-- Global flag to track if we're showing a WHO player menu (not a friend)
_G.BetterFriendlist_IsWhoPlayerMenu = false

-- Hook UnitPopup to filter out group options for WHO players
local function FilterWhoPlayerMenu(rootDescription, contextData)
	if not _G.BetterFriendlist_IsWhoPlayerMenu then
		return -- Not a WHO player, don't filter
	end

	-- List of group-related options to hide for WHO players
	local groupOptionsToHide = {
		"BETTERFRIENDLIST_ADD_GROUP",
		"BETTERFRIENDLIST_REMOVE_GROUP",
		"BETTERFRIENDLIST_MOVE_TO_GROUP",
		"BETTERFRIENDLIST_FAVORITE",
		"BETTERFRIENDLIST_UNFAVORITE",
	}

	-- Remove group management options
	if rootDescription and rootDescription.elements then
		for i = #rootDescription.elements, 1, -1 do
			local element = rootDescription.elements[i]
			if element and element.data then
				-- Check if this is one of our group options
				for _, optionName in ipairs(groupOptionsToHide) do
					if element.data.text and element.data.text:find(optionName) or element.data.value == optionName then
						table.remove(rootDescription.elements, i)
						break
					end
				end
			end
		end
	end
end

-- Initialize Menu System (delegates to MenuSystem module)
local function InitializeMenuSystem()
	local MenuSystem = GetMenuSystem()
	if MenuSystem then
		MenuSystem:Initialize()
	end
end

-- Update the friends list data (now delegates to FriendsList module)
local function UpdateFriendsList()
	local FriendsList = GetFriendsList()

	if FriendsList then
		-- Set search text from local state
		FriendsList:SetSearchText(searchText)

		-- CRITICAL: Read filterMode from DB instead of local variable to prevent stale values
		local DB = BFL:GetModule("DB")
		if DB then
			local db = DB:Get()
			if db and db.quickFilter then
				FriendsList:SetFilterMode(db.quickFilter)
				-- Update local cache
				filterMode = db.quickFilter
			end
		end

		-- DON'T override sortMode - it's managed internally by FriendsList module
		-- FriendsList:SetSortMode(currentSortMode)

		-- Update friends list data
		FriendsList:UpdateFriendsList()

		-- Sync local state for compatibility
		friendsList = FriendsList.friendsList
	end
end

-- Forward declaration for UpdateFriendsDisplay
local UpdateFriendsDisplay

-- Helper: Get color code for a group (wrapper for ColorManager)
local function GetGroupColorCode(groupId)
	InitializeManagers()
	return ColorManager:GetGroupColorCode(groupId)
end

-- ========================================
-- Quick Filter Functions
-- ========================================
-- QUICK FILTER DROPDOWN FUNCTIONS (Modern API, Icon-Only Display)
-- ========================================

-- Filter icon definitions (Feather Icons for custom filters)
local FILTER_ICONS = {
	all = "Interface\\AddOns\\BetterFriendlist\\Icons\\filter-all", -- All Friends (users icon)
	online = "Interface\\AddOns\\BetterFriendlist\\Icons\\filter-online", -- Online Only (user-check icon)
	offline = "Interface\\AddOns\\BetterFriendlist\\Icons\\filter-offline", -- Offline Only (user-x icon)
	wow = "Interface\\AddOns\\BetterFriendlist\\Icons\\filter-wow", -- WoW Only (shield icon)
	wowonline = "Interface\\AddOns\\BetterFriendlist\\Icons\\check-circle", -- WoW Online (check circle icon)
	bnet = "Interface\\AddOns\\BetterFriendlist\\Icons\\filter-bnet", -- Battle.net Only (share-2 icon)
	hideafk = "Interface\\AddOns\\BetterFriendlist\\Icons\\filter-hide-afk", -- Hide AFK (eye-off icon)
	retail = "Interface\\AddOns\\BetterFriendlist\\Icons\\filter-retail", -- Retail Only (trending-up icon)
}

-- Initialize the Quick Filter dropdown menu (Modern WoW 11.0+ API)
-- Based on InitializeStatusDropdown() implementation
function BetterFriendsFrame_InitQuickFilterDropdown()
	local dropdown = BetterFriendsFrame.FriendsTabHeader.QuickFilterDropdown
	if not dropdown then
		return
	end

	-- Delegate to QuickFilters module
	local QuickFilters = GetQuickFilters()
	if QuickFilters then
		-- BFL:DebugPrint("BetterFriendlist: Delegating QuickFilter init to module")
		QuickFilters:InitDropdown(dropdown)
	else
		-- BFL:DebugPrint("BetterFriendlist: QuickFilters module not found!")
	end
end

-- Set the quick filter mode
-- DEPRECATED: This function is kept for backward compatibility only
-- All filter management is now handled by QuickFilters module
function BetterFriendsFrame_SetQuickFilter(mode) -- Delegate to QuickFilters module for consistent state management
	local QuickFilters = GetQuickFilters()
	if QuickFilters then
		QuickFilters:SetFilter(mode)
		-- QuickFilters:SetFilter already calls FriendsList:SetFilterMode and triggers refresh
		-- No need to call UpdateFriendsList() here - it would cause double update
	else
		-- Fallback if module not available
		filterMode = mode
		if BetterFriendlistDB then
			BetterFriendlistDB.quickFilter = filterMode
		end
		local FriendsList = GetFriendsList()
		if FriendsList then
			FriendsList:SetFilterMode(filterMode)
		end
	end
end

-- ========================================
-- GROUP MANAGEMENT
-- ========================================
-- All group management functionality is delegated to Modules/Groups.lua
-- These wrapper functions are kept for backward compatibility only

-- Helper function to get friend unique ID (global for use in multiple places)
function GetFriendUID(friend)
	if not friend then
		return nil
	end
	if friend.type == "bnet" then
		-- Use battleTag as persistent identifier (bnetAccountID is temporary per session)
		-- CRITICAL: Check for non-empty string, not just truthy value
		if friend.battleTag and friend.battleTag ~= "" then
			return "bnet_" .. friend.battleTag
		elseif friend.bnetAccountID then
			-- Fallback: Try to look up battleTag from bnetAccountID
			local numBNet = BNGetNumFriends()
			for i = 1, numBNet do
				local accountInfo = C_BattleNet.GetFriendAccountInfo(i)
				if
					accountInfo
					and accountInfo.bnetAccountID == friend.bnetAccountID
					and accountInfo.battleTag
					and accountInfo.battleTag ~= ""
				then
					return "bnet_" .. accountInfo.battleTag
				end
			end
			-- Last resort: use bnetAccountID (will cause issues with persistence)
			-- BFL:DebugPrint("|cffff0000BetterFriendlist Warning:|r Using bnetAccountID for friend UID (battleTag unavailable)")
			return "bnet_" .. tostring(friend.bnetAccountID)
		else
			-- Should never happen — generate unique fallback to prevent UID collisions
			BFL.unknownUIDCounter = (BFL.unknownUIDCounter or 0) + 1
			return "bnet_unknown_" .. BFL.unknownUIDCounter
		end
	else
		return "wow_" .. (friend.name or "")
	end
end

-- ========================================
-- DISPLAY MANAGEMENT
-- ========================================

-- Update the UI display (Two-line layout with groups)
UpdateFriendsDisplay = function()
	local FriendsList = GetFriendsList()
	if FriendsList then
		FriendsList:RenderDisplay()
	end
end

-- Throttled update function to batch rapid events
-- IMPROVED (Phase 14d): Always update data layer, only conditionally update display
local function RequestUpdate(forceDisplay)
	local currentTime = GetTime()

	-- If enough time has passed, update immediately
	if currentTime - lastUpdateTime >= UPDATE_THROTTLE then
		lastUpdateTime = currentTime
		pendingUpdate = false
		UpdateFriendsList() -- ALWAYS update data

		-- Only update display if frame is shown (unless forced)
		if forceDisplay or (BetterFriendsFrame and BetterFriendsFrame:IsShown()) then
			UpdateFriendsDisplay()
		end
	else
		-- Otherwise, schedule a delayed update
		if not pendingUpdate then
			pendingUpdate = true
			C_Timer.After(UPDATE_THROTTLE, function()
				if pendingUpdate then
					lastUpdateTime = GetTime()
					pendingUpdate = false
					UpdateFriendsList() -- ALWAYS update data

					-- Only update display if frame is shown
					if BetterFriendsFrame and BetterFriendsFrame:IsShown() then
						UpdateFriendsDisplay()
					end
				end
			end)
		end
	end
end

-- Global function for getting friends list (called from XML)
function BetterFriendlist_GetFriendsList()
	return friendsList
end

-- Global function for updating the display (called from XML)
function BetterFriendsFrame_UpdateDisplay()
	UpdateFriendsDisplay()
end

-- Search debounce timer
BFL.searchDebounceTimer = BFL.searchDebounceTimer or nil

local function CancelSearchDebounceTimer()
	if BFL.searchDebounceTimer then
		BFL.searchDebounceTimer:Cancel()
		BFL.searchDebounceTimer = nil
	end
end

local function SetSearchBoxTextSilently(searchBox, text)
	if not searchBox then
		return
	end

	local newText = text or ""
	if searchBox.GetText and searchBox:GetText() == newText then
		return
	end

	local wasSuppressed = BFL.suppressSearchTextChanged
	BFL.suppressSearchTextChanged = true
	searchBox:SetText(newText)
	BFL.suppressSearchTextChanged = wasSuppressed
end

local function ClearTopTabSearchState(frame, tabIndex)
	CancelSearchDebounceTimer()
	searchText = ""

	local searchBox = frame and frame.FriendsTabHeader and frame.FriendsTabHeader.SearchBox
	SetSearchBoxTextSilently(searchBox, "")

	local FriendsList = BFL:GetModule("FriendsList")
	if FriendsList and FriendsList.SetSearchText then
		FriendsList:SetSearchText("", true)
	end
	local RecentAllies = BFL:GetModule("RecentAllies")
	if RecentAllies and RecentAllies.SetSearchText then
		RecentAllies:SetSearchText("", true)
	end
	local RAF = BFL:GetModule("RAF")
	if RAF and RAF.SetSearchText then
		RAF:SetSearchText("", true)
	end
	local GuildFrame = BFL:GetModule("GuildFrame")
	if GuildFrame and GuildFrame.SetSearchText then
		GuildFrame:SetSearchText("", true)
	end

	if searchBox and searchBox.Instructions then
		UpdateSearchBoxInstruction(searchBox, tabIndex or 1)
	end
end

-- Handle search box text changes (called from XML)
-- Routes search to the correct module based on active top tab
function BetterFriendsFrame_OnSearchTextChanged(editBox)
	if BFL.suppressSearchTextChanged then
		return
	end

	local text = editBox:GetText()
	-- Convert to lowercase, handle empty string as well
	local newSearchText = (text and text ~= "") and text:lower() or ""

	-- Cancel pending search update
	CancelSearchDebounceTimer()

	-- Debounce: Wait 0.3s after last keystroke before updating
	local newTimer = C_Timer.NewTimer(0.3, function()
		searchText = newSearchText
		BFL.searchDebounceTimer = nil

		-- Determine which top tab is active
		local frame = BetterFriendsFrame
		local activeTopTab = 1
		if frame and frame.FriendsTabHeader then
			activeTopTab = PanelTemplates_GetSelectedTab(frame.FriendsTabHeader) or 1
		end

		if activeTopTab == 1 then
			-- Tab 1: Friends
			local FriendsList = GetFriendsList()
			if FriendsList then
				FriendsList:SetSearchText(searchText)
			end
		elseif activeTopTab == 2 then
			-- Tab 2: Recent Allies
			local RecentAllies = BFL:GetModule("RecentAllies")
			if RecentAllies then
				RecentAllies:SetSearchText(searchText)
			end
		elseif activeTopTab == 3 then
			-- Tab 3: Recruit A Friend
			local RAF = BFL:GetModule("RAF")
			if RAF then
				RAF:SetSearchText(searchText)
			end
		elseif activeTopTab == 4 then
			-- Tab 4: Guild roster
			local GuildFrame = BFL:GetModule("GuildFrame")
			if GuildFrame and GuildFrame.SetSearchText then
				GuildFrame:SetSearchText(searchText)
			end
		end
	end)
	BFL.searchDebounceTimer = newTimer
end

local function SuspendBetterFriendsFrameUIPanelForCombat()
	if not (BetterFriendsFrame and UIPanelWindows) then
		return
	end

	local settings = UIPanelWindows["BetterFriendsFrame"]
	if settings then
		BFL.SuspendedBetterFriendsFrameUIPanelSettings = settings
		UIPanelWindows["BetterFriendsFrame"] = nil
	end

	BFL.pendingBetterFriendsFrameUIPanelRestore = true
end

function BFL:RestoreBetterFriendsFrameUIPanelAfterCombat()
	if InCombatLockdown() then
		return
	end
	if not (BetterFriendlistDB and BetterFriendlistDB.useUIPanelSystem and BetterFriendsFrame) then
		self.pendingBetterFriendsFrameUIPanelRestore = nil
		return
	end

	if UIPanelWindows and self.SuspendedBetterFriendsFrameUIPanelSettings and not UIPanelWindows["BetterFriendsFrame"] then
		UIPanelWindows["BetterFriendsFrame"] = self.SuspendedBetterFriendsFrameUIPanelSettings
	end

	if _G.ConfigureUIPanelAttributes then
		_G.ConfigureUIPanelAttributes(true)
	end

	self.SuspendedBetterFriendsFrameUIPanelSettings = nil
	self.pendingBetterFriendsFrameUIPanelRestore = nil
end

-- Configure UI Panel Layout attributes for BetterFriendsFrame
-- Made global for access from Settings module
function ConfigureUIPanelAttributes(enable)
	if not BetterFriendsFrame then
		return
	end

	-- Optimization: Don't update if in combat (SetAttribute is restricted)
	if InCombatLockdown() then
		return
	end

	-- Optimization: Don't set attributes if they are already in the desired state
	local isDefined = BetterFriendsFrame:GetAttribute("UIPanelLayout-defined")
	local hasUIPanelEntry = UIPanelWindows and UIPanelWindows["BetterFriendsFrame"]
	if enable and isDefined and hasUIPanelEntry then
		return
	elseif not enable and not isDefined then
		return
	end

	if enable then
		-- Enable UI Panel Layout system
		BetterFriendsFrame:SetAttribute("UIPanelLayout-defined", true)
		BetterFriendsFrame:SetAttribute("UIPanelLayout-enabled", true)
		BetterFriendsFrame:SetAttribute("UIPanelLayout-area", "left")
		BetterFriendsFrame:SetAttribute("UIPanelLayout-pushable", 0) -- 8+ recommended for custom addons (Blizzard uses 0-7)
		-- BetterFriendsFrame:SetAttribute("UIPanelLayout-width", width) -- Custom width is not recommended
		BetterFriendsFrame:SetAttribute("UIPanelLayout-whileDead", true)
		BFL:DebugPrint("UI Panel Layout attributes enabled")
	else
		-- Disable UI Panel Layout system
		-- CRITICAL: Remove from UIPanelWindows first (ShowUIPanel auto-registers it)
		if UIPanelWindows and UIPanelWindows["BetterFriendsFrame"] then
			UIPanelWindows["BetterFriendsFrame"] = nil
			BFL:DebugPrint("BetterFriendsFrame removed from UIPanelWindows")
		end

		-- Then disable attributes (false disables, nil doesn't work reliably)
		BetterFriendsFrame:SetAttribute("UIPanelLayout-defined", false)
		BetterFriendsFrame:SetAttribute("UIPanelLayout-enabled", false)
		BetterFriendsFrame:SetAttribute("UIPanelLayout-area", "")
		BetterFriendsFrame:SetAttribute("UIPanelLayout-pushable", nil)
		BetterFriendsFrame:SetAttribute("UIPanelLayout-whileDead", false)
		BFL:DebugPrint("UI Panel Layout attributes disabled")
	end
end

-- ========================================
-- STORY MODE RAID TAB HANDLING (Fix #43)
-- ========================================
-- When player is in Story Mode raid content, the Raid tab should be disabled
-- exactly like Blizzard's FriendsFrame does it (see FriendsFrame_OnShow in Blizzard_FriendsFrame)

-- Helper: Check if DifficultyUtil.InStoryRaid is available (Retail only)
local function IsInStoryRaid()
	-- Guard for Classic (no DifficultyUtil) and missing API
	if BFL.IsClassic then
		return false
	end
	if not DifficultyUtil or not DifficultyUtil.InStoryRaid then
		return false
	end
	return DifficultyUtil.InStoryRaid()
end

-- Update Raid tab state (enable/disable based on Story Mode)
-- This mirrors Blizzard's FriendsFrame_OnShow behavior
-- NOTE: We can't use PanelTemplates_SetTabEnabled because it expects tabs named
-- "BetterFriendsFrameTab3" but ours are named "BetterFriendsFrameBottomTab3"
function BetterFriendsFrame_UpdateRaidTabState()
	if not BetterFriendsFrame then
		return
	end
	if BFL.IsClassic then
		return
	end -- Classic has different tab layout

	local raidTab = BetterFriendsFrame.BottomTab3
	if not raidTab then
		return
	end

	local inStoryRaid = IsInStoryRaid()
	local enableRaidTab = not inStoryRaid

	-- Manual tab enable/disable (mirroring PanelTemplates_SetTabEnabled behavior)
	-- Since our tabs are named "BottomTabX" instead of "TabX", we must do this manually
	if enableRaidTab then
		-- Enable the tab
		raidTab.isDisabled = nil
		raidTab:Enable()
		-- Reset to normal visual state (deselected by default, UpdateTabs will correct if selected)
		PanelTemplates_DeselectTab(raidTab)
		-- Remove tooltip handlers
		raidTab:SetScript("OnEnter", nil)
		raidTab:SetScript("OnLeave", nil)
	else
		-- Disable the tab
		raidTab.isDisabled = true
		raidTab:Disable()
		-- Apply disabled visual state
		PanelTemplates_SetDisabledTabState(raidTab)
		-- Add tooltip explaining why tab is disabled
		raidTab:SetScript("OnEnter", function(self)
			BFL_Tooltip:SetOwner(self, "ANCHOR_RIGHT", 0, 0)
			-- Use Blizzard's global string (available in Retail)
			local reason = DIFFICULTY_LOCKED_REASON_STORY_RAID or "Unavailable in Story Raid"
			BFL_Tooltip:SetText(RED_FONT_COLOR:WrapTextInColorCode(reason))
			BFL_Tooltip:Show()
		end)
		raidTab:SetScript("OnLeave", function(self)
			BFL_Tooltip:Hide()
		end)
	end

	return inStoryRaid
end

-- Show the friends frame
-- tabIndex: Optional tab to show (1=Friends, 2=Who, 3=Raid, 4=Quick Join)
function ShowBetterFriendsFrame(tabIndex) -- Clear search box
	ClearTopTabSearchState(BetterFriendsFrame, 1)

	-- Use UI Panel system if enabled (for auto-repositioning)
	-- Note: ShowUIPanel is protected in combat (since 8.2.0), fallback to Show()
	if BetterFriendlistDB and BetterFriendlistDB.useUIPanelSystem and not InCombatLockdown() then
		-- Configure UI Panel Layout attributes
		ConfigureUIPanelAttributes(true)
		ShowUIPanel(BetterFriendsFrame)
		BFL:DebugPrint("ShowUIPanel called")
	else
		-- Direct :Show() - combat-safe fallback
		if BetterFriendlistDB and BetterFriendlistDB.useUIPanelSystem and InCombatLockdown() then
			SuspendBetterFriendsFrameUIPanelForCombat()
		else
			-- Ensure attributes are disabled when not using UI Panel system
			ConfigureUIPanelAttributes(false)
		end
		BetterFriendsFrame:Show()
	end

	-- Force immediate refresh AFTER Show() so the frame is visible
	-- and UpdateFriendsList() won't bail due to the visibility check.
	-- Note: OnShow also calls UpdateFriendsList when needsRenderOnShow is set,
	-- but ForceRefresh additionally updates font caches and clears pending updates.
	BFL:ForceRefreshFriendsList()

	-- Switch to requested tab if specified, otherwise always default to Friends tab
	-- Fix #81: Do NOT remember the last selected tab - always open Friends tab when
	-- no explicit tab is requested (prevents Who/Quick Join tab stickiness)
	local targetTab = tabIndex or 1

	-- Fix #43: Update Raid tab state (disable if in Story Mode)
	local inStoryRaid = BetterFriendsFrame_UpdateRaidTabState()

	-- Fix #43: If trying to open Raid tab while in Story Mode, redirect to Friends tab
	if inStoryRaid and targetTab == 3 then
		targetTab = 1
	end

	-- Reset top tab (FriendsTabHeader) to Friends when reopening
	-- Fix: Without this, reopening after viewing Recent Allies/RAF would show
	-- stale content because FriendsTabHeader.selectedTab was not reset
	if not BFL.IsClassic then
		BetterFriendsFrame_ShowTab(1)
	end

	if targetTab >= 1 and targetTab <= 4 then
		-- BFL:DebugPrint("[BFL] ShowBetterFriendsFrame: Switching to tab " .. targetTab)
		PanelTemplates_SetTab(BetterFriendsFrame, targetTab)
		BetterFriendsFrame_ShowBottomTab(targetTab)
	end
end

-- Hide the friends frame
function HideBetterFriendsFrame() -- Clear friend selection (like Blizzard's FriendsFrame)
	local FriendsList = GetFriendsList()
	if FriendsList then
		if FriendsList.selectedButton and FriendsList.selectedButton.selectionHighlight then
			FriendsList.selectedButton.selectionHighlight:Hide()
		end
		FriendsList.selectedFriend = nil
		FriendsList.selectedFriendUID = nil
		FriendsList.selectedButton = nil
	end

	-- Use UI Panel system if enabled
	-- Note: HideUIPanel is protected in combat (since 8.2.0), fallback to Hide()
	if BetterFriendlistDB and BetterFriendlistDB.useUIPanelSystem and not InCombatLockdown() then
		HideUIPanel(BetterFriendsFrame)
	else
		-- Direct :Hide() - combat-safe fallback
		if BetterFriendlistDB and BetterFriendlistDB.useUIPanelSystem and InCombatLockdown() then
			SuspendBetterFriendsFrameUIPanelForCombat()
		end
		BetterFriendsFrame:Hide()
	end
end

-- Toggle the friends frame
-- tabIndex: Optional tab to show when opening (1=Friends, 2=Who, 3=Raid, 4=Quick Join)
function ToggleBetterFriendsFrame(tabIndex) -- BFL:DebugPrint("[BFL] ToggleBetterFriendsFrame() called - Frame shown: " .. tostring(BetterFriendsFrame:IsShown()) .. ", tabIndex: " .. tostring(tabIndex))
	if BetterFriendsFrame:IsShown() then
		-- If already shown and same tab (or no tab specified), close it
		-- If different tab requested, switch to that tab instead of closing
		if tabIndex and tabIndex >= 1 and tabIndex <= 4 then
			local currentTab = PanelTemplates_GetSelectedTab(BetterFriendsFrame) or 1
			if currentTab ~= tabIndex then
				-- BFL:DebugPrint("[BFL] Switching from tab " .. currentTab .. " to tab " .. tabIndex)
				PanelTemplates_SetTab(BetterFriendsFrame, tabIndex)
				BetterFriendsFrame_ShowBottomTab(tabIndex)
				return
			end
		end
		-- BFL:DebugPrint("[BFL] Hiding BetterFriendsFrame")
		HideBetterFriendsFrame()
	else
		-- BFL:DebugPrint("[BFL] Showing BetterFriendsFrame")
		ShowBetterFriendsFrame(tabIndex)
	end
end

-- Helper functions for child frame visibility
local function ShowChildFrame(childFrame)
	if not childFrame then
		return
	end
	childFrame:Show()
end

local function HideChildFrame(childFrame)
	if not childFrame then
		return
	end
	childFrame:Hide()
end

-- ========================================
-- EVENT HANDLERS
-- ========================================

-- Event handler
frame:SetScript("OnEvent", function(self, event, ...)
	if event == "ADDON_LOADED" then
		local addonName = ...
		if addonName == "BetterFriendlist" then
			-- Note: Version print is in Core.lua ADDON_LOADED handler (prevents duplicate)

			-- Initialize menu system
			InitializeMenuSystem()

			-- Load saved filter mode from database
			LoadFilterMode()

			-- Sync groups from module (if available)
			local FriendsListModule = GetFriendsList()
			if FriendsListModule and FriendsListModule.SyncGroups then
				FriendsListModule:SyncGroups()
			end

			-- Force initial friends list update (Classic: ensure list is populated on load)
			if BFL.IsClassic then
				local FriendsList = GetFriendsList()
				if FriendsList and FriendsList.UpdateFriendsList then
					-- BFL:DebugPrint("|cff00ffffADDON_LOADED:|r Scheduling initial Classic UpdateFriendsList")
					C_Timer.After(0.5, function()
						if FriendsList and FriendsList.UpdateFriendsList then
							FriendsList:UpdateFriendsList()
						end
					end)
				end
			end

			-- FriendsList:Initialize() is called by Core.lua's module initialization loop.
			-- Do NOT call it again here — double init creates orphaned Classic button frames
			-- that cause duplicate rendering (buttons from 1st init remain visible alongside 2nd init's buttons).

			-- Initialize saved variables (fallback if modules not used)
			BetterFriendlistDB = BetterFriendlistDB or {}
			BetterFriendlistDB.groupStates = BetterFriendlistDB.groupStates or {}
			BetterFriendlistDB.customGroups = BetterFriendlistDB.customGroups or {}
			BetterFriendlistDB.friendGroups = BetterFriendlistDB.friendGroups or {}

			-- Load custom groups from DB (if not using modules)
			local Groups = GetGroups()
			if not Groups then
				-- Fallback: Load custom groups manually
				for groupId, groupInfo in pairs(BetterFriendlistDB.customGroups) do
					if not BetterFriendlistDB.friendGroups[groupId] then
						BetterFriendlistDB.friendGroups[groupId] = {
							id = groupId,
							name = groupInfo.name,
							collapsed = groupInfo.collapsed or false,
							builtin = false,
							order = groupInfo.order or 50,
							color = { r = 0, g = 0.7, b = 1.0 },
							icon = "Interface\\FriendsFrame\\UI-Toast-ChatInviteIcon",
						}
					end
				end
			end

			-- Load collapsed states
			for groupId, groupData in pairs(BetterFriendlistDB.friendGroups) do
				if BetterFriendlistDB.groupStates[groupId] ~= nil then
					groupData.collapsed = BetterFriendlistDB.groupStates[groupId]
				end
			end

			-- Initialize Quick Filter dropdown
			if
				BetterFriendsFrame
				and BetterFriendsFrame.FriendsTabHeader
				and BetterFriendsFrame.FriendsTabHeader.QuickFilterDropdown
			then
				BetterFriendsFrame_InitQuickFilterDropdown()
			end

			-- COMBAT FIX: ESC key via UISpecialFrames
			-- Add our frame to UISpecialFrames so it closes on ESC
			if BetterFriendsFrame then
				if BFL.AddUniqueUISpecialFrame then
					BFL:AddUniqueUISpecialFrame("BetterFriendsFrame")
				end
				if BFL.InstallBetterFriendsFrameEscapeHandler then
					BFL:InstallBetterFriendsFrameEscapeHandler()
				end
			else
				-- If frame not created yet (though it should be loaded from XML), ensure it's added later
				local f = CreateFrame("Frame")
				f:RegisterEvent("PLAYER_LOGIN")
				f:SetScript("OnEvent", function()
					if BetterFriendsFrame then
						if BFL.AddUniqueUISpecialFrame then
							BFL:AddUniqueUISpecialFrame("BetterFriendsFrame")
						end
						if BFL.InstallBetterFriendsFrameEscapeHandler then
							BFL:InstallBetterFriendsFrameEscapeHandler()
						end
					end
				end)
			end

			-- For WoW 11.2: Use Menu.ModifyMenu with correct MENU_UNIT_* tags
			-- According to https://warcraft.wiki.gg/wiki/Blizzard_Menu_implementation_guide
			-- UnitPopup menus use "MENU_UNIT_<UNIT_TYPE>" format
			local function BFL_IsOwnMenuContext(contextData)
				if not contextData then
					return false
				end
				local ok, origin = pcall(function()
					return contextData.bflOrigin
				end)
				return ok and origin == ADDON_NAME
			end
			local function BFL_SafeContextValue(contextData, key)
				if not contextData then
					return nil
				end
				local ok, value = pcall(function()
					return contextData[key]
				end)
				if not ok or BFL:IsSecret(value) then
					return nil
				end
				return value
			end
			local function BFL_SafeFieldValue(tableValue, key)
				if BFL:IsSecret(tableValue) or not tableValue then
					return nil
				end
				local ok, value = pcall(function()
					return tableValue[key]
				end)
				if not ok or BFL:IsSecret(value) then
					return nil
				end
				return value
			end
			local function BFL_NumberOrNil(value)
				if type(value) == "number" then
					return value
				end
				if type(value) == "string" then
					return tonumber(value)
				end
				return nil
			end
			local function BFL_FirstSafeText(...)
				for i = 1, select("#", ...) do
					local value = select(i, ...)
					if not BFL:IsSecret(value) then
						if value and value ~= "" then
							return value
						end
					end
				end
				return UNKNOWN or "Unknown"
			end

			-- Fix for "Copy Character Name" protected action error
			-- Replaces the protected Blizzard button with a safe BFL version in generic menus
			-- Helper: Build the copy-name click handler (shared between replace and fallback)
			local function BFL_BuildCopyNameHandler(contextData, menuTypeWrapper)
				return function()
					-- Resolve Name Lazy (at click time) for maximum accuracy
					local copyNameText = nil

					if contextData.bnetIDAccount then
						local accountInfo = C_BattleNet.GetAccountInfoByID(contextData.bnetIDAccount)
						if accountInfo then
							-- 1. Try Character Name (if in-game)
							if accountInfo.gameAccountInfo and accountInfo.gameAccountInfo.characterName then
								local charName = accountInfo.gameAccountInfo.characterName
								local realmName = accountInfo.gameAccountInfo.realmName
								if realmName and realmName ~= "" then
									copyNameText = charName .. "-" .. realmName
								else
									copyNameText = charName
								end
							end

							-- 2. Try BattleTag (if Valid and Character Name failed, or as backup)
							if (not copyNameText or copyNameText == "") and accountInfo.battleTag then
								copyNameText = accountInfo.battleTag
							end

							-- 3. Try Real Name (Account Name) if nothing else
							-- [STREAMER MODE CHECK] Never expose Real ID in Streamer Mode
							if (not copyNameText or copyNameText == "") and accountInfo.accountName then
								if BFL.StreamerMode and BFL.StreamerMode:IsActive() then
									copyNameText = accountInfo.battleTag or "Unknown"
								else
									copyNameText =
										BFL:GetSafeAccountName(accountInfo.accountName, accountInfo.battleTag)
								end
							end
						end
					end

					-- 4. Fallback to contextData.name (Generic)
					if not copyNameText or copyNameText == "" then
						if not BFL:IsSecret(contextData.name) and contextData.name then
							copyNameText = contextData.name
							if not string.find(copyNameText, "-") then
								if not BFL:IsSecret(contextData.server) and contextData.server and contextData.server ~= "" then
									copyNameText = copyNameText .. "-" .. contextData.server
								elseif not BFL:IsSecret(contextData.realm) and contextData.realm and contextData.realm ~= "" then
									copyNameText = copyNameText .. "-" .. contextData.realm
								end
							end
						end

						-- Special handling for Who List
						if menuTypeWrapper == "WHO" and (contextData.index or contextData.whoIndex) then
							local index = contextData.whoIndex or contextData.index
							local info = C_FriendList.GetWhoInfo(index)
							if info and info.fullName then
								copyNameText = info.fullName
							end
						end
					end

					StaticPopupDialogs["BETTERFRIENDLIST_COPY_URL"] = {
						text = L.COPY_CHARACTER_NAME_POPUP_TITLE,
						button1 = CLOSE,
						hasEditBox = true,
						editBoxWidth = 350,
						OnShow = function(self)
							self.EditBox:SetText(copyNameText or "")
							self.EditBox:SetFocus()
							self.EditBox:HighlightText()
							self.EditBox:SetScript("OnKeyUp", function(editBox, key)
								if IsControlKeyDown() and key == "C" then
									editBox:GetParent():Hide()
								end
							end)
						end,
						EditBoxOnEnterPressed = function(self)
							self:GetParent():Hide()
						end,
						EditBoxOnEscapePressed = function(self)
							self:GetParent():Hide()
						end,
						timeout = 0,
						whileDead = true,
						hideOnEscape = true,
						preferredIndex = 3,
					}
					StaticPopup_Show("BETTERFRIENDLIST_COPY_URL")
				end
			end

			local function BFL_ReplaceCopyNameButton(owner, rootDescription, contextData, menuTypeWrapper)
				-- Only mutate menus that BFL opened itself. Native Blizzard menus should not
				-- inherit BFL responders just because our Menu.ModifyMenu callback exists.
				if not BFL_IsOwnMenuContext(contextData) then
					return
				end
				if not rootDescription or not rootDescription.EnumerateElementDescriptions then
					return
				end

				-- Check if this BNet friend has multiple WoW game accounts (submenu mode)
				-- Only WoW accounts have a character name to copy
				local multiAccountEntries
				if contextData and contextData.bnetIDAccount then
					local FriendsListModule = GetFriendsList()
					if FriendsListModule and FriendsListModule.friendsList then
						for _, f in ipairs(FriendsListModule.friendsList) do
							if f.type == "bnet" and f.bnetAccountID == contextData.bnetIDAccount then
								if f.gameAccounts and #f.gameAccounts > 1 then
									local wowAccounts = {}
									for _, ga in ipairs(f.gameAccounts) do
										if ga.clientProgram == (BNET_CLIENT_WOW or "WoW") then
											local entry = FriendsListModule:BuildAccountDisplay(ga)
											if entry then
												wowAccounts[#wowAccounts + 1] = entry
											end
										end
									end
									if #wowAccounts > 1 then
										multiAccountEntries = wowAccounts
										-- Sort alphabetically by character name
										table.sort(multiAccountEntries, function(a, b)
											local aName = (a.raw and a.raw.characterName) or a.text or ""
											local bName = (b.raw and b.raw.characterName) or b.text or ""
											return tostring(aName):lower() < tostring(bName):lower()
										end)
									end
								end
								break
							end
						end
					end
				end

				local targetText = COPY_CHARACTER_NAME -- Blizzard global string
				local foundAndReplaced = false

				-- Helper: Build a copy handler for a specific name string
				local function BuildCopyHandler(copyNameText)
					return function()
						StaticPopupDialogs["BETTERFRIENDLIST_COPY_URL"] = {
							text = L.COPY_CHARACTER_NAME_POPUP_TITLE,
							button1 = CLOSE,
							hasEditBox = true,
							editBoxWidth = 350,
							OnShow = function(self)
								self.EditBox:SetText(copyNameText or "")
								self.EditBox:SetFocus()
								self.EditBox:HighlightText()
								self.EditBox:SetScript("OnKeyUp", function(editBox, key)
									if IsControlKeyDown() and key == "C" then
										editBox:GetParent():Hide()
									end
								end)
							end,
							EditBoxOnEnterPressed = function(self)
								self:GetParent():Hide()
							end,
							EditBoxOnEscapePressed = function(self)
								self:GetParent():Hide()
							end,
							timeout = 0,
							whileDead = true,
							hideOnEscape = true,
							preferredIndex = 3,
						}
						StaticPopup_Show("BETTERFRIENDLIST_COPY_URL")
					end
				end

				-- Wrap in pcall: if Blizzard's button is restricted/protected, SetResponder
				-- can throw a taint error which would kill the entire function and prevent
				-- the fallback from running.
				pcall(function()
					for _, elementDescription in rootDescription:EnumerateElementDescriptions() do
						-- Check text (handle function or string)
						local text = elementDescription.text
						if type(text) == "function" then
							local success, result = pcall(text)
							if success then
								text = result
							end
						end

						-- Check for match: Text match OR Data match
						local isMatch = false

						-- Match by global string (localized) or fallback english
						if
							text
							and (
								text == targetText
								or text == "Copy Character Name"
								or (L and text == L.MENU_COPY_CHARACTER_NAME)
							)
						then
							isMatch = true
						-- Match by internal data key (Blizzard usually uses this for UnitPopup)
						elseif elementDescription.data == "COPY_CHARACTER_NAME" then
							isMatch = true
						end

						if isMatch then
							if multiAccountEntries and #multiAccountEntries > 1 then
								-- Multi-account: Convert to submenu
								elementDescription:SetResponder(function()
									return MenuResponse and MenuResponse.Open or false
								end)

								-- Update text to our localized version
								if L and L.MENU_COPY_CHARACTER_NAME then
									if elementDescription.SetText then
										elementDescription:SetText(L.MENU_COPY_CHARACTER_NAME)
									else
										elementDescription.text = L.MENU_COPY_CHARACTER_NAME
									end
								end

								-- Add character entries as submenu children
								for _, entry in ipairs(multiAccountEntries) do
									local displayText = entry.text or "Unknown"
									local charName = entry.raw and entry.raw.characterName or "Unknown"
									local realmName = entry.raw and entry.raw.realmName or ""
									local copyText
									if realmName ~= "" then
										copyText = charName .. "-" .. realmName
									else
										copyText = charName
									end
									elementDescription:CreateButton(displayText, BuildCopyHandler(copyText))
								end
							else
								-- Single account or non-BNet: Hijack the click handler directly
								elementDescription:SetResponder(BFL_BuildCopyNameHandler(contextData, menuTypeWrapper))

								-- Update text to our localized version if available
								if L and L.MENU_COPY_CHARACTER_NAME then
									if elementDescription.SetText then
										elementDescription:SetText(L.MENU_COPY_CHARACTER_NAME)
									else
										elementDescription.text = L.MENU_COPY_CHARACTER_NAME
									end
								end
							end

							foundAndReplaced = true
							break
						end
					end
				end)

				-- If not found or replacement failed (protected button), add our own safe button
				if not foundAndReplaced and rootDescription.CreateButton then
					local buttonText = (L and L.MENU_COPY_CHARACTER_NAME) or "Copy Character Name"
					if multiAccountEntries and #multiAccountEntries > 1 then
						-- Add as submenu with all character entries
						local submenu = rootDescription:CreateButton(buttonText, function()
							return MenuResponse and MenuResponse.Open or false
						end)
						if submenu and submenu.CreateButton then
							for _, entry in ipairs(multiAccountEntries) do
								local displayText = entry.text or "Unknown"
								local charName = entry.raw and entry.raw.characterName or "Unknown"
								local realmName = entry.raw and entry.raw.realmName or ""
								local copyText
								if realmName ~= "" then
									copyText = charName .. "-" .. realmName
								else
									copyText = charName
								end
								submenu:CreateButton(displayText, BuildCopyHandler(copyText))
							end
						end
					else
						rootDescription:CreateButton(buttonText, BFL_BuildCopyNameHandler(contextData, menuTypeWrapper))
					end
				end
			end

			-- Legacy BFL-owned UnitPopup wrapper for clients without secret values.
			-- Secret-value builds use the BFL custom menu and Taint-Free Whisper paths instead
			-- of replacing native Blizzard responders.
			local function BFL_ReplaceWhisperButton(owner, rootDescription, contextData)
				if BFL.HasSecretValues then
					return
				end
				-- This workaround is only for BFL-created UnitPopup contextData.
				if not BFL_IsOwnMenuContext(contextData) then
					return
				end
				if not rootDescription or not rootDescription.EnumerateElementDescriptions then
					return
				end
				if not contextData then
					return
				end

				pcall(function()
					for _, elementDescription in rootDescription:EnumerateElementDescriptions() do
						local text = elementDescription.text
						if type(text) == "function" then
							local success, result = pcall(text)
							if success then
								text = result
							end
						end
						if text == WHISPER then
							elementDescription:SetResponder(function()
								local isBNetAccount = contextData.bnetIDAccount
								if not isBNetAccount then
									local playerLocation = contextData.playerLocation
									if playerLocation and playerLocation.IsBattleNetGUID then
										isBNetAccount = playerLocation:IsBattleNetGUID()
									end
								end
								if isBNetAccount then
									BFL:SecureSendBNetTell(contextData.name, contextData.bnetIDAccount)
								else
									local fullName
									if UnitPopupSharedUtil and UnitPopupSharedUtil.GetFullPlayerName then
										fullName = UnitPopupSharedUtil.GetFullPlayerName(contextData)
									else
										fullName = contextData.name
									end
									BFL:SecureSendTell(fullName)
								end
								return MenuResponse.Close
							end)
							break
						end
					end
				end)
			end

			local function BFL_GetDisplayedInviteTypeSafe(guid)
				if not guid or not GetDisplayedInviteType or BFL:IsSecret(guid) then
					return nil
				end

				local ok, inviteType = pcall(GetDisplayedInviteType, guid)
				if ok then
					return inviteType
				end
				return nil
			end

			local function BFL_GetInviteButtonText(inviteType)
				local fallback = PARTY_INVITE or INVITE or "Invite"
				if inviteType == "SUGGEST_INVITE" or inviteType == "SUGGEST_INVITE_CROSS_FACTION" then
					return SUGGEST_INVITE or fallback
				elseif inviteType == "REQUEST_INVITE" or inviteType == "REQUEST_INVITE_CROSS_FACTION" then
					return REQUEST_INVITE or fallback
				elseif inviteType == "INVITE_CROSS_FACTION" and TRAVEL_PASS_INVITE_CROSS_FACTION then
					return TRAVEL_PASS_INVITE_CROSS_FACTION
				end
				return fallback
			end

			local function BFL_InvokeBNetInviteOrRequest(entry)
				if not entry or entry.isInvitable == false or not entry.gameAccountID then
					return false
				end

				local inviteType = entry.inviteType or BFL_GetDisplayedInviteTypeSafe(entry.playerGuid)
				if FriendsFrame_InviteOrRequestToJoin and entry.playerGuid then
					FriendsFrame_InviteOrRequestToJoin(entry.playerGuid, entry.gameAccountID)
					return true
				elseif (inviteType == "REQUEST_INVITE" or inviteType == "REQUEST_INVITE_CROSS_FACTION") and BNRequestInviteFriend then
					BNRequestInviteFriend(entry.gameAccountID)
					return true
				elseif BFL.BNInviteFriend then
					BFL.BNInviteFriend(entry.gameAccountID)
					return true
				end
				return false
			end

			-- Multi-Game-Account: Replace Blizzard's single invite button with a character picker submenu
			-- Only for BNet friend menus where the friend has multiple invitable WoW accounts
			local function BFL_ReplaceInviteButton(owner, rootDescription, contextData)
				if not BFL_IsOwnMenuContext(contextData) then
					return
				end
				if not rootDescription or not rootDescription.EnumerateElementDescriptions then
					BFL:DebugPrint("ReplaceInvite: No rootDescription or EnumerateElementDescriptions")
					return
				end
				if not contextData or not contextData.bnetIDAccount then
					BFL:DebugPrint("ReplaceInvite: No contextData or bnetIDAccount")
					return
				end

				-- Find friend data from FriendsList module
				local FriendsList = GetFriendsList()
				if not FriendsList or not FriendsList.friendsList then
					BFL:DebugPrint("ReplaceInvite: No FriendsList module")
					return
				end

				local friendData
				for _, f in ipairs(FriendsList.friendsList) do
					if f.type == "bnet" and f.bnetAccountID == contextData.bnetIDAccount then
						friendData = f
						break
					end
				end

				-- Only enhance for friends with multiple invitable accounts
				if not friendData then
					BFL:DebugPrint("ReplaceInvite: Friend not found for bnetID=" .. tostring(contextData.bnetIDAccount))
					return
				end
				if not friendData.invitableAccounts then
					BFL:DebugPrint(
						"ReplaceInvite: No invitableAccounts for friend (numGameAccounts="
							.. tostring(friendData.numGameAccounts)
							.. ")"
					)
					return
				end
				if #friendData.invitableAccounts <= 1 then
					BFL:DebugPrint(
						"ReplaceInvite: Only " .. #friendData.invitableAccounts .. " invitable account(s), skipping"
					)
					return
				end

				BFL:DebugPrint(
					"ReplaceInvite: Friend has "
						.. #friendData.invitableAccounts
						.. " invitable accounts, searching menu elements..."
				)

				-- Text/data keys used by Blizzard's invite buttons
				local inviteTexts = {}
				if PARTY_INVITE then
					inviteTexts[PARTY_INVITE] = true
					BFL:DebugPrint("ReplaceInvite: PARTY_INVITE = '" .. tostring(PARTY_INVITE) .. "'")
				end
				if SUGGEST_INVITE then
					inviteTexts[SUGGEST_INVITE] = true
				end
				if REQUEST_INVITE then
					inviteTexts[REQUEST_INVITE] = true
				end

				local inviteDataKeys = {
					["BN_INVITE"] = true,
					["BN_SUGGEST_INVITE"] = true,
					["BN_REQUEST_INVITE"] = true,
				}

				local friendUID = FriendsList:GetFriendUID(friendData)
				local lastInvited = friendUID and FriendsList:GetLastInvitedAccountID(friendUID) or nil

				local entries = {}
				for _, ga in ipairs(friendData.invitableAccounts) do
					local entry = FriendsList:BuildAccountDisplay(ga)
					if entry then
						entry.isPreferred = lastInvited and entry.gameAccountID == lastInvited or false
						entry.inviteType = BFL_GetDisplayedInviteTypeSafe(entry.playerGuid)
						entry.inviteText = BFL_GetInviteButtonText(entry.inviteType)
						entries[#entries + 1] = entry
					end
				end

				local inviteText = entries[1] and entries[1].inviteText
				local mixedInviteTypes = false
				for _, entry in ipairs(entries) do
					if entry.inviteText ~= inviteText then
						mixedInviteTypes = true
						break
					end
				end

				local function GetRestrictionText(restriction)
					return GetBFLInviteRestrictionText(restriction, L)
				end

				local function FormatEntryText(entry)
					local label = entry.text or "Unknown"
					if mixedInviteTypes and entry.inviteText then
						label = entry.inviteText .. ": " .. label
					end
					if entry.isPreferred then
						label = "* " .. label
					end

					if not entry.isInvitable then
						local reason = GetRestrictionText(entry.restriction)
						if reason and reason ~= "" then
							label = label .. " (" .. reason .. ")"
						else
							label = label .. " (Not invitable)"
						end
					end
					return label
				end

				table.sort(entries, function(a, b)
					if a.isPreferred ~= b.isPreferred then
						return a.isPreferred
					end

					local aName = (a.raw and a.raw.characterName) or a.text or ""
					local bName = (b.raw and b.raw.characterName) or b.text or ""
					return tostring(aName):lower() < tostring(bName):lower()
				end)

				local found = false
				local ok, err = pcall(function()
					local elementCount = 0
					for _, elementDescription in rootDescription:EnumerateElementDescriptions() do
						elementCount = elementCount + 1
						local text = elementDescription.text
						local rawTextType = type(text)
						if type(text) == "function" then
							local fOk, fResult = pcall(text)
							if fOk then
								text = fResult
							end
						end

						-- Debug: log every element's text and data
						local dataVal = elementDescription.data
						BFL:DebugPrint(
							"  Element #"
								.. elementCount
								.. ": text="
								.. tostring(text)
								.. " (raw type="
								.. rawTextType
								.. "), data="
								.. tostring(dataVal)
						)

						local isInvite = false
						if text and inviteTexts[text] then
							isInvite = true
							BFL:DebugPrint("  -> MATCHED via text!")
						elseif dataVal and inviteDataKeys[tostring(dataVal)] then
							isInvite = true
							BFL:DebugPrint("  -> MATCHED via data key!")
						end

						if isInvite then
							found = true
							local replacementText = mixedInviteTypes
									and (TRAVEL_PASS_INVITE or PARTY_INVITE or INVITE or "Invite")
								or inviteText
							if replacementText then
								if elementDescription.SetText then
									elementDescription:SetText(replacementText)
								else
									elementDescription.text = replacementText
								end
							end
							-- Convert from single-click button to submenu parent
							-- Suppress original handler: return MenuResponse.Open to keep submenu visible
							elementDescription:SetResponder(function()
								return MenuResponse and MenuResponse.Open or false
							end)

							-- Add character entries as submenu children
							for _, entry in ipairs(entries) do
								local displayText = FormatEntryText(entry)
								elementDescription:CreateButton(displayText, function()
									local didInvite = BFL_InvokeBNetInviteOrRequest(entry)
									if didInvite and friendUID and entry.gameAccountID then
										FriendsList:SetLastInvitedAccountID(friendUID, entry.gameAccountID)
									end
								end)
							end
							BFL:DebugPrint(
								"ReplaceInvite: Successfully converted invite button to submenu with "
									.. #friendData.invitableAccounts
									.. " entries"
							)
							break -- Only one invite button should be visible at a time
						end
					end
					BFL:DebugPrint("ReplaceInvite: Scanned " .. elementCount .. " elements, found=" .. tostring(found))
				end)
				if not ok then
					BFL:DebugPrint("ReplaceInvite: pcall ERROR: " .. tostring(err))
				end
			end

			local function AddGroupsToFriendMenu(owner, rootDescription, contextData)
				if not contextData or not rootDescription or not rootDescription.CreateButton then
					return
				end

				local isOwnMenu = BFL_IsOwnMenuContext(contextData)
				local allowApiFallbacks = isOwnMenu or not BFL.HasSecretValues

				-- Native Menu.ModifyMenu callbacks may receive secret-backed contextData on 12.x.
				-- Read only shallow, pcall-protected values here; keep full fallback logic for BFL-owned menus.
				if BFL_SafeContextValue(contextData, "bflWhoPlayerMenu") or _G.BetterFriendlist_IsWhoPlayerMenu then
					return
				end

				local bnetIDAccount = BFL_SafeContextValue(contextData, "bnetIDAccount")
				local battleTag = BFL_SafeContextValue(contextData, "battleTag")
				local contextName = BFL_SafeContextValue(contextData, "name")
				local contextIndex = BFL_NumberOrNil(BFL_SafeContextValue(contextData, "index"))
				local contextFriendsList = BFL_NumberOrNil(BFL_SafeContextValue(contextData, "friendsList"))
				local contextGUID = BFL_SafeContextValue(contextData, "guid")
				local contextUID = BFL_SafeContextValue(contextData, "uid")
				local streamerDisplayName = BFL_SafeContextValue(contextData, "bfl_streamerDisplayName")

				if not bnetIDAccount then
					local accountInfo = BFL_SafeContextValue(contextData, "accountInfo")
					if accountInfo then
						bnetIDAccount = BFL_NumberOrNil(BFL_SafeFieldValue(accountInfo, "bnetAccountID"))
					end
				end

				if contextUID and BetterFriendlistDB.friendGroups and BetterFriendlistDB.friendGroups[contextUID] then
					contextUID = tostring(contextUID)
				else
					contextUID = nil
				end

				if
					not bnetIDAccount
					and not battleTag
					and not contextName
					and not contextIndex
					and not contextFriendsList
					and not contextGUID
					and not contextUID
				then
					return
				end

				local friendUID
				local friendData

				-- Prefer BFL's already-cached FriendsList data. This is the only resolver used by
				-- native secret-value menus.
				local FriendsList = GetFriendsList()
				if FriendsList and FriendsList.friendsList then
					if contextUID then
						for _, friend in ipairs(FriendsList.friendsList) do
							local cachedUID = friend.uid or FriendsList:GetFriendUID(friend)
							if cachedUID == contextUID then
								friendData = friend
								friendUID = cachedUID
								break
							end
						end
					end

					if not friendUID and (bnetIDAccount or battleTag or contextGUID) then
						for _, friend in ipairs(FriendsList.friendsList) do
							local idMatch = bnetIDAccount and friend.type == "bnet" and friend.bnetAccountID == bnetIDAccount
							local tagMatch = battleTag
								and battleTag ~= ""
								and friend.type == "bnet"
								and friend.battleTag == battleTag
							local friendGUID = friend.gameAccountInfo and friend.gameAccountInfo.playerGuid
							local guidMatch = contextGUID
								and friend.type == "bnet"
								and not BFL:IsSecret(friendGUID)
								and friendGUID == contextGUID
							if idMatch or tagMatch or guidMatch then
								friendData = friend
								friendUID = FriendsList:GetFriendUID(friend)
								BFL:DebugPrint(
									"|cff00ff00[MENU UID]|r Resolved BNet UID via FriendsList: " .. tostring(friendUID)
								)
								break
							end
						end
					end
					if not friendUID and (contextIndex or contextFriendsList or contextName or contextGUID) then
						local resolvedIndex = contextIndex or contextFriendsList
						if contextName and FriendsList.ResolveWoWFriendIndex then
							resolvedIndex = FriendsList:ResolveWoWFriendIndex(contextName) or resolvedIndex
						end
						local normalizedContextName = type(contextName) == "string" and contextName ~= ""
								and BFL:NormalizeWoWFriendName(contextName)
							or nil
						for _, friend in ipairs(FriendsList.friendsList) do
							if friend.type == "wow" then
								local normalizedFriendName = friend.name and BFL:NormalizeWoWFriendName(friend.name)
									or nil
								local indexMatch = resolvedIndex and friend.index == resolvedIndex
								local guidMatch = contextGUID
									and friend.guid
									and not BFL:IsSecret(friend.guid)
									and friend.guid == contextGUID
								local nameMatch = normalizedContextName
									and normalizedFriendName == normalizedContextName
								if indexMatch or guidMatch or nameMatch then
									friendData = friend
									friendUID = FriendsList:GetFriendUID(friend)
									BFL:DebugPrint(
										"|cff00ff00[MENU UID]|r Resolved WoW UID via FriendsList: "
											.. tostring(friendUID)
									)
									break
								end
							end
						end
					end
				end

				-- BFL-created menus may still use API fallbacks. Native secret-value menus must not.
				if not friendUID and allowApiFallbacks then
					if bnetIDAccount then
						local accountInfo
						if C_BattleNet and C_BattleNet.GetAccountInfoByID then
							accountInfo = C_BattleNet.GetAccountInfoByID(bnetIDAccount)
						end
						if accountInfo and accountInfo.battleTag and accountInfo.isFriend then
							friendUID = "bnet_" .. accountInfo.battleTag
						end

						if not friendUID and battleTag and battleTag ~= "" then
							if accountInfo and accountInfo.isFriend then
								friendUID = "bnet_" .. battleTag
							end
						end

						if not friendUID and BNGetNumFriends and C_BattleNet and C_BattleNet.GetFriendAccountInfo then
							local numBNet = BNGetNumFriends()
							for i = 1, numBNet do
								local bnInfo = C_BattleNet.GetFriendAccountInfo(i)
								if bnInfo and bnInfo.bnetAccountID == bnetIDAccount then
									if bnInfo.battleTag and bnInfo.battleTag ~= "" then
										friendUID = "bnet_" .. bnInfo.battleTag
									else
										friendUID = "bnet_" .. tostring(bnetIDAccount)
									end
									break
								end
							end
						end
					elseif type(contextName) == "string" and contextName ~= "" then
						local cleanContextName = contextName
						if string.find(cleanContextName, "-") then
							local cName, cRealm = strsplit("-", cleanContextName, 2)
							if cRealm then
								cleanContextName = cName .. "-" .. cRealm:gsub("%s+", "")
							end
						end

						if BNGetNumFriends and C_BattleNet and C_BattleNet.GetFriendAccountInfo then
							local numBNet = BNGetNumFriends()
							for i = 1, numBNet do
								local accountInfo = C_BattleNet.GetFriendAccountInfo(i)
								if
									accountInfo
									and accountInfo.gameAccountInfo
									and accountInfo.gameAccountInfo.characterName
								then
									local charName = accountInfo.gameAccountInfo.characterName
									local realmName = accountInfo.gameAccountInfo.realmName
									local fullName = charName
									local normalizedFullName = charName

									if realmName and realmName ~= "" then
										fullName = charName .. "-" .. realmName
										normalizedFullName = charName .. "-" .. realmName:gsub("%s+", "")
									end

									if
										fullName == contextName
										or normalizedFullName == contextName
										or normalizedFullName == cleanContextName
										or fullName == cleanContextName
										or charName == contextName
									then
										if accountInfo.battleTag then
											friendUID = "bnet_" .. accountInfo.battleTag
											break
										end
									end
								end
							end
						end

						if not friendUID then
							local targetName = contextName
							if string.find(targetName, "-") then
								local tName, tRealm = strsplit("-", targetName, 2)
								targetName = tName .. "-" .. tRealm:gsub("%s+", "")
							end

							local normalized = BFL:NormalizeWoWFriendName(targetName)
							if not BFL:IsActionRestricted() and C_FriendList.GetFriendInfo(normalized or targetName) then
								friendUID = "wow_" .. (normalized or targetName)
							end
						end
					end
				end

				if not friendUID then
					return
				end

				local friendCurrentGroups = {}
				if BetterFriendlistDB.friendGroups and BetterFriendlistDB.friendGroups[friendUID] then
					for _, gid in ipairs(BetterFriendlistDB.friendGroups[friendUID]) do
						friendCurrentGroups[gid] = true
					end
				end

				rootDescription:CreateDivider()
				rootDescription:CreateTitle(L.MENU_TITLE)

				local menuDisplayName = BFL_FirstSafeText(streamerDisplayName, contextName, battleTag)
				rootDescription:CreateButton(L.MENU_SET_NICKNAME, function()
					local DB = BFL:GetModule("DB")
					local currentNickname = DB and DB:GetNickname(friendUID)

					StaticPopup_Show("BETTER_FRIENDLIST_SET_NICKNAME", menuDisplayName or "Friend", nil, {
						uid = friendUID,
						nickname = currentNickname,
					})
				end)

				-- Keep richer game-account controls on BFL-owned menus only. Native Blizzard
				-- Menu.ModifyMenu callbacks append BFL group/nickname entries without mutating
				-- or expanding Blizzard-owned menu behavior.
				if bnetIDAccount and isOwnMenu then
					if not friendData and FriendsList and FriendsList.friendsList then
						for _, f in ipairs(FriendsList.friendsList) do
							if f.type == "bnet" and f.bnetAccountID == bnetIDAccount then
								friendData = f
								break
							end
						end
					end

					if friendData and friendData.gameAccounts and #friendData.gameAccounts > 1 then
						local switchButton =
							rootDescription:CreateButton((L and L.MENU_SWITCH_GAME_ACCOUNT) or "Switch Game Account")

						if FriendsList and FriendsList.GetDisplayName then
							switchButton:CreateTitle(FriendsList:GetDisplayName(friendData))
						end

						local currentPrefID = FriendsList and FriendsList:GetPreferredGameAccountID(friendUID)

						local function IsSelected(gaID)
							if gaID == "default" then
								return currentPrefID == nil
							end
							return currentPrefID == gaID
						end

						local function SetSelected(gaID)
							if not FriendsList then
								return
							end
							if gaID == "default" then
								FriendsList:ClearPreferredGameAccount(friendUID)
							else
								FriendsList:SetPreferredGameAccount(friendUID, gaID)
							end
						end

						local defaultText = (L and L.MENU_DEFAULT_FOCUS) or "Default (Server)"
						switchButton:CreateRadio(defaultText, IsSelected, SetSelected, "default")

						switchButton:CreateDivider()

						for _, ga in ipairs(friendData.gameAccounts) do
							if
								ga.clientProgram ~= "App"
								and ga.clientProgram ~= "CLNT"
								and ga.clientProgram ~= "BSAp"
							then
								local isWoW = ga.clientProgram == (BNET_CLIENT_WOW or "WoW")
								local label
								if isWoW then
									local display = FriendsList
										and FriendsList.BuildAccountDisplay
										and FriendsList:BuildAccountDisplay(ga)
									label = display and (display.coloredName or display.text)
										or ga.characterName
										or "Unknown"
									if ga.areaName and ga.areaName ~= "" then
										label = label .. " - " .. ga.areaName
									end
								else
									label = ga.richPresence or ga.clientProgram or "Unknown"
								end

								label = BFL:GetClientEmbeddedIcon(ga.clientProgram, 16) .. label
								switchButton:CreateRadio(label, IsSelected, SetSelected, ga.gameAccountID)
							end
						end
					end
				end

				local groupsButton = rootDescription:CreateButton(L.MENU_GROUPS)

				-- Enable scrolling for the groups submenu (User requested scrolling instead of dialog)
				if groupsButton.SetScrollMode then
					groupsButton:SetScrollMode(300) -- 300px limit before scrolling
				end

				-- Add "Create Group" option at the top
				groupsButton:CreateButton(L.MENU_CREATE_GROUP, function()
					StaticPopup_Show("BETTER_FRIENDLIST_CREATE_GROUP_AND_ADD_FRIEND", nil, nil, friendUID)
				end)

				-- Add "Remove from All Groups" button immediately after Create Group (User request)
				if next(friendCurrentGroups) then
					-- Add "Remove from All Groups" button
					local Groups = GetGroups()
					groupsButton:CreateButton(L.MENU_REMOVE_ALL_GROUPS, function()
						if Groups then
							for currentGroupId in pairs(friendCurrentGroups) do
								Groups:RemoveFriendFromGroup(friendUID, currentGroupId)
							end
							-- Force full display refresh - group membership affects display structure
							BFL:ForceRefreshFriendsList()
						end
					end)
				end

				-- Add "Remove from Recently Added" button if friend is in the Recently Added group
				local RecentlyAddedModule = BFL:GetModule("RecentlyAdded")
				if RecentlyAddedModule and RecentlyAddedModule:IsFriendRecentlyAdded(friendUID) then
					groupsButton:CreateButton(L.MENU_REMOVE_RECENTLY_ADDED or "Remove from Recently Added", function()
						RecentlyAddedModule:RemoveFriend(friendUID)
						BFL:ForceRefreshFriendsList()
					end)
				end

				-- Get fresh groups list directly from module (bypass stale local cache)
				local Groups = GetGroups()
				local friendGroups = Groups and Groups:GetAll() or {}

				-- Count custom groups
				local customGroupCount = 0
				for groupId, groupData in pairs(friendGroups) do
					if not groupData.builtin then
						customGroupCount = customGroupCount + 1
					end
				end

				-- Add divider if there are custom groups
				if customGroupCount > 0 then
					groupsButton:CreateDivider()

					-- Collect and sort custom groups by order
					local sortedGroups = {}
					for groupId, groupData in pairs(friendGroups) do
						if not groupData.builtin then
							table.insert(sortedGroups, { id = groupId, data = groupData })
						end
					end
					table.sort(sortedGroups, function(a, b)
						return a.data.order < b.data.order
					end)

					-- Add checkbox for each custom group in sorted order
					for _, group in ipairs(sortedGroups) do
						-- Capture group.id in local variable for closure
						local groupId = group.id

						groupsButton:CreateCheckbox(
							group.data.name,
							function() -- Read state dynamically from DB each time checkbox is rendered
								if BetterFriendlistDB.friendGroups and BetterFriendlistDB.friendGroups[friendUID] then
									for _, gid in ipairs(BetterFriendlistDB.friendGroups[friendUID]) do
										if gid == groupId then
											return true
										end
									end
								end
								return false
							end,
							function()
								local Groups = GetGroups()
								if Groups then
									Groups:ToggleFriendInGroup(friendUID, groupId)
									-- Force full display refresh - group membership affects display structure
									BFL:ForceRefreshFriendsList()
								end
							end
						)
					end -- End of group checkboxes
				end
			end

			local function AddRecentAllyOptions(owner, rootDescription, contextData)
				if not BFL_IsOwnMenuContext(contextData) then
					return
				end

				if not contextData or BFL:IsSecret(contextData.name) or not contextData.name then
					return
				end

				-- Check if already a friend (by GUID if available)
				if contextData.guid and C_FriendList.IsFriend(contextData.guid) then
					return
				end

				if BNFeaturesEnabledAndConnected() then
					local button = rootDescription:CreateButton(ADD_FRIEND)
					button:CreateButton(CHARACTER_FRIEND or "Character Friend", function()
						C_FriendList.AddFriend(contextData.name)
					end)
				else
					rootDescription:CreateButton(ADD_FRIEND, function()
						C_FriendList.AddFriend(contextData.name)
					end)
				end
			end

			local function BFL_GetContextFullName(contextData)
				if not contextData or BFL:IsSecret(contextData.name) or not contextData.name then
					return nil
				end
				if
					not BFL:IsSecret(contextData.server)
					and contextData.server
					and contextData.server ~= ""
					and not string.find(contextData.name, "-", 1, true)
				then
					return contextData.name .. "-" .. contextData.server
				end
				return contextData.name
			end

			local function BFL_HasFriendListContext(contextData)
				return contextData and contextData.friendsList ~= nil
			end

			local function BFL_FindBNetFriendData(contextData)
				if not contextData or not contextData.bnetIDAccount then
					return nil, nil
				end
				local FriendsList = GetFriendsList()
				if not FriendsList or not FriendsList.friendsList then
					return nil, FriendsList
				end
				for _, friend in ipairs(FriendsList.friendsList) do
					if friend.type == "bnet" and friend.bnetAccountID == contextData.bnetIDAccount then
						return friend, FriendsList
					end
				end
				return nil, FriendsList
			end

			local function BFL_AddInviteButton(rootDescription, contextData, isBNet, isOffline)
				if isOffline or not rootDescription or not contextData then
					return
				end

				if isBNet then
					local friendData, FriendsList = BFL_FindBNetFriendData(contextData)
					if friendData and friendData.invitableAccounts and #friendData.invitableAccounts > 0 then
						local friendUID = FriendsList and FriendsList.GetFriendUID and FriendsList:GetFriendUID(friendData)
						local lastInvited = friendUID and FriendsList.GetLastInvitedAccountID
							and FriendsList:GetLastInvitedAccountID(friendUID)
							or nil
						local entries = {}

						for _, ga in ipairs(friendData.invitableAccounts) do
							local entry = FriendsList.BuildAccountDisplay and FriendsList:BuildAccountDisplay(ga)
							if entry then
								entry.isPreferred = lastInvited and entry.gameAccountID == lastInvited or false
								entry.inviteType = BFL_GetDisplayedInviteTypeSafe(entry.playerGuid)
								entry.inviteText = BFL_GetInviteButtonText(entry.inviteType)
								entries[#entries + 1] = entry
							end
						end

						local function InviteEntry(entry)
							local didInvite = BFL_InvokeBNetInviteOrRequest(entry)
							if didInvite and friendUID and entry.gameAccountID and FriendsList.SetLastInvitedAccountID then
								FriendsList:SetLastInvitedAccountID(friendUID, entry.gameAccountID)
							end
						end

						if #entries > 1 then
							local inviteText = entries[1].inviteText
							local mixedInviteTypes = false
							for _, entry in ipairs(entries) do
								if entry.inviteText ~= inviteText then
									mixedInviteTypes = true
									break
								end
							end

							local inviteMenu = rootDescription:CreateButton(
								mixedInviteTypes and (TRAVEL_PASS_INVITE or PARTY_INVITE or INVITE or "Invite")
									or (inviteText or BFL_GetInviteButtonText(nil))
							)
							for _, entry in ipairs(entries) do
								local label = entry.text or entry.name or UNKNOWN or "Unknown"
								if mixedInviteTypes and entry.inviteText then
									label = entry.inviteText .. ": " .. label
								end
								inviteMenu:CreateButton(label, function()
									InviteEntry(entry)
								end)
							end
						elseif entries[1] then
							rootDescription:CreateButton(entries[1].inviteText or BFL_GetInviteButtonText(nil), function()
								InviteEntry(entries[1])
							end)
						end
					end
					return
				end

				local fullName = BFL_GetContextFullName(contextData)
				if fullName and BFL.InviteUnit then
					local inviteType = BFL_GetDisplayedInviteTypeSafe(contextData.guid) or "INVITE"
					local buttonText = BFL_GetInviteButtonText(inviteType)

					rootDescription:CreateButton(buttonText, function()
						if inviteType == "REQUEST_INVITE" or inviteType == "REQUEST_INVITE_CROSS_FACTION" then
							if BFL.RequestInviteFromUnit then
								BFL.RequestInviteFromUnit(fullName)
							elseif C_PartyInfo and C_PartyInfo.RequestInviteFromUnit then
								C_PartyInfo.RequestInviteFromUnit(fullName)
							end
						elseif BFL.InviteUnit then
							BFL.InviteUnit(fullName)
						end
					end)
				end
			end

			local function BFL_GetBNetAccountInfo(contextData)
				if not contextData or not contextData.bnetIDAccount then
					return nil
				end
				if contextData.accountInfo then
					return contextData.accountInfo
				end
				if C_BattleNet and C_BattleNet.GetAccountInfoByID then
					local accountInfo = C_BattleNet.GetAccountInfoByID(contextData.bnetIDAccount)
					contextData.accountInfo = accountInfo
					return accountInfo
				end
				return nil
			end

			local function BFL_GetBNetGameAccountInfo(contextData)
				local accountInfo = BFL_GetBNetAccountInfo(contextData)
				return accountInfo and accountInfo.gameAccountInfo or nil
			end

			local function BFL_GetContextGUID(contextData, isBNet)
				if not contextData then
					return nil
				end
				if contextData.guid then
					return contextData.guid
				end
				if isBNet then
					local gameAccountInfo = BFL_GetBNetGameAccountInfo(contextData)
					return gameAccountInfo and gameAccountInfo.playerGuid or nil
				end
				return nil
			end

			local function BFL_GetContextDisplayName(contextData, isBNet)
				if isBNet then
					local accountInfo = BFL_GetBNetAccountInfo(contextData)
					if accountInfo then
						return BFL_FirstSafeText(
							contextData.bfl_streamerDisplayName,
							accountInfo.accountName,
							accountInfo.battleTag,
							contextData.battleTag,
							contextData.name
						)
					end
				end
				return BFL_FirstSafeText(contextData and contextData.bfl_streamerDisplayName, contextData and contextData.name)
			end

			local function BFL_GetContextPlayerLocation(contextData)
				if not contextData or not UnitPopupSharedUtil or not UnitPopupSharedUtil.TryCreatePlayerLocation then
					return nil
				end
				local ok, playerLocation = pcall(UnitPopupSharedUtil.TryCreatePlayerLocation, contextData)
				if ok then
					return playerLocation
				end
				return nil
			end

			local function BFL_IsValidReportLocation(playerLocation)
				if not playerLocation then
					return false
				end
				if UnitPopupSharedUtil and UnitPopupSharedUtil.IsValidPlayerLocation then
					local ok, valid = pcall(UnitPopupSharedUtil.IsValidPlayerLocation, playerLocation)
					return ok and valid
				end
				return playerLocation.IsValid and playerLocation:IsValid()
			end

			local function BFL_CanReportPlayer(playerLocation)
				if not BFL_IsValidReportLocation(playerLocation) then
					return false
				end
				if C_ReportSystem and C_ReportSystem.CanReportPlayer then
					local ok, canReport = pcall(C_ReportSystem.CanReportPlayer, playerLocation)
					return ok and canReport
				end
				return false
			end

			local function BFL_ShowReportFrame(contextData, reportType, isBNet)
				if not (ReportInfo and ReportInfo.CreateReportInfoFromType and ReportFrame and ReportFrame.InitiateReport) then
					return
				end
				local playerLocation = BFL_GetContextPlayerLocation(contextData)
				if not BFL_CanReportPlayer(playerLocation) then
					return
				end
				local reportInfo = ReportInfo:CreateReportInfoFromType(reportType)
				local fullName = BFL_GetContextFullName(contextData) or BFL_GetContextDisplayName(contextData, isBNet)
				ReportFrame:InitiateReport(reportInfo, fullName, playerLocation, isBNet and true or false)
			end

			local function BFL_AddPopoutChatButton(rootDescription, contextData)
				local chatType = contextData and contextData.chatType
				local chatTarget = contextData and contextData.chatTarget
				if not ((chatType == "WHISPER" or chatType == "BN_WHISPER") and chatTarget) then
					return
				end
				if FCFManager_GetNumDedicatedFrames and FCFManager_GetNumDedicatedFrames(chatType, chatTarget) ~= 0 then
					return
				end
				if FCF_OpenTemporaryWindow then
					rootDescription:CreateButton(MOVE_TO_WHISPER_WINDOW or "Move to Whisper Window", function()
						FCF_OpenTemporaryWindow(chatType, chatTarget, contextData.chatFrame, true)
					end)
				end
			end

			local function BFL_AddSetNoteButton(rootDescription, contextData, isBNet)
				if isBNet and contextData.bnetIDAccount then
					rootDescription:CreateButton(SET_NOTE or "Set Note", function()
						if not FriendsFrame and C_AddOns and C_AddOns.LoadAddOn then
							pcall(C_AddOns.LoadAddOn, "Blizzard_FriendsFrame")
						end
						if FriendsFrame then
							FriendsFrame.NotesID = contextData.bnetIDAccount
							StaticPopup_Show("SET_BNFRIENDNOTE", BFL_GetContextDisplayName(contextData, true))
							if PlaySound and SOUNDKIT and SOUNDKIT.IG_CHARACTER_INFO_CLOSE then
								PlaySound(SOUNDKIT.IG_CHARACTER_INFO_CLOSE)
							end
						end
					end)
					return
				end

				local fullName = BFL_GetContextFullName(contextData)
				if fullName and BFL_HasFriendListContext(contextData) then
					rootDescription:CreateButton(SET_NOTE or "Set Note", function()
						if not FriendsFrame and C_AddOns and C_AddOns.LoadAddOn then
							pcall(C_AddOns.LoadAddOn, "Blizzard_FriendsFrame")
						end
						if FriendsFrame then
							FriendsFrame.NotesID = fullName
							StaticPopup_Show("SET_FRIENDNOTE", fullName)
							if PlaySound and SOUNDKIT and SOUNDKIT.IG_CHARACTER_INFO_CLOSE then
								PlaySound(SOUNDKIT.IG_CHARACTER_INFO_CLOSE)
							end
						end
					end)
				end
			end

			local function BFL_AddRecentAllyNativeButtons(rootDescription, contextData)
				local recentAllyData = contextData and contextData.recentAllyData
				if not recentAllyData or not recentAllyData.characterData then
					return
				end
				local guid = recentAllyData.characterData.guid

				if
					guid
					and C_RecentAllies
					and C_RecentAllies.CanSetRecentAllyNote
					and C_RecentAllies.CanSetRecentAllyNote(guid)
				then
					rootDescription:CreateButton(RECENT_ALLIES_MENU_BUTTON_LABEL_SET_NOTE or SET_NOTE or "Set Note", function()
						StaticPopup_Show(
							"SET_RECENT_ALLY_NOTE",
							recentAllyData.characterData.name,
							nil,
							recentAllyData
						)
					end)
				end

				if guid and C_RecentAllies and C_RecentAllies.IsRecentAllyPinned and C_RecentAllies.SetRecentAllyPinned then
					local pinned = C_RecentAllies.IsRecentAllyPinned(guid)
					local label = pinned and (RECENT_ALLIES_MENU_BUTTON_LABEL_UNPIN or "Unpin")
						or (RECENT_ALLIES_MENU_BUTTON_LABEL_PIN or "Pin")
					rootDescription:CreateButton(label, function()
						C_RecentAllies.SetRecentAllyPinned(guid, not C_RecentAllies.IsRecentAllyPinned(guid))
					end)
				end
			end

			local function BFL_AddViewBNetFriendsButton(rootDescription, contextData)
				if contextData and contextData.bnetIDAccount and FriendsFriendsFrame_Show then
					rootDescription:CreateButton(VIEW_FRIENDS_OF_FRIENDS or "View Friends", function()
						FriendsFriendsFrame_Show(contextData.bnetIDAccount)
					end)
				end
			end

			local function BFL_AddRafSummonButton(rootDescription, contextData, isBNet, isOffline)
				if isOffline or not (C_RecruitAFriend and C_RecruitAFriend.IsRecruitAFriendLinked) then
					return
				end
				local guid = BFL_GetContextGUID(contextData, isBNet)
				if not guid or not C_RecruitAFriend.IsRecruitAFriendLinked(guid) then
					return
				end

				rootDescription:CreateButton(RAF_SUMMON or "Summon Friend", function()
					local fullName = BFL_GetContextFullName(contextData) or BFL_GetContextDisplayName(contextData, isBNet)
					C_RecruitAFriend.SummonFriend(guid, fullName)
				end)
			end

			local function BFL_AddViewHousesButton(rootDescription, contextData, isBNet)
				if not UNIT_VIEW_HOUSES then
					return
				end
				if not (isBNet or (contextData and (contextData.unit or contextData.isGuildMember))) then
					return
				end
				rootDescription:CreateButton(UNIT_VIEW_HOUSES, function()
					if BFL:IsActionRestricted() then
						return
					end
					if not HouseListFrame and C_AddOns and C_AddOns.LoadAddOn then
						pcall(C_AddOns.LoadAddOn, "Blizzard_HouseList")
					end
					if HouseListFrame and HouseListFrame.InitWithContextData then
						local playerName = BFL_GetContextFullName(contextData) or BFL_GetContextDisplayName(contextData, isBNet)
						local guid = BFL_GetContextGUID(contextData, isBNet)
						if ShowUIPanel then
							ShowUIPanel(HouseListFrame)
						end
						HouseListFrame:InitWithContextData(
							playerName,
							guid,
							isBNet and contextData.bnetIDAccount or nil,
							contextData and contextData.isGuildMember
						)
					end
				end)
			end

			local function BFL_AddFavoriteButton(rootDescription, contextData)
				if not (contextData and contextData.bnetIDAccount and BNSetFriendFavoriteFlag) then
					return
				end
				local accountInfo = BFL_GetBNetAccountInfo(contextData)
				if not accountInfo then
					return
				end
				local isFavorite = accountInfo.isFavorite
				rootDescription:CreateButton(
					isFavorite and (REMOVE_FAVORITE_STATUS or "Remove Favorite") or (ADD_FAVORITE_STATUS or "Add Favorite"),
					function()
						BNSetFriendFavoriteFlag(contextData.bnetIDAccount, not isFavorite)
					end
				)
			end

			local function BFL_ShowRemoveWoWFriendPrompt(fullName)
				local prompt = fullName
				if REMOVE_FRIEND_CONFIRMATION then
					local ok, text = pcall(string.format, REMOVE_FRIEND_CONFIRMATION, fullName)
					if ok then
						prompt = text
					end
				elseif CONFIRM_REMOVE_WOW_FRIEND then
					prompt = CONFIRM_REMOVE_WOW_FRIEND
				end

				StaticPopupDialogs["BFL_CONFIRM_REMOVE_WOW_FRIEND"] = {
					text = "%s",
					button1 = ACCEPT,
					button2 = CANCEL,
					OnAccept = function()
						if C_FriendList and C_FriendList.RemoveFriend then
							if not C_FriendList.RemoveFriend(fullName) and UIErrorsFrame then
								UIErrorsFrame:AddExternalErrorMessage(ERR_FRIEND_NOT_FOUND)
							end
						elseif BFL.RemoveFriend then
							BFL.RemoveFriend(fullName)
						end
					end,
					timeout = 0,
					whileDead = 1,
					hideOnEscape = 1,
					preferredIndex = 3,
				}
				StaticPopup_Show("BFL_CONFIRM_REMOVE_WOW_FRIEND", prompt)
			end

			local function BFL_AddRemoveFriendButton(rootDescription, contextData, isBNet)
				if isBNet then
					if not contextData.bnetIDAccount then
						return
					end
					rootDescription:CreateButton(BNET_REMOVE_FRIEND or REMOVE_FRIEND or "Remove Friend", function()
						local displayName = BFL_GetContextDisplayName(contextData, true)
						local confirmation = REMOVE_FRIEND_CONFIRMATION or BATTLETAG_REMOVE_FRIEND_CONFIRMATION
						local prompt = displayName
						if confirmation then
							local ok, text = pcall(string.format, confirmation, displayName)
							if ok then
								prompt = text
							end
						end
						StaticPopup_Show("CONFIRM_REMOVE_BN_FRIEND", prompt, nil, contextData.bnetIDAccount)
					end)
					return
				end

				local fullName = BFL_GetContextFullName(contextData)
				if fullName and BFL_HasFriendListContext(contextData) then
					rootDescription:CreateButton(REMOVE_FRIEND or "Remove Friend", function()
						BFL_ShowRemoveWoWFriendPrompt(fullName)
					end)
				end
			end

			local function BFL_AddIgnoreOrBlockButton(rootDescription, contextData, isBNet)
				if isBNet then
					if not contextData.bnetIDAccount or not BNSetBlocked then
						return
					end
					rootDescription:CreateButton(BN_BLOCK_FRIEND or BLOCK_COMMUNICATION or IGNORE or "Block", function()
						local displayName = BFL_GetContextDisplayName(contextData, true)
						local prompt = displayName
						if BLOCK_FRIEND_CONFIRMATION then
							local ok, text = pcall(string.format, BLOCK_FRIEND_CONFIRMATION, displayName)
							if ok then
								prompt = text
							end
						end
						StaticPopupDialogs["BFL_CONFIRM_BLOCK_BNET_FRIEND"] = {
							text = "%s",
							button1 = ACCEPT,
							button2 = CANCEL,
							OnAccept = function()
								BNSetBlocked(contextData.bnetIDAccount, true)
							end,
							timeout = 0,
							whileDead = 1,
							hideOnEscape = 1,
							preferredIndex = 3,
						}
						StaticPopup_Show("BFL_CONFIRM_BLOCK_BNET_FRIEND", prompt)
					end)
					return
				end

				local fullName = BFL_GetContextFullName(contextData)
				if fullName and C_FriendList and C_FriendList.AddOrDelIgnore then
					if fullName == UnitNameUnmodified("player") then
						return
					end
					local label = C_FriendList.IsOnIgnoredList and C_FriendList.IsOnIgnoredList(fullName) and IGNORE_REMOVE
						or IGNORE
						or "Ignore"
					rootDescription:CreateButton(label, function()
						C_FriendList.AddOrDelIgnore(fullName)
					end)
				end
			end

			local function BFL_AddDeleteCommunityMessageButton(rootDescription, contextData)
				if
					not (
						contextData
						and contextData.communityClubID
						and contextData.communityStreamID
						and contextData.communityEpoch
						and contextData.communityPosition
						and C_Club
						and C_Club.DestroyMessage
					)
				then
					return
				end

				local canDestroy = true
				if C_Club.GetMessageInfo and C_Club.GetClubPrivileges then
					local messageId = { epoch = contextData.communityEpoch, position = contextData.communityPosition }
					local messageInfo = C_Club.GetMessageInfo(
						contextData.communityClubID,
						contextData.communityStreamID,
						messageId
					)
					local privileges = C_Club.GetClubPrivileges(contextData.communityClubID)
					if not messageInfo or messageInfo.destroyed or not privileges then
						canDestroy = false
					elseif messageInfo.author and messageInfo.author.isSelf then
						canDestroy = privileges.canDestroyOwnMessage
					else
						canDestroy = privileges.canDestroyOtherMessage
					end
				end
				if not canDestroy then
					return
				end

				rootDescription:CreateButton(COMMUNITY_MESSAGE_DROP_DOWN_DELETE or DELETE or "Delete", function()
					C_Club.DestroyMessage(contextData.communityClubID, contextData.communityStreamID, {
						epoch = contextData.communityEpoch,
						position = contextData.communityPosition,
					})
				end)
			end

			local function BFL_AddReportButtons(rootDescription, contextData, isBNet, isRecentAlly)
				local playerLocation = BFL_GetContextPlayerLocation(contextData)
				if not BFL_CanReportPlayer(playerLocation) then
					return
				end
				local isChatLocation = playerLocation
					and ((playerLocation.IsChatLineID and playerLocation:IsChatLineID())
						or (playerLocation.IsCommunityData and playerLocation:IsCommunityData()))

				if isChatLocation and Enum and Enum.ReportType and Enum.ReportType.Chat then
					rootDescription:CreateButton(REPORT_CHAT or "Report Chat", function()
						BFL_ShowReportFrame(contextData, Enum.ReportType.Chat, isBNet)
					end)
				elseif Enum and Enum.ReportType then
					local reportType = isRecentAlly and Enum.ReportType.RecentAlly or Enum.ReportType.Friend
					if reportType then
						rootDescription:CreateButton(REPORT_IN_WORLD_PLAYER or REPORT_PLAYER or "Report Player", function()
							BFL_ShowReportFrame(contextData, reportType, isBNet)
						end)
					end
				end
			end

			local function BFL_AddPvpReportAfkButton(rootDescription, contextData)
				if not (Enum and Enum.ReportType and Enum.ReportType.PvP) then
					return
				end
				local fullName = BFL_GetContextFullName(contextData)
				if not fullName or fullName == UnitNameUnmodified("player") then
					return
				end
				local canShow = false
				if UnitInBattleground and UnitInBattleground(fullName) then
					canShow = true
				elseif IsInActiveWorldPVP and IsInActiveWorldPVP(fullName) then
					canShow = true
				end
				if canShow then
					rootDescription:CreateButton(PVP_REPORT_AFK or "Report AFK", function()
						BFL_ShowReportFrame(contextData, Enum.ReportType.PvP, false)
					end)
				end
			end

			function BFL:OpenBetterFriendlistContextMenu(button, menuType, contextData, name)
				if not (MenuUtil and MenuUtil.CreateContextMenu and contextData) then
					return false
				end

				contextData.bflOrigin = ADDON_NAME
				local owner = button
				if not owner and GetMouseFoci then
					local mouseFoci = GetMouseFoci()
					owner = mouseFoci and mouseFoci[1]
				end
				if not owner and GetMouseFocus then
					owner = GetMouseFocus()
				end
				owner = owner or UIParent

				local MenuBridge = BFL:GetModule("MenuBridge")
				if MenuBridge and MenuBridge.WarmUpLazyModifiers then
					MenuBridge:WarmUpLazyModifiers(owner)
				end

				local isBNet = menuType == "BN_FRIEND" or menuType == "BN_FRIEND_OFFLINE" or contextData.bnetIDAccount
				local isOffline = menuType == "BN_FRIEND_OFFLINE"
					or menuType == "FRIEND_OFFLINE"
					or menuType == "RECENT_ALLY_OFFLINE"
					or contextData.isOffline
				local isRecentAlly = menuType == "RECENT_ALLY" or menuType == "RECENT_ALLY_OFFLINE"
				local isFriendMenu = menuType == "BN_FRIEND"
					or menuType == "BN_FRIEND_OFFLINE"
					or menuType == "FRIEND"
					or menuType == "FRIEND_OFFLINE"
				local menuTypeWrapper = contextData.bflWhoPlayerMenu and "WHO" or nil

				MenuUtil.CreateContextMenu(owner, function(ownerRegion, rootDescription)
					local title = BFL_FirstSafeText(contextData.bfl_streamerDisplayName, name, contextData.name)
					rootDescription:CreateTitle(title)

					BFL_AddPopoutChatButton(rootDescription, contextData)

					if isFriendMenu then
						BFL_AddSetNoteButton(rootDescription, contextData, isBNet)
					end
					if isRecentAlly then
						BFL_AddRecentAllyNativeButtons(rootDescription, contextData)
					end
					if isBNet and isFriendMenu then
						BFL_AddViewBNetFriendsButton(rootDescription, contextData)
					end

					rootDescription:CreateDivider()
					rootDescription:CreateTitle(UNIT_FRAME_DROPDOWN_SUBSECTION_TITLE_INTERACT or "Interact")

					BFL_AddRafSummonButton(rootDescription, contextData, isBNet, isOffline)

					if (not isOffline) or isBNet then
						rootDescription:CreateButton(WHISPER or "Whisper", function()
							if isBNet then
								BFL:SecureSendBNetTell(contextData.name, contextData.bnetIDAccount)
							else
								BFL:SecureSendTell(BFL_GetContextFullName(contextData))
							end
						end)
					end

					if not isOffline then
						BFL_AddInviteButton(rootDescription, contextData, isBNet, isOffline)
					end

					BFL_AddViewHousesButton(rootDescription, contextData, isBNet)

					rootDescription:CreateDivider()
					rootDescription:CreateTitle(UNIT_FRAME_DROPDOWN_SUBSECTION_TITLE_OTHER or "Other")

					BFL_AddDeleteCommunityMessageButton(rootDescription, contextData)
					if isBNet and isFriendMenu then
						BFL_AddFavoriteButton(rootDescription, contextData)
					end
					if isBNet then
						if isFriendMenu then
							BFL_AddIgnoreOrBlockButton(rootDescription, contextData, true)
						end
					else
						BFL_AddIgnoreOrBlockButton(rootDescription, contextData, false)
					end
					if isFriendMenu then
						BFL_AddRemoveFriendButton(rootDescription, contextData, isBNet)
					end
					BFL_AddReportButtons(rootDescription, contextData, isBNet, isRecentAlly)
					if not isBNet then
						BFL_AddPvpReportAfkButton(rootDescription, contextData)
					end

					rootDescription:CreateButton(
						(L and L.MENU_COPY_CHARACTER_NAME) or COPY_CHARACTER_NAME or "Copy Character Name",
						BFL_BuildCopyNameHandler(contextData, menuTypeWrapper)
					)

					if isRecentAlly then
						AddRecentAllyOptions(ownerRegion, rootDescription, contextData)
					end

					if isFriendMenu then
						AddGroupsToFriendMenu(ownerRegion, rootDescription, contextData)
					end

					if MenuBridge and MenuBridge.PopulateMenu then
						MenuBridge:PopulateMenu(ownerRegion, rootDescription, contextData, menuType)
					end
				end)

				return true
			end

			if Menu and Menu.ModifyMenu then
				local groupMenuTags = {
					"MENU_UNIT_BN_FRIEND",
					"MENU_UNIT_BN_FRIEND_OFFLINE",
					"MENU_UNIT_FRIEND",
					"MENU_UNIT_FRIEND_OFFLINE",
					"MENU_UNIT_CHAT_ROSTER",
					"MENU_UNIT_RECENT_ALLY",
					"MENU_UNIT_RECENT_ALLY_OFFLINE",
					"MENU_UNIT_WORLD_STATE_SCORE",
					"MENU_UNIT_PVP_SCOREBOARD",
				}

				if not BFL.HasSecretValues then
					-- Guild/Communities menus feed Blizzard's Communities list, which
					-- reads secret member fields while opening on 12.x.
					groupMenuTags[#groupMenuTags + 1] = "MENU_UNIT_GUILD"
					groupMenuTags[#groupMenuTags + 1] = "MENU_UNIT_GUILD_OFFLINE"
					groupMenuTags[#groupMenuTags + 1] = "MENU_UNIT_COMMUNITIES_WOW_MEMBER"
					groupMenuTags[#groupMenuTags + 1] = "MENU_UNIT_COMMUNITIES_GUILD_MEMBER"
					groupMenuTags[#groupMenuTags + 1] = "MENU_UNIT_COMMUNITIES_MEMBER"
				end

				for _, tag in ipairs(groupMenuTags) do
					Menu.ModifyMenu(tag, AddGroupsToFriendMenu)
				end
			end

			-- Pre-secret clients may still use safe BFL-owned native menu mutations.
			-- On secret-value builds, keep native ModifyMenu to BFL's own appended group section only.
			if Menu and Menu.ModifyMenu and not BFL.HasSecretValues then
				-- Streamer Mode: Rename context menu title to safe display name
				-- (contextData.name stays as accountName for whisper functionality)
				local function BFL_StreamerModeRenameTitle(owner, rootDescription, contextData)
					if not BFL_IsOwnMenuContext(contextData) then
						return
					end
					if not BFL.StreamerMode or not BFL.StreamerMode:IsActive() then
						return
					end
					if not contextData or not rootDescription then
						return
					end
					local safeName = contextData.bfl_streamerDisplayName
					if not safeName or safeName == "" then
						return
					end
					-- Find the title element (first element matching the original name) and rename it
					pcall(function()
						for _, elementDescription in rootDescription:EnumerateElementDescriptions() do
							local text = elementDescription.text
							if type(text) == "function" then
								local success, result = pcall(text)
								if success then
									text = result
								end
							end
							if text == contextData.name then
								if elementDescription.SetText then
									elementDescription:SetText(safeName)
								else
									elementDescription.text = safeName
								end
								break
							end
						end
					end)
				end
				Menu.ModifyMenu("MENU_UNIT_BN_FRIEND", BFL_StreamerModeRenameTitle)
				Menu.ModifyMenu("MENU_UNIT_BN_FRIEND_OFFLINE", BFL_StreamerModeRenameTitle)
				Menu.ModifyMenu("MENU_UNIT_FRIEND", BFL_StreamerModeRenameTitle)
				Menu.ModifyMenu("MENU_UNIT_FRIEND_OFFLINE", BFL_StreamerModeRenameTitle)

				-- Legacy whisper responder replacement for BFL-owned pre-secret menus.
				Menu.ModifyMenu("MENU_UNIT_BN_FRIEND", BFL_ReplaceWhisperButton)
				Menu.ModifyMenu("MENU_UNIT_BN_FRIEND_OFFLINE", BFL_ReplaceWhisperButton)
				Menu.ModifyMenu("MENU_UNIT_FRIEND", BFL_ReplaceWhisperButton)
				Menu.ModifyMenu("MENU_UNIT_FRIEND_OFFLINE", BFL_ReplaceWhisperButton)

				-- Fix "Copy Character Name" button (Must be registered BEFORE AddGroupsToFriendMenu)
				Menu.ModifyMenu("MENU_UNIT_BN_FRIEND", BFL_ReplaceCopyNameButton)
				Menu.ModifyMenu("MENU_UNIT_BN_FRIEND_OFFLINE", BFL_ReplaceCopyNameButton)
				Menu.ModifyMenu("MENU_UNIT_FRIEND", BFL_ReplaceCopyNameButton)
				Menu.ModifyMenu("MENU_UNIT_FRIEND_OFFLINE", BFL_ReplaceCopyNameButton)
				-- RAF: Clear flag manually as no other function follows
				Menu.ModifyMenu("MENU_UNIT_RAF_RECRUIT", function(owner, root, context)
					BFL_ReplaceCopyNameButton(owner, root, context)
				end)

				-- Pass context wrapper for Recent Allies and Who list to handle Realm Name retrieval
				Menu.ModifyMenu("MENU_UNIT_RECENT_ALLY", function(owner, root, context)
					BFL_ReplaceCopyNameButton(owner, root, context, "RECENT")
				end)
				Menu.ModifyMenu("MENU_UNIT_RECENT_ALLY_OFFLINE", function(owner, root, context)
					BFL_ReplaceCopyNameButton(owner, root, context, "RECENT")
				end)
				-- WHO: Clear flag manually as no other function follows
				Menu.ModifyMenu("MENU_UNIT_WHO", function(owner, root, context)
					BFL_ReplaceCopyNameButton(owner, root, context, "WHO")
				end)

				-- Add "Add Friend" to Recent Allies
				Menu.ModifyMenu("MENU_UNIT_RECENT_ALLY", AddRecentAllyOptions)
				Menu.ModifyMenu("MENU_UNIT_RECENT_ALLY_OFFLINE", AddRecentAllyOptions)

				-- Multi-Game-Account: Replace invite button with character picker for BNet friends
				Menu.ModifyMenu("MENU_UNIT_BN_FRIEND", BFL_ReplaceInviteButton)

				-- Housing: Intercept "View Houses" menu entry during combat (Retail 12.0+ only)
				-- ShowUIPanel() is protected since 8.2.0, so the house list cannot be opened in combat.
				-- On pre-secret clients this replaces the combat-blocked action with a clear
				-- localized message for BFL-owned menus.
				if BFL.IsRetail and UNIT_VIEW_HOUSES then
					local function BFL_InterceptViewHousesInCombat(owner, rootDescription, contextData)
						if not BFL_IsOwnMenuContext(contextData) then
							return
						end
						if not BFL:IsActionRestricted() then
							return
						end
						if not rootDescription then
							return
						end
						pcall(function()
							for _, elementDescription in rootDescription:EnumerateElementDescriptions() do
								local text = elementDescription.text
								if type(text) == "function" then
									local success, result = pcall(text)
									if success then
										text = result
									end
								end
								if text == UNIT_VIEW_HOUSES then
									elementDescription:SetResponder(function()
										local L = BFL.L
										if L and L.HOUSING_COMBAT_RESTRICTED then
											UIErrorsFrame:AddMessage(L.HOUSING_COMBAT_RESTRICTED, 1.0, 0.1, 0.1, 1.0)
										end
										return MenuResponse and MenuResponse.Close
									end)
									break
								end
							end
						end)
					end
					Menu.ModifyMenu("MENU_UNIT_BN_FRIEND", BFL_InterceptViewHousesInCombat)
					Menu.ModifyMenu("MENU_UNIT_BN_FRIEND_OFFLINE", BFL_InterceptViewHousesInCombat)
					Menu.ModifyMenu("MENU_UNIT_PARTY", BFL_InterceptViewHousesInCombat)
					Menu.ModifyMenu("MENU_UNIT_RAID_PLAYER", BFL_InterceptViewHousesInCombat)
					Menu.ModifyMenu("MENU_UNIT_COMMUNITIES_GUILD_MEMBER", BFL_InterceptViewHousesInCombat)
				end
			end

			-- DEPRECATED: Old Menu.ModifyMenu code removed (was using wrong tags without MENU_UNIT_ prefix)

			-- ScrollBox initialization is now handled by FriendsList:InitializeScrollBox()
			-- No need for OnVerticalScroll script - ScrollBox handles this automatically

			-- Setup close button
			if BetterFriendsFrame and BetterFriendsFrame.CloseButton then
				BetterFriendsFrame.CloseButton:SetScript("OnClick", function()
					HideBetterFriendsFrame()
				end)
			end

			if BFL.IsClassic and BetterFriendsFrame then
				-- Classic has 4 main tabs: Friends(1), Who(2), Guild(3), Raid(4)

				local enableGuildTab = BFL.IsGuildTabEnabled and BFL:IsGuildTabEnabled()

				-- TAB 3: Guild tab (set locale text, hide if disabled)
				if BetterFriendsFrame.BottomTab3 then
					local L = BFL.L
					BetterFriendsFrame.BottomTab3:SetText(L and L.GUILD_TAB_TITLE or GUILD or "Guild")
					if not enableGuildTab then
						BetterFriendsFrame.BottomTab3:Hide()
					end
				end

				-- TAB 4: Create "Raid" tab
				if not BetterFriendsFrame.BottomTab4 then
					local tab = CreateFrame(
						"Button",
						"BetterFriendsFrameBottomTab4",
						BetterFriendsFrame,
						"CharacterFrameTabButtonTemplate"
					)
					tab:SetID(4)
					tab:SetText(RAID or "Raid")
					tab:SetFrameStrata("LOW")
					tab:SetScript("OnClick", function(self)
						PanelTemplates_Tab_OnClick(self, BetterFriendsFrame)
						BetterFriendsFrame_ShowBottomTab(4)
						PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON)
					end)
					BetterFriendsFrame.BottomTab4 = tab
				end

				-- Position Tab 4: after Tab 3 if guild enabled, after Tab 2 if disabled
				if BetterFriendsFrame.BottomTab4 then
					BetterFriendsFrame.BottomTab4:ClearAllPoints()
					if enableGuildTab then
						BetterFriendsFrame.BottomTab4:SetPoint("LEFT", BetterFriendsFrame.BottomTab3, "RIGHT", -15, 0)
					else
						BetterFriendsFrame.BottomTab4:SetPoint("LEFT", BetterFriendsFrame.BottomTab2, "RIGHT", -15, 0)
					end
				end

				-- Setup Tabs Registry for PanelTemplates
				BetterFriendsFrame.Tabs = {
					BetterFriendsFrame.BottomTab1,
					BetterFriendsFrame.BottomTab2,
					BetterFriendsFrame.BottomTab3,
					BetterFriendsFrame.BottomTab4,
				}

				-- Enable 4 Tabs
				PanelTemplates_SetNumTabs(BetterFriendsFrame, 4)
				PanelTemplates_SetTab(BetterFriendsFrame, 1)
			end

			-- Retail: Setup Guild Top Tab (Tab4 in FriendsTabHeader)
			if not BFL.IsClassic and BetterFriendsFrame and BetterFriendsFrame.FriendsTabHeader then
				local tab4 = BetterFriendsFrame.FriendsTabHeader.Tab4
				if tab4 then
					-- Set locale text (overrides XML "GUILD" global with BFL locale)
					local L = BFL.L
					if L and L.GUILD_TAB_TITLE then
						tab4:SetText(L.GUILD_TAB_TITLE)
					end
					PanelTemplates_TabResize(tab4, 0)

					local enableGuildTab = BFL.IsGuildTabEnabled and BFL:IsGuildTabEnabled()
					if enableGuildTab then
						tab4:Show()
						PanelTemplates_SetNumTabs(BetterFriendsFrame.FriendsTabHeader, 4)
						PanelTemplates_UpdateTabs(BetterFriendsFrame.FriendsTabHeader)
					end
				end
			end
		end
	elseif
		event == "FRIENDLIST_UPDATE"
		or event == "BN_FRIEND_LIST_SIZE_CHANGED"
		or event == "BN_FRIEND_ACCOUNT_ONLINE"
		or event == "BN_FRIEND_ACCOUNT_OFFLINE"
		or event == "BN_FRIEND_INFO_CHANGED"
	then
		-- NOTE: BFL:FireEventCallbacks(event) is NOT called here because Core.lua's
		-- eventFrame already fires callbacks for all registered events (including these).
		-- Calling it again would execute each callback twice per event.

		-- CRITICAL (Phase 14d): Always update friend list data, even if UI is hidden
		-- This ensures data stays synchronized with WoW API
		-- The UpdateFriendsList() function will apply search filter automatically
		RequestUpdate() -- No longer checks if frame is shown
	elseif event == "SOCIAL_QUEUE_UPDATE" or event == "GROUP_LEFT" or event == "GROUP_JOINED" then
		-- Update Quick Join tab counter when social queue changes
		if BetterFriendsFrame and BetterFriendsFrame:IsShown() then
			BetterFriendsFrame_UpdateQuickJoinTab()
		end
		-- Fire callbacks for RaidFrame module
		BFL:FireEventCallbacks(event, ...)
	elseif event == "GROUP_ROSTER_UPDATE" or event == "RAID_ROSTER_UPDATE" or event == "PLAYER_ROLES_ASSIGNED" then
		-- Fire callbacks for RaidFrame module to update raid info
		BFL:FireEventCallbacks(event, ...)
	elseif event == "PLAYER_REGEN_DISABLED" then
		-- Entering combat - update combat overlay on all raid buttons
		BetterRaidFrame_UpdateCombatOverlay(true)

		-- Close all active Contacts Menus (forces refresh with combat icon on reopen)
		for i = #_G.BFL_ActiveContactsMenus, 1, -1 do
			local menu = _G.BFL_ActiveContactsMenus[i]
			if menu and menu:IsShown() then
				menu:Hide()
			end
		end
		-- Clean up closed menus
		for i = #_G.BFL_ActiveContactsMenus, 1, -1 do
			local menu = _G.BFL_ActiveContactsMenus[i]
			if not menu or not menu:IsShown() then
				table.remove(_G.BFL_ActiveContactsMenus, i)
			end
		end
	elseif event == "PLAYER_REGEN_ENABLED" then
		-- Leaving combat - update combat overlay on all raid buttons
		BetterRaidFrame_UpdateCombatOverlay(false)

		-- Close all active Contacts Menus (forces refresh without combat icon on reopen)
		for i = #_G.BFL_ActiveContactsMenus, 1, -1 do
			local menu = _G.BFL_ActiveContactsMenus[i]
			if menu and menu:IsShown() then
				menu:Hide()
			end
		end
		-- Clean up closed menus
		for i = #_G.BFL_ActiveContactsMenus, 1, -1 do
			local menu = _G.BFL_ActiveContactsMenus[i]
			if not menu or not menu:IsShown() then
				table.remove(_G.BFL_ActiveContactsMenus, i)
			end
		end
		if BFL.pendingBetterFriendsFrameUIPanelRestore and BFL.RestoreBetterFriendsFrameUIPanelAfterCombat then
			BFL:RestoreBetterFriendsFrameUIPanelAfterCombat()
		end
	elseif event == "WHO_LIST_UPDATE" then
		-- Fire callbacks for modules
		BFL:FireEventCallbacks(event, ...)

		-- Update Who list when results are received
		if BetterFriendsFrame and BetterFriendsFrame:IsShown() and BetterFriendsFrame.WhoFrame:IsShown() then
			BetterWhoFrame_Update()
		end
	elseif event == "PLAYER_LOGIN" then
		-- Localize UI Elements (replacing hardcoded XML text)
		LocalizeUI()

		-- Override Close Button to use our Hide function
		if BetterFriendsFrame.CloseButton then
			BetterFriendsFrame.CloseButton:SetScript("OnClick", function()
				HideBetterFriendsFrame()
			end)
		end

		-- Initialize UI components (delegated to FrameInitializer)
		if BFL.FrameInitializer and BetterFriendsFrame then
			BFL.FrameInitializer:Initialize(BetterFriendsFrame)
		end

		-- Initialize Quick Join tab counter
		BetterFriendsFrame_UpdateQuickJoinTab()

		-- Initialize Quick Filter Buttons
		C_Timer.After(0.5, function()
			if
				BetterFriendsFrame
				and BetterFriendsFrame.FriendsTabHeader
				and BetterFriendsFrame.FriendsTabHeader.QuickFilters
			then
				BetterFriendsFrame_UpdateQuickFilterButtons()
			end
		end)
	end
end)

-- Slash Commands are now in Core.lua
-- This avoids loading order conflicts

-- Show specific tab content (11.2.5: 4 tabs - Friends, Recent Allies, RAF, Sort)
-- In Classic: Only tab 1 (Friends) is available
function BetterFriendsFrame_ShowTab(tabIndex)
	local frame = BetterFriendsFrame
	if not frame then
		return
	end
	if tabIndex == 4 and (BFL.IsClassic or not (BFL.IsGuildTabEnabled and BFL:IsGuildTabEnabled())) then
		tabIndex = 1
	end

	-- Update FriendsTabHeader.selectedTab to match displayed content
	-- Without this, PanelTemplates_GetSelectedTab returns stale values after reopen
	if frame.FriendsTabHeader then
		PanelTemplates_SetTab(frame.FriendsTabHeader, tabIndex)
	end

	-- Clear search and update placeholder when switching tabs. Programmatic clearing is
	-- kept silent so tab clicks do not schedule an extra debounced refresh.
	ClearTopTabSearchState(frame, tabIndex)

	-- Force SearchBox state update (handles Simple Mode visibility + positioning)
	local FriendsList = BFL:GetModule("FriendsList")
	if FriendsList and FriendsList.UpdateSearchBoxState then
		FriendsList:UpdateSearchBoxState()
	end

	local GuildFrameModule = BFL:GetModule("GuildFrame")
	if GuildFrameModule and GuildFrameModule.OnTopTabChanged then
		GuildFrameModule:OnTopTabChanged(tabIndex)
	end

	-- Force layout update for Tab 1 to position ScrollFrame correctly
	if tabIndex == 1 then
		if FriendsList and FriendsList.UpdateScrollBoxExtent then
			FriendsList:UpdateScrollBoxExtent()
		end
	end

	-- FORCE FONT UPDATE: Ensure Custom Fonts win against ElvUI
	BFL:RefreshTopTabVisualState(tabIndex)

	-- Use hybrid helper functions for all child frames
	HideChildFrame(frame.SortFrame)
	HideChildFrame(frame.RecentAlliesFrame)
	HideChildFrame(frame.RecruitAFriendFrame)
	HideChildFrame(frame.ScrollFrame)
	HideChildFrame(frame.MinimalScrollBar)
	BFL:CloseTaintFreeWhisper()
	HideChildFrame(frame.AddFriendButton)
	HideChildFrame(frame.SendMessageButton)
	HideChildFrame(frame.RecruitmentButton)
	HideChildFrame(frame.GuildFrame)

	-- Additional Classic frames
	HideChildFrame(frame.WhoFrame)
	if BFL.IsClassic and frame.RaidFrame then
		HideChildFrame(frame.RaidFrame)
	end

	-- Hide docked Search Builder when switching away from WHO tab
	local WhoFrameModule = BFL:GetModule("WhoFrame")
	if WhoFrameModule and WhoFrameModule.builderDocked and WhoFrameModule.builderDockedContainer then
		WhoFrameModule.builderDockedContainer:Hide()
	end

	if tabIndex == 1 then
		-- Show Friends list
		ShowChildFrame(frame.ScrollFrame)
		ShowChildFrame(frame.MinimalScrollBar)
		ShowChildFrame(frame.AddFriendButton)
		ShowChildFrame(frame.SendMessageButton)
		UpdateFriendsDisplay()
	elseif tabIndex == 2 then
		-- Retail: Recent Allies
		if not BFL.IsClassic and frame.RecentAlliesFrame then
			ShowChildFrame(frame.RecentAlliesFrame)
			local RecentAllies = BFL:GetModule("RecentAllies")
			if RecentAllies then
				RecentAllies:Refresh(frame.RecentAlliesFrame, ScrollBoxConstants.RetainScrollPosition)
			end
		-- Classic: Who Frame
		elseif BFL.IsClassic and frame.WhoFrame then
			ShowChildFrame(frame.WhoFrame)
			BetterWhoFrame_Update()
			-- Restore docked Search Builder if it was open
			if
				WhoFrameModule
				and WhoFrameModule.builderDocked
				and WhoFrameModule.builderDockedContainer
				and WhoFrameModule.builderFlyout
				and WhoFrameModule.builderFlyout:IsShown()
			then
				WhoFrameModule.builderDockedContainer:Show()
			end
		end
	elseif tabIndex == 3 then
		-- Retail: Recruit A Friend
		-- Check IsEnabled() as requested
		local rafEnabled = C_RecruitAFriend and C_RecruitAFriend.IsEnabled and C_RecruitAFriend.IsEnabled()
		if not BFL.IsClassic and frame.RecruitAFriendFrame and rafEnabled then
			ShowChildFrame(frame.RecruitAFriendFrame)
			ShowChildFrame(frame.RecruitmentButton)

			-- Initialize RAF data (match Blizzard's OnLoad behavior)
			if C_RecruitAFriend and C_RecruitAFriend.IsSystemEnabled then
				if not RecruitAFriendFrame then
					local loadAddOn = C_AddOns and C_AddOns.LoadAddOn or LoadAddOn
					if loadAddOn then
						if securecallfunction then
							pcall(securecallfunction, loadAddOn, "Blizzard_RecruitAFriend")
						else
							pcall(loadAddOn, "Blizzard_RecruitAFriend")
						end
					end
				end

				-- Get RAF system info
				local rafSystemInfo = C_RecruitAFriend.GetRAFSystemInfo()
				if rafSystemInfo then
					-- Store system info for use by RAF functions
					frame.RecruitAFriendFrame.rafSystemInfo = rafSystemInfo
				end

				-- Get RAF info and update
				local rafInfo = C_RecruitAFriend.GetRAFInfo()
				if rafInfo then
					BetterRAF_UpdateRAFInfo(frame.RecruitAFriendFrame, rafInfo)
				end

				-- Request updated recruitment info (enables "Generate Link" functionality)
				if C_RecruitAFriend.RequestUpdatedRecruitmentInfo then
					if securecallfunction then
						securecallfunction(C_RecruitAFriend.RequestUpdatedRecruitmentInfo)
					else
						C_RecruitAFriend.RequestUpdatedRecruitmentInfo()
					end
				end
			end
		end
	elseif tabIndex == 4 then
		-- Retail: Guild roster
		if not BFL.IsClassic and BFL.IsGuildTabEnabled and BFL:IsGuildTabEnabled() then
			ShowChildFrame(frame.GuildFrame)
		-- Classic: Raid Frame
		else
			if frame.RaidFrame then
				ShowChildFrame(frame.RaidFrame)
			end
		end
	end

	-- Simple Mode: Adjust content frame offsets to make room for SearchBox
	-- In Simple Mode, the SearchBox is reparented to the main frame at the top of Inset
	-- Content frames for tabs 2/3 need to be pushed down to avoid overlap
	local FriendsList = BFL:GetModule("FriendsList")
	local isSimpleMode = FriendsList and FriendsList.settingsCache and FriendsList.settingsCache.simpleMode
	local showSearchInSimple = FriendsList
		and FriendsList.settingsCache
		and FriendsList.settingsCache.simpleModeShowSearch
	if showSearchInSimple == nil then
		showSearchInSimple = true
	end
	local searchOffset = (isSimpleMode and showSearchInSimple) and -22 or -4

	if frame.RecentAlliesFrame then
		frame.RecentAlliesFrame:ClearAllPoints()
		frame.RecentAlliesFrame:SetPoint("TOPLEFT", frame.Inset, "TOPLEFT", 3, searchOffset)
		frame.RecentAlliesFrame:SetPoint("BOTTOMRIGHT", frame.Inset, "BOTTOMRIGHT", -3, 2)
	end
	if frame.RecruitAFriendFrame then
		frame.RecruitAFriendFrame:ClearAllPoints()
		frame.RecruitAFriendFrame:SetPoint("TOPLEFT", frame.Inset, "TOPLEFT", 0, searchOffset)
		frame.RecruitAFriendFrame:SetPoint("BOTTOMRIGHT", frame.Inset, "BOTTOMRIGHT", 0, 0)
	end
	-- GuildFrame uses setAllPoints="true" (like WhoFrame) and positions its elements
	-- relative to $parent.$parent.Inset in XML/OnLoad. No repositioning needed here.
end

-- Set sort method and update display
function BetterFriendlist_SetSortMethod(method)
	if not method then
		return
	end

	-- Update FriendsList module
	local FriendsList = BFL and BFL:GetModule("FriendsList")
	if FriendsList then
		FriendsList:SetSortMode(method)
	end

	-- Also store in legacy global for backwards compatibility
	currentSortMethod = method

	-- Switch back to Friends tab and update tab selection
	local frame = BetterFriendsFrame
	if frame and frame.FriendsTabHeader then
		PanelTemplates_SetTab(frame.FriendsTabHeader, 1)
	end

	-- Show Friends tab content
	BetterFriendsFrame_ShowTab(1)

	-- Provide feedback
	local methodNames = {
		status = L.SORT_STATUS,
		name = L.SORT_NAME,
		level = L.SORT_LEVEL,
		zone = L.SORT_ZONE,
	}
	AddChatMessage("|cff00ff00BetterFriendlist:|r " .. string.format(L.SORT_CHANGED, (methodNames[method] or method)))
end

-- Update Quick Join tab with group count (matching Blizzard's FriendsFrame_UpdateQuickJoinTab)
function BetterFriendsFrame_UpdateQuickJoinTab() -- Quick Join is Retail only
	if BFL.IsClassic then
		return
	end

	local frame = BetterFriendsFrame
	if not frame or not frame.BottomTab4 then
		return
	end

	-- Get number of groups from QuickJoin module (supports mock mode)
	local QuickJoin = BFL and BFL:GetModule("QuickJoin")
	local numGroups = 0

	if QuickJoin then
		local groups = QuickJoin:GetAllGroups()
		numGroups = groups and #groups or 0
	else
		-- Fallback to C_SocialQueue if module not loaded
		numGroups = C_SocialQueue and C_SocialQueue.GetAllGroups and #C_SocialQueue.GetAllGroups() or 0
	end

	-- Update tab text with count
	local quickJoinTabText = QUICK_JOIN .. " " .. string.format(NUMBER_IN_PARENTHESES, numGroups)
	local textChanged = not frame.BottomTab4.GetText or frame.BottomTab4:GetText() ~= quickJoinTabText
	if textChanged then
		frame.BottomTab4:SetText(quickJoinTabText)
	end

	-- Re-apply tab fonts only when the label actually changes or the tab layout has not been initialized yet.
	if textChanged or not (BFL._tabFontCache and BFL._tabFontCache.signature) then
		BFL:ApplyTabFonts()
	end
end

--------------------------------------------------------------------------
-- TRAVEL PASS BUTTON HANDLERS
--------------------------------------------------------------------------

local CLASS_ID_TO_GAME_MODE = {}
if Enum and Enum.GameMode then
	CLASS_ID_TO_GAME_MODE[14] = Enum.GameMode.Plunderstorm
	CLASS_ID_TO_GAME_MODE[15] = Enum.GameMode.WoWHack
end

local function CanInviteByGameMode(gameAccountInfo)
	if not C_GameRules or not C_GameRules.GetActiveGameMode or not Enum or not Enum.GameMode then
		return true
	end

	local standardGameMode = Enum.GameMode.Standard
	if not standardGameMode then
		return true
	end

	local otherGameMode = CLASS_ID_TO_GAME_MODE[gameAccountInfo.classID]
	local activeGameMode = C_GameRules.GetActiveGameMode()

	if otherGameMode then
		return otherGameMode == activeGameMode
	else
		-- If we're both in standard we can invite them.
		return activeGameMode == standardGameMode
	end
end

-- Multi-Game-Account Invite Picker
-- Shows a menu to select which game account to invite when a friend has multiple WoW accounts online
local function GetInviteRestrictionText(restriction)
	return GetBFLInviteRestrictionText(restriction)
end

BFL.GetInviteRestrictionText = GetInviteRestrictionText

local function ShowMultiAccountInvitePicker(invitableAccounts, anchorButton)
	if not invitableAccounts or #invitableAccounts == 0 then
		return
	end

	local L = BFL.L or {}
	local FriendsList = GetFriendsList()
	local friendUID = nil
	if anchorButton and anchorButton.friendData and FriendsList and FriendsList.GetFriendUID then
		friendUID = FriendsList:GetFriendUID(anchorButton.friendData)
	end

	local lastInvited = nil
	if FriendsList and friendUID then
		lastInvited = FriendsList:GetLastInvitedAccountID(friendUID)
	end

	local entries = {}
	if FriendsList and FriendsList.BuildAccountDisplay then
		for _, ga in ipairs(invitableAccounts) do
			local entry = FriendsList:BuildAccountDisplay(ga)
			if entry then
				entry.isPreferred = lastInvited and entry.gameAccountID == lastInvited or false
				entries[#entries + 1] = entry
			end
		end
	else
		for _, ga in ipairs(invitableAccounts) do
			entries[#entries + 1] = {
				text = ga.characterName or "Unknown",
				gameAccountID = ga.gameAccountID,
			}
		end
	end

	local function FormatEntryText(entry)
		local label = entry.text or "Unknown"
		if entry.isPreferred then
			label = "* " .. label
		end
		if not entry.isInvitable then
			local reason = GetInviteRestrictionText(entry.restriction)
			if reason and reason ~= "" then
				label = label .. " (" .. reason .. ")"
			else
				label = label .. " (Not invitable)"
			end
		end
		return label
	end

	table.sort(entries, function(a, b)
		if a.isPreferred ~= b.isPreferred then
			return a.isPreferred
		end
		return tostring(a.text or ""):lower() < tostring(b.text or ""):lower()
	end)

	-- Retail: Use MenuUtil if available
	if MenuUtil and MenuUtil.CreateContextMenu then
		MenuUtil.CreateContextMenu(anchorButton, function(ownerRegion, rootDescription)
			rootDescription:CreateTitle(L.INVITE_ACCOUNT_PICKER_TITLE or "Invite Character")
			for _, entry in ipairs(entries) do
				local text = FormatEntryText(entry)
				local gameAccountID = entry.gameAccountID
				rootDescription:CreateButton(text, function()
					if not entry.isInvitable then
						return
					end
					if gameAccountID then
						-- Use Blizzard's wrapper for proper Invite vs Request-To-Join handling
						if FriendsFrame_InviteOrRequestToJoin and entry.playerGuid then
							FriendsFrame_InviteOrRequestToJoin(entry.playerGuid, gameAccountID)
						else
							BFL.BNInviteFriend(gameAccountID)
						end
					end

					if FriendsList and friendUID and gameAccountID then
						FriendsList:SetLastInvitedAccountID(friendUID, gameAccountID)
					end
				end)
			end
		end)
		return
	end

	-- Classic: Use UIDropDownMenu
	if UIDropDownMenu_Initialize and ToggleDropDownMenu then
		if not BFL.InvitePickerDropdown then
			BFL.InvitePickerDropdown =
				CreateFrame("Frame", "BFL_InvitePickerDropdown", UIParent, "UIDropDownMenuTemplate")
		end

		local accounts = invitableAccounts -- closure capture
		UIDropDownMenu_Initialize(BFL.InvitePickerDropdown, function(self, level)
			-- Title
			local titleInfo = UIDropDownMenu_CreateInfo()
			titleInfo.text = L.INVITE_ACCOUNT_PICKER_TITLE or "Invite Character"
			titleInfo.isTitle = true
			titleInfo.notCheckable = true
			UIDropDownMenu_AddButton(titleInfo, level)

			for _, entry in ipairs(entries) do
				local info = UIDropDownMenu_CreateInfo()
				info.text = FormatEntryText(entry)
				info.notCheckable = true
				local gameAccountID = entry.gameAccountID
				info.disabled = entry.isInvitable == false
				info.func = function()
					if not entry.isInvitable then
						return
					end
					if gameAccountID then
						-- Use Blizzard's wrapper for proper Invite vs Request-To-Join handling
						if FriendsFrame_InviteOrRequestToJoin and entry.playerGuid then
							FriendsFrame_InviteOrRequestToJoin(entry.playerGuid, gameAccountID)
						else
							BFL.BNInviteFriend(gameAccountID)
						end
					end

					if FriendsList and friendUID and gameAccountID then
						FriendsList:SetLastInvitedAccountID(friendUID, gameAccountID)
					end
				end
				UIDropDownMenu_AddButton(info, level)
			end
		end, "MENU")

		ToggleDropDownMenu(1, nil, BFL.InvitePickerDropdown, anchorButton, 0, 0)
	end
end

BFL.ShowMultiAccountInvitePicker = ShowMultiAccountInvitePicker

-- TravelPass Button Handlers
function BetterFriendsList_TravelPassButton_OnClick(self)
	local friendData = self.friendData
	if not friendData or friendData.type ~= "bnet" then
		return
	end

	-- Get the actual Battle.net friend index from our stored data
	local numBNet = BNGetNumFriends()
	local actualIndex = nil

	for i = 1, numBNet do
		local accountInfo = C_BattleNet.GetFriendAccountInfo(i)
		if accountInfo and accountInfo.bnetAccountID == friendData.bnetAccountID then
			actualIndex = i
			break
		end
	end

	if not actualIndex then
		return
	end

	-- USE BLIZZARD LOGIC (Retail)
	-- Fixes "Join" button not working (Request vs Invite) and Cross-Faction logic
	if FriendsFrame_InviteOrRequestToJoin and FriendsFrame_SetupTravelPassDropdown then
		-- Check Valid Game Accounts (Ported from FriendsFrame.lua)
		local numGameAccounts = C_BattleNet.GetFriendNumGameAccounts(actualIndex)
		local numValidGameAccounts = 0
		local lastGameAccountID, lastGameAccountGUID
		local playerFactionGroup = UnitFactionGroup("player")
		local playerRealmID = (GetNativeRealmID and GetNativeRealmID()) or (GetRealmID and GetRealmID())
		local WOW_PROJECT_ID = WOW_PROJECT_ID

		for i = 1, numGameAccounts do
			local gameAccountInfo = C_BattleNet.GetFriendGameAccountInfo(actualIndex, i)
			local isValid = true

			-- Basic checks
			if gameAccountInfo.clientProgram ~= BNET_CLIENT_WOW then
				isValid = false
			end
			if gameAccountInfo.wowProjectID ~= WOW_PROJECT_ID then
				isValid = false
			end
			if gameAccountInfo.realmID == 0 then
				isValid = false
			end
			if isValid and BFL.IsClassic and playerRealmID and gameAccountInfo.realmID ~= playerRealmID then
				isValid = false
			end
			if not gameAccountInfo.isInCurrentRegion then
				isValid = false
			end
			if not gameAccountInfo.playerGuid then
				isValid = false
			end

			-- Cross-faction checks
			if isValid and gameAccountInfo.factionName ~= playerFactionGroup then
				if C_QuestSession and C_QuestSession.Exists() then
					isValid = false
				elseif
					C_PartyInfo
					and C_PartyInfo.CanFormCrossFactionParties
					and not C_PartyInfo.CanFormCrossFactionParties()
				then
					isValid = false
				end
			end

			-- Game Mode Check
			if isValid and not CanInviteByGameMode(gameAccountInfo) then
				isValid = false
			end

			if isValid then
				numValidGameAccounts = numValidGameAccounts + 1
				lastGameAccountID = gameAccountInfo.gameAccountID
				lastGameAccountGUID = gameAccountInfo.playerGuid
			end
		end

		if numValidGameAccounts == 1 then
			-- Single valid account: Auto-Invite or Request Join
			FriendsFrame_InviteOrRequestToJoin(lastGameAccountGUID, lastGameAccountID)
		else
			-- Multiple valid (or 0 valid): Show Dropdown
			-- Blizzard's dropdown logic handles the "0 valid" case by showing disabled buttons
			FriendsFrame_SetupTravelPassDropdown(actualIndex, self)
		end
		return
	end

	-- CLASSIC FALLBACK (Enhanced with Multi-Game-Account Invite Picker)
	-- Use pre-calculated invitable accounts from the data layer
	if friendData.invitableAccounts and #friendData.invitableAccounts > 0 then
		if #friendData.invitableAccounts == 1 then
			-- Single invitable account: use Request-To-Join when appropriate
			local singleAccount = friendData.invitableAccounts[1]
			local singleGuid = singleAccount.playerGuid
			if singleGuid and GetDisplayedInviteType then
				local inviteType = GetDisplayedInviteType(singleGuid)
				if inviteType == "REQUEST_INVITE" or inviteType == "REQUEST_INVITE_CROSS_FACTION" then
					BNRequestInviteFriend(singleAccount.gameAccountID)
				else
					BFL.BNInviteFriend(singleAccount.gameAccountID)
				end
			else
				BFL.BNInviteFriend(singleAccount.gameAccountID)
			end
		else
			-- Multiple invitable accounts: show picker
			ShowMultiAccountInvitePicker(friendData.invitableAccounts, self)
		end
		return
	end

	-- Final fallback: iterate game accounts (handles stale pre-calc data)
	local numGameAccounts = C_BattleNet.GetFriendNumGameAccounts(actualIndex)
	for i = 1, numGameAccounts do
		local gameAccountInfo = C_BattleNet.GetFriendGameAccountInfo(actualIndex, i)
		if
			gameAccountInfo
			and gameAccountInfo.clientProgram == BNET_CLIENT_WOW
			and gameAccountInfo.isOnline
			and gameAccountInfo.realmID
			and gameAccountInfo.realmID ~= 0
		then
			BFL.BNInviteFriend(gameAccountInfo.gameAccountID)
			return
		end
	end

	-- Single game account fallback
	local gameAccountInfo = C_BattleNet.GetFriendGameAccountInfo(actualIndex, 1)
	if gameAccountInfo and gameAccountInfo.gameAccountID then
		BFL.BNInviteFriend(gameAccountInfo.gameAccountID)
	end
end

local inviteTypeToButtonText = {
	["INVITE"] = TRAVEL_PASS_INVITE,
	["SUGGEST_INVITE"] = SUGGEST_INVITE,
	["REQUEST_INVITE"] = REQUEST_INVITE,
	["INVITE_CROSS_FACTION"] = TRAVEL_PASS_INVITE_CROSS_FACTION,
	["SUGGEST_INVITE_CROSS_FACTION"] = SUGGEST_INVITE_CROSS_FACTION or SUGGEST_INVITE,
	["REQUEST_INVITE_CROSS_FACTION"] = REQUEST_INVITE_CROSS_FACTION or REQUEST_INVITE,
}

local inviteTypeIsCrossFaction = {
	["INVITE_CROSS_FACTION"] = true,
	["SUGGEST_INVITE_CROSS_FACTION"] = true,
	["REQUEST_INVITE_CROSS_FACTION"] = true,
}

local FACTION_LABELS_FROM_STRING = {
	["Alliance"] = FACTION_ALLIANCE,
	["Horde"] = FACTION_HORDE,
}

local function AddSocialQueueGroupMembersToTooltip(tooltip, guid)
	if
		not tooltip
		or not guid
		or not C_SocialQueue
		or not C_SocialQueue.GetGroupForPlayer
		or not C_SocialQueue.GetGroupMembers
	then
		return
	end

	local group = C_SocialQueue.GetGroupForPlayer(guid)
	if not group then
		return
	end

	local members = C_SocialQueue.GetGroupMembers(group)
	if not members then
		return
	end

	local numDisplayed = 0
	for i = 1, #members do
		local member = members[i]
		if member and member.guid and member.guid ~= guid then
			local name, color
			if SocialQueueUtil_GetRelationshipInfo then
				name, color = SocialQueueUtil_GetRelationshipInfo(member.guid, nil, member.clubId)
			end
			if name and name ~= "" then
				if numDisplayed == 0 then
					if SOCIAL_QUEUE_ALSO_IN_GROUP then
						tooltip:AddLine(SOCIAL_QUEUE_ALSO_IN_GROUP)
					end
				elseif numDisplayed >= 7 then
					if SOCIAL_QUEUE_AND_MORE then
						tooltip:AddLine(
							SOCIAL_QUEUE_AND_MORE,
							GRAY_FONT_COLOR.r,
							GRAY_FONT_COLOR.g,
							GRAY_FONT_COLOR.b,
							1
						)
					end
					break
				end

				tooltip:AddLine((color or "") .. name .. (color and FONT_COLOR_CODE_CLOSE or ""))
				numDisplayed = numDisplayed + 1
			end
		end
	end
end

function BetterFriendsList_TravelPassButton_OnEnter(self)
	local friendData = self.friendData
	if not friendData then
		return
	end

	BFL_Tooltip:SetOwner(self, "ANCHOR_RIGHT")

	-- Get the actual Battle.net friend index
	local numBNet = BNGetNumFriends()
	local actualIndex = nil

	for i = 1, numBNet do
		local accountInfo = C_BattleNet.GetFriendAccountInfo(i)
		if accountInfo and accountInfo.bnetAccountID == friendData.bnetAccountID then
			actualIndex = i
			break
		end
	end

	if not actualIndex then
		BFL_Tooltip:SetText("Error", RED_FONT_COLOR.r, RED_FONT_COLOR.g, RED_FONT_COLOR.b)
		BFL_Tooltip:Show()
		return
	end

	-- USE BLIZZARD LOGIC (Retail 11.0+)
	if
		FriendsFrame_GetDisplayedInviteTypeAndGuid
		and FriendsFrame_GetInviteRestriction
		and FriendsFrame_GetInviteRestrictionText
	then
		local inviteType, guid, factionName = FriendsFrame_GetDisplayedInviteTypeAndGuid(actualIndex)
		local restriction = FriendsFrame_GetInviteRestriction(actualIndex)
		local restrictionText = FriendsFrame_GetInviteRestrictionText(restriction)
		local hasRestriction = restrictionText and restrictionText ~= ""

		if inviteType and inviteTypeToButtonText[inviteType] then
			BFL_Tooltip:SetText(
				inviteTypeToButtonText[inviteType],
				HIGHLIGHT_FONT_COLOR.r,
				HIGHLIGHT_FONT_COLOR.g,
				HIGHLIGHT_FONT_COLOR.b
			)

			if inviteTypeIsCrossFaction[inviteType] and factionName then
				local factionLabel = FACTION_LABELS_FROM_STRING[factionName]
					or (factionName == "Horde" and FACTION_HORDE or FACTION_ALLIANCE)
				if CROSS_FACTION_INVITE_TOOLTIP then
					BFL_Tooltip:AddLine(CROSS_FACTION_INVITE_TOOLTIP:format(factionLabel), nil, nil, nil, true)
				end
			end

			if not hasRestriction then
				if
					(inviteType == "REQUEST_INVITE" or inviteType == "REQUEST_INVITE_CROSS_FACTION")
				then
					AddSocialQueueGroupMembersToTooltip(BFL_Tooltip, guid)
				end
			else
				BFL_Tooltip:AddLine(
					restrictionText,
					RED_FONT_COLOR.r,
					RED_FONT_COLOR.g,
					RED_FONT_COLOR.b,
					true
				)
			end

			BFL_Tooltip:Show()
			return
		end
	end

	-- CLASSIC FALLBACK (Legacy Logic)
	-- Check for invite restrictions (matching Blizzard's Check code)
	local restriction = nil -- Will be set to NO_GAME_ACCOUNTS if no valid accounts found
	local numGameAccounts = C_BattleNet.GetFriendNumGameAccounts(actualIndex)
	local playerFactionGroup = UnitFactionGroup("player")
	local playerRealmID = (GetNativeRealmID and GetNativeRealmID()) or (GetRealmID and GetRealmID())
	local isCrossFaction = false
	local factionName = nil
	local displayedGuid = nil

	for i = 1, numGameAccounts do
		local gameAccountInfo = C_BattleNet.GetFriendGameAccountInfo(actualIndex, i)
		if gameAccountInfo then
			if gameAccountInfo.clientProgram == BNET_CLIENT_WOW then
				if gameAccountInfo.playerGuid and not displayedGuid then
					displayedGuid = gameAccountInfo.playerGuid
				end
				if gameAccountInfo.factionName then
					factionName = gameAccountInfo.factionName
					isCrossFaction = (factionName ~= playerFactionGroup)
				end

				-- Check WoW version compatibility (same logic as enable/disable)
				if gameAccountInfo.wowProjectID and WOW_PROJECT_ID then
					if
						gameAccountInfo.wowProjectID == WOW_PROJECT_CLASSIC
						and WOW_PROJECT_ID ~= WOW_PROJECT_CLASSIC
					then
						-- Friend is on Classic, we're not
						if not restriction then
							restriction = BFL_INVITE_RESTRICTION_WOW_PROJECT_CLASSIC
						end
					elseif
						gameAccountInfo.wowProjectID == WOW_PROJECT_MAINLINE
						and WOW_PROJECT_ID ~= WOW_PROJECT_MAINLINE
					then
						-- Friend is on Mainline, we're not
						if not restriction then
							restriction = BFL_INVITE_RESTRICTION_WOW_PROJECT_MAINLINE
						end
					elseif gameAccountInfo.wowProjectID ~= WOW_PROJECT_ID then
						-- Different WoW version (other)
						if not restriction then
							restriction = BFL_INVITE_RESTRICTION_WOW_PROJECT_ID
						end
					elseif gameAccountInfo.realmID == 0 then
						-- No realm info
						if not restriction then
							restriction = BFL_INVITE_RESTRICTION_INFO
						end
					elseif BFL.IsClassic and playerRealmID and gameAccountInfo.realmID ~= playerRealmID then
						restriction = BFL_INVITE_RESTRICTION_REALM
					elseif not gameAccountInfo.isInCurrentRegion then
						-- Different region
						restriction = BFL_INVITE_RESTRICTION_REGION
					else
						-- At least one valid WoW account that can be invited
						restriction = BFL_INVITE_RESTRICTION_NONE
						break
					end
				elseif gameAccountInfo.realmID == 0 then
					-- No realm info
					if not restriction then
						restriction = BFL_INVITE_RESTRICTION_INFO
					end
				elseif BFL.IsClassic and playerRealmID and gameAccountInfo.realmID ~= playerRealmID then
					restriction = BFL_INVITE_RESTRICTION_REALM
				elseif not gameAccountInfo.isInCurrentRegion then
					-- Different region
					restriction = BFL_INVITE_RESTRICTION_REGION
				elseif gameAccountInfo.realmID and gameAccountInfo.realmID ~= 0 then
					-- Valid WoW account (no project ID check needed)
					restriction = BFL_INVITE_RESTRICTION_NONE
					break
				end
			else
				-- Non-WoW client (App, BSAp, etc.)
				if not restriction then
					restriction = BFL_INVITE_RESTRICTION_CLIENT
				end
			end
		end
	end

	-- If no restriction was set, means no game accounts at all
	if not restriction then
		restriction = BFL_INVITE_RESTRICTION_NO_GAME_ACCOUNTS
	end

	if FriendsFrame_GetPlayerGUIDFromIndex then
		displayedGuid = FriendsFrame_GetPlayerGUIDFromIndex(actualIndex) or displayedGuid
	end

	local displayedInviteType = displayedGuid and GetDisplayedInviteType and GetDisplayedInviteType(displayedGuid) or nil
	if displayedInviteType and isCrossFaction then
		displayedInviteType = displayedInviteType .. "_CROSS_FACTION"
	end

	-- Set tooltip text based on restriction
	local tooltipText = inviteTypeToButtonText[displayedInviteType] or TRAVEL_PASS_INVITE or "Invite to Group"
	if not inviteTypeToButtonText[displayedInviteType] and isCrossFaction then
		tooltipText = TRAVEL_PASS_INVITE_CROSS_FACTION or "Invite to Group"
	end

	BFL_Tooltip:SetText(tooltipText, HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)

	-- Add cross-faction line
	if isCrossFaction and factionName then
		local factionLabel = factionName == "Horde" and (FACTION_HORDE or "Horde") or (FACTION_ALLIANCE or "Alliance")
		if CROSS_FACTION_INVITE_TOOLTIP then
			BFL_Tooltip:AddLine(CROSS_FACTION_INVITE_TOOLTIP:format(factionLabel), nil, nil, nil, true)
		end
	end

	if
		restriction == BFL_INVITE_RESTRICTION_NONE
		and (displayedInviteType == "REQUEST_INVITE" or displayedInviteType == "REQUEST_INVITE_CROSS_FACTION")
	then
		AddSocialQueueGroupMembersToTooltip(BFL_Tooltip, displayedGuid)
	end

	-- Add restriction text in red if there's a restriction
	if restriction ~= BFL_INVITE_RESTRICTION_NONE then
		local restrictionText = GetBFLInviteRestrictionText(restriction) or ""

		if restrictionText ~= "" then
			BFL_Tooltip:AddLine(restrictionText, RED_FONT_COLOR.r, RED_FONT_COLOR.g, RED_FONT_COLOR.b, true)
		end
	end

	BFL_Tooltip:Show()
end

-- Button OnClick Handler (matching Blizzard's FriendsFrame_SelectFriend behavior)
function BetterFriendsList_Button_OnClick(button, mouseButton)
	if not button.friendData then
		return
	end

	local FriendsListModule = GetFriendsList()

	if mouseButton == "LeftButton" then
		PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON)
		-- Left click: Select friend (for Send Message button) with visual highlight
		if FriendsListModule and button.friendData then
			-- Clear previous selection highlight
			if FriendsListModule.selectedButton and FriendsListModule.selectedButton.selectionHighlight then
				FriendsListModule.selectedButton.selectionHighlight:Hide()
			end

			-- Set new selection (store UID to avoid stale pooled objects)
			local friendUID = button.friendData.uid
				or (FriendsListModule.GetFriendUID and FriendsListModule:GetFriendUID(button.friendData))
			FriendsListModule.selectedFriendUID = friendUID
			FriendsListModule.selectedFriend = nil
			FriendsListModule.selectedButton = button

			-- Show selection highlight (blue, like Blizzard)
			if button.selectionHighlight then
				button.selectionHighlight:Show()
			end

			-- Update Send Message button state
			if FriendsListModule.UpdateSendMessageButton then
				FriendsListModule:UpdateSendMessageButton()
			end

			if FriendsListModule.HandleFriendWhisperClick then
				FriendsListModule:HandleFriendWhisperClick(button.friendData, "single")
			end
		end
	elseif mouseButton == "RightButton" then
		PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON)

		local friendData = button.friendData

		if friendData.type == "bnet" then
			-- BattleNet friend context menu
			-- FIXED: Use bnetAccountID instead of index to avoid stale data issues
			local accountInfo = friendData.bnetAccountID and C_BattleNet.GetAccountInfoByID(friendData.bnetAccountID)
				or nil
			if accountInfo then
				local resolvedIndex = FriendsListModule
						and FriendsListModule.ResolveBNetFriendIndex
						and FriendsListModule:ResolveBNetFriendIndex(friendData.bnetAccountID, friendData.battleTag)
					or friendData.index
				BetterFriendsList_ShowBNDropdown(
					BFL:GetSafeAccountName(accountInfo.accountName, accountInfo.battleTag),
					accountInfo.gameAccountInfo.isOnline,
					nil, -- lineID
					nil, -- chatType
					nil, -- chatFrame
					true, -- showMenuFlag (Pass TRUE to force menu show, NOT friendsList index)
					accountInfo.bnetAccountID,
					nil, -- communityClubID
					nil, -- communityStreamID
					nil, -- communityEpoch
					nil, -- communityPosition
					accountInfo.battleTag,
					resolvedIndex
				)
			end
		else
			-- WoW friend context menu
			-- FIXED: Use index instead of guid (guid is not stored in friend data)
			local resolvedIndex = FriendsListModule
					and FriendsListModule.ResolveWoWFriendIndex
					and FriendsListModule:ResolveWoWFriendIndex(friendData.name)
				or friendData.index
			local info = resolvedIndex and C_FriendList.GetFriendInfoByIndex(resolvedIndex) or nil
			if info then
				BetterFriendsList_ShowDropdown(
					info.name,
					info.connected,
					nil, -- lineID
					nil, -- chatType
					nil, -- chatFrame
					resolvedIndex, -- friendsList (Pass INDEX here, not true)
					nil, -- communityClubID
					nil, -- communityStreamID
					nil, -- communityEpoch
					nil, -- communityPosition
					info.guid,
					resolvedIndex
				)
			end
		end
	end
end

function BetterFriendsList_Button_OnDoubleClick(button, mouseButton)
	if mouseButton ~= "LeftButton" or not button.friendData then
		return
	end

	local FriendsListModule = GetFriendsList()
	if FriendsListModule and FriendsListModule.HandleFriendWhisperClick then
		FriendsListModule:HandleFriendWhisperClick(button.friendData, "double")
	end
end

-- Show WoW Friend Context Menu (1:1 from Blizzard)
function BetterFriendsList_ShowDropdown(
	name,
	connected,
	lineID,
	chatType,
	chatFrame,
	friendsList,
	communityClubID,
	communityStreamID,
	communityEpoch,
	communityPosition,
	guid,
	index
) -- Ensure friendsList is a number (index) if possible
	local actualIndex = index
	if not actualIndex and type(friendsList) == "number" then
		actualIndex = friendsList
	end

	if connected or friendsList then
		local contextData = {
			name = name,
			friendsList = actualIndex, -- RIO Fix: Pass index if available (MUST be number or nil)
			lineID = lineID,
			communityClubID = communityClubID,
			communityStreamID = communityStreamID,
			communityEpoch = communityEpoch,
			communityPosition = communityPosition,
			chatType = chatType,
			chatTarget = name,
			chatFrame = chatFrame,
			bnetIDAccount = nil,
			guid = guid,
			uid = BFL:NormalizeWoWFriendName(name) and ("wow_" .. BFL:NormalizeWoWFriendName(name)) or nil, -- For Nickname (consistent with FriendsList UID)
			index = actualIndex, -- Required for some addons (e.g. RaiderIO)
			friendsIndex = actualIndex, -- Alternative name used by Blizzard
		}

		-- Determine menu type based on online status
		local menuType = connected and "FRIEND" or "FRIEND_OFFLINE"
		-- Use compatibility wrapper for Classic support
		BFL.OpenContextMenu(nil, menuType, contextData, name)
	end
end

-- Show BattleNet Friend Context Menu (1:1 from Blizzard)
function BetterFriendsList_ShowBNDropdown(
	name,
	connected,
	lineID,
	chatType,
	chatFrame,
	showMenuFlag,
	bnetIDAccount,
	communityClubID,
	communityStreamID,
	communityEpoch,
	communityPosition,
	battleTag,
	index
) -- Ensure friendsList is a number (index) if possible
	local actualIndex = index
	if not actualIndex and type(showMenuFlag) == "number" then
		actualIndex = showMenuFlag
	end

	if connected or showMenuFlag then
		local contextData = {
			name = name,
			friendsList = showMenuFlag, -- Restored v2.0.0 behavior (was nil)
			lineID = lineID,
			communityClubID = communityClubID,
			communityStreamID = communityStreamID,
			communityEpoch = communityEpoch,
			communityPosition = communityPosition,
			chatType = chatType,
			chatTarget = name,
			chatFrame = chatFrame,
			bnetIDAccount = bnetIDAccount,
			battleTag = battleTag,
			uid = (battleTag and battleTag ~= "") and ("bnet_" .. battleTag) or ("bnet_" .. tostring(bnetIDAccount)), -- For Nickname (consistent with FriendsList UID)
			index = actualIndex, -- Required for some addons
			friendsIndex = actualIndex, -- Alternative name
		}

		-- Determine menu type based on online status
		local menuType = connected and "BN_FRIEND" or "BN_FRIEND_OFFLINE"

		-- Use compatibility wrapper for Classic support
		BFL.OpenContextMenu(nil, menuType, contextData, name)
	end
end

--------------------------------------------------------------------------
-- WHO FRAME FUNCTIONALITY
--------------------------------------------------------------------------

-- Who frame variables
local whoSortValue = 1 -- Default sort: zone
local MAX_WHOS_FROM_SERVER = 50 -- Maximum number of results from server
local NUM_WHO_BUTTONS = 17 -- Number of visible buttons in Who list
local whoDataProvider = nil
local selectedWhoButton = nil

--------------------------------------------------------------------------
-- WHO FRAME COLUMN DROPDOWN
--------------------------------------------------------------------------

-- WHO Frame mixins are now in Modules/WhoFrame.lua

--------------------------------------------------------------------------
-- CONTACTS MENU (11.2.5)
--------------------------------------------------------------------------

-- Store active menu instance for combat closing (use global scope for persistence)
if not _G.BFL_ActiveContactsMenus then
	_G.BFL_ActiveContactsMenus = {}
end

-- Contacts Menu (11.2.5 - replaces broadcast button, includes ignore list)
-- Uses MenuUtil like Blizzard's ContactsMenuMixin
function BetterFriendsFrame_ShowContactsMenu(button)
	local generator = function(ownerRegion, rootDescription)
		rootDescription:SetTag("BFL_CONTACTS_MENU")

		-- Add BetterFriendList title
		rootDescription:CreateTitle(L.MENU_TITLE)

		-- Settings option
		rootDescription:CreateButton(L.MENU_SETTINGS, function()
			BetterFriendlistSettings_Show()
		end)

		rootDescription:CreateDivider()

		-- Simple Mode ONLY: Include "Quick Filter" and "Sort" menus here
		local DB = GetDB()
		local simpleMode = DB and DB:Get("simpleMode", false) or false
		if simpleMode then
			-- Search Toggle
			local searchToggle = rootDescription:CreateCheckbox(L.MENU_SHOW_SEARCH or "Show Search", function()
				return DB and DB:Get("simpleModeShowSearch", true)
			end, function()
				if DB then
					local current = DB:Get("simpleModeShowSearch", true)
					DB:Set("simpleModeShowSearch", not current)
					BFL:ForceRefreshFriendsList()
				end
			end)

			-- Quick Filter Submenu
			local filterSubmenu = rootDescription:CreateButton(L.MENU_QUICK_FILTER or "Quick Filter")
			local QuickFilters = BFL:GetModule("QuickFilters")
			if QuickFilters and QuickFilters.PopulateMenu then
				QuickFilters:PopulateMenu(filterSubmenu)
			end

			-- Sort Submenu
			local sortSubmenu = rootDescription:CreateButton(L.Sort or "Sort")
			local FriendsList = BFL:GetModule("FriendsList")
			if FriendsList and FriendsList.PopulateSortMenu then
				-- Primary Sort
				local primarySort = sortSubmenu:CreateButton(L.SORT_PRIMARY_LABEL or "Primary")
				FriendsList:PopulateSortMenu(primarySort, "primary")

				-- Secondary Sort
				local secondarySort = sortSubmenu:CreateButton(L.SORT_SECONDARY_LABEL or "Secondary")
				FriendsList:PopulateSortMenu(secondarySort, "secondary")
			end

			rootDescription:CreateDivider()
		end

		-- Changelog option (Simple Mode OR Classic+ElvUI)
		local DB = GetDB()
		local simpleMode = DB and DB:Get("simpleMode", false) or false
		local isElvUIActive = BFL.IsClassic
			and _G.ElvUI
			and BFL.IsThemeActive
			and BFL:IsThemeActive("elvui")
		if simpleMode or isElvUIActive then
			local changelogText = L.MENU_CHANGELOG or "Changelog"
			local Changelog = BFL:GetModule("Changelog")
			if Changelog and Changelog:IsNewVersion() then
				local atlas, size
				if BFL.IsClassic then
					atlas = "communities-icon-invitemail"
					size = ":48:64"
					changelogText = string.format("|A:%s%s|a %s", atlas, size, changelogText)
				else
					-- Retail: Use "NewCharacter-Alliance" (56x29) positioned after text
					-- Height: 29, Width: 56
					changelogText = string.format("%s|A:NewCharacter-Alliance:29:56|a", changelogText)
				end
			end

			rootDescription:CreateButton(changelogText, function()
				local Changelog = BFL:GetModule("Changelog")
				if Changelog then
					Changelog:Show()
				end
			end)
		end

		-- Show Blizzard's Friendlist option (conditional based on setting)
		local DB = GetDB()
		if DB and DB:Get("showBlizzardOption", false) then
			-- Check combat state
			local inCombat = InCombatLockdown()

			-- Create button title with combat lock icon if in combat
			local buttonTitle = L.MENU_SHOW_BLIZZARD
			if inCombat then
				-- Add combat lock icon (same as Raid Tab)
				buttonTitle = "|TInterface\\DialogFrame\\UI-Dialog-Icon-AlertNew:16:16:0:0|t " .. buttonTitle
			end

			local menuButton = rootDescription:CreateButton(
				buttonTitle,
				function() -- Use helper function that bypasses our OnShow hook
					if BFL and BFL.ShowBlizzardFriendsFrame then
						BFL.ShowBlizzardFriendsFrame()
					elseif BFL and BFL.OriginalToggleFriendsFrame then
						-- Fallback to stored original function
						BFL.OriginalToggleFriendsFrame()
					else
						-- Fallback to ShowUIPanel (may cause taint in combat)
						if FriendsFrame:IsShown() then
							HideUIPanel(FriendsFrame)
						else
							ShowUIPanel(FriendsFrame)
						end
					end
				end
			)

			-- Disable button in combat (ShowUIPanel/HideUIPanel are protected)
			if inCombat then
				menuButton:SetEnabled(false)
				-- Add tooltip using OnEnter/OnLeave (SetTooltip doesn't exist)
				menuButton:AddInitializer(function(btn, description, menuInstance)
					btn:SetScript("OnEnter", function(self)
						BFL_Tooltip:SetOwner(self, "ANCHOR_RIGHT")
						BFL_Tooltip:SetText(L.MENU_SHOW_BLIZZARD, 1, 1, 1)
						BFL_Tooltip:AddLine(L.MENU_COMBAT_LOCKED, 1, 0.2, 0.2, true)
						BFL_Tooltip:Show()
					end)
					btn:SetScript("OnLeave", function()
						BFL_Tooltip:Hide()
					end)
				end)
			else
				menuButton:SetEnabled(true)
			end
		end

		-- Create Group option
		rootDescription:CreateButton(L.MENU_CREATE_GROUP, function()
			StaticPopup_Show("BETTER_FRIENDLIST_CREATE_GROUP")
		end)

		rootDescription:CreateDivider()

		-- Broadcast option (only if BNet is connected)
		local canUseBroadCastFrame = BNFeaturesEnabled() and BNConnected()
		if canUseBroadCastFrame then
			rootDescription:CreateButton(CONTACTS_MENU_BROADCAST_BUTTON_NAME or L.MENU_SET_BROADCAST, function()
				local broadcastFrame = BetterFriendsFrame.FriendsTabHeader.BattlenetFrame.BroadcastFrame
				if broadcastFrame then
					broadcastFrame:ToggleFrame()
				end
			end)
		end

		-- Ignore List option
		rootDescription:CreateButton(CONTACTS_MENU_IGNORE_BUTTON_NAME or L.MENU_IGNORE_LIST, function()
			BetterFriendsFrame_ShowIgnoreList()
		end)
	end

	-- Use DropdownButtonMixin (Retail 11.0+) to ensure correct anchoring
	if button.SetupMenu and button.OpenMenu then
		-- Setup once and restore native Toggle behavior
		if not button._bflMenuInitialized then
			button._bflMenuInitialized = true
			button:SetupMenu(generator)

			-- Fix: Template overrides OnMouseDown, breaking Mixin behavior.
			-- Hook OnMouseDown to properly toggle the menu.
			if button.HookScript then
				button:HookScript("OnMouseDown", function(self)
					if self.ToggleMenu then
						self:ToggleMenu()
					end
				end)
			end

			-- Disable OnClick to prevent "Double Toggle" (Close on MouseDown -> Re-open on MouseUp)
			if button.SetScript then
				button:SetScript("OnClick", nil)
			end

			-- Open initially (since we missed the MouseDown hook for this first click)
			button:OpenMenu()
		end
		return
	end

	-- Fallback: Manual creation (forces cursor anchor, but tries to fix it)
	local menu = MenuUtil.CreateContextMenu(button, generator)

	if menu then
		table.insert(_G.BFL_ActiveContactsMenus, menu)
	end

	-- Hook menu's Hide to track when it closes (but DON'T remove from list yet)
	if menu and not menu._bflHooked then
		menu._bflHooked = true
		local originalHide = menu.Hide
		menu.Hide = function(self)
			originalHide(self)
			-- DON'T remove from list - let combat handler do cleanup
		end
	end
end

-- Show Ignore List (11.2.5 - part of contacts menu)
function BetterFriendsFrame_ShowIgnoreList() -- Show IgnoreList module UI (always, even if empty)
	local IgnoreList = BFL and BFL:GetModule("IgnoreList")
	if IgnoreList then
		-- Set ignore list as active dropdown and show
		if BetterFriendsFrame and BetterFriendsFrame.IgnoreListWindow then
			BetterFriendsFrame.IgnoreListWindow:Show()
			IgnoreList:Update()
		end
	else
		-- Fallback: show console message
		local numIgnores = C_FriendList.GetNumIgnores()
		if numIgnores == 0 then
			AddChatMessage(L.IGNORE_LIST_EMPTY)
		else
			AddChatMessage(string.format(L.IGNORE_LIST_HEADER, numIgnores))
			for i = 1, numIgnores do
				local name = C_FriendList.GetIgnoreName(i)
				if name then
					AddChatMessage("  " .. i .. ". " .. name)
				end
			end
			AddChatMessage(L.IGNORE_LIST_HELP)
		end
	end
end

-- ========================================
-- XML CALLBACK FUNCTIONS (Required by XML OnLoad/OnEvent/OnShow/OnHide attributes)
-- ========================================
-- These functions are called directly from XML and must remain global

-- WHO Frame OnLoad (called from XML line 1540)
function BetterWhoFrame_OnLoad(self)
	local WhoFrame = GetWhoFrame()
	if WhoFrame then
		WhoFrame:OnLoad(self)
	end
end

-- WHO Frame Update (called from XML OnShow and event handlers)
function BetterWhoFrame_Update(forceRebuild)
	local WhoFrame = GetWhoFrame()
	if WhoFrame then
		WhoFrame:Update(forceRebuild)
	end
end

-- WHO Frame Sort By Column (called from XML header buttons)
function BetterWhoFrame_SortByColumn(column)
	local WhoFrame = GetWhoFrame()
	if WhoFrame then
		WhoFrame:SortByColumn(column)
	end
end

-- WHO Frame Send Request (called from XML Who button)
function BetterWhoFrame_SendWhoRequest(text)
	local WhoFrame = GetWhoFrame()
	if WhoFrame then
		WhoFrame:SendWhoRequest(text)
	end
end

-- WHO Frame Set Selected Button (called from XML)
function BetterWhoFrame_SetSelectedButton(button)
	local WhoFrame = GetWhoFrame()
	if WhoFrame then
		WhoFrame:SetSelectedButton(button)
	end
end

-- WHO Frame Invalidate Font Cache (called from XML OnShow)
function BetterWhoFrame_InvalidateFontCache()
	local WhoFrame = GetWhoFrame()
	if WhoFrame then
		WhoFrame:InvalidateFontCache()
	end
end

-- Ignore List Window OnLoad (called from XML line 1801)
function BetterIgnoreListWindow_OnLoad(self)
	local IgnoreList = GetIgnoreList()
	if IgnoreList then
		IgnoreList:OnLoad(self)
	end
end

-- Ignore List Update (called from XML OnShow)
function IgnoreList_Update()
	local IgnoreList = GetIgnoreList()
	if IgnoreList then
		IgnoreList:Update()
	end
end

-- Recent Allies Frame OnLoad (called from XML line 1841)
function BetterRecentAlliesFrame_OnLoad(self)
	local RecentAllies = GetRecentAllies()
	if RecentAllies then
		RecentAllies:OnLoad(self)
	end
end

-- Recent Allies Frame OnShow (called from XML)
function BetterRecentAlliesFrame_OnShow(self)
	local RecentAllies = GetRecentAllies()
	if RecentAllies then
		RecentAllies:OnShow(self)
	end
end

-- Recent Allies Frame OnHide (called from XML)
function BetterRecentAlliesFrame_OnHide(self)
	local RecentAllies = GetRecentAllies()
	if RecentAllies then
		RecentAllies:OnHide(self)
	end
end

-- Recent Allies Frame OnEvent (called from XML)
function BetterRecentAlliesFrame_OnEvent(self, event, ...)
	local RecentAllies = GetRecentAllies()
	if RecentAllies then
		RecentAllies:OnEvent(self, event, ...)
	end
end

-- RAF Frame OnLoad (called from XML line 1292)
function BetterRAF_OnLoad(frame)
	local RAF = GetRAF()
	if RAF then
		RAF:OnLoad(frame)
	end
end

-- RAF Frame OnEvent (called from XML)
function BetterRAF_OnEvent(frame, event, ...)
	local RAF = GetRAF()
	if RAF then
		RAF:OnEvent(frame, event, ...)
	end
end

-- RAF Frame OnHide (called from XML)
function BetterRAF_OnHide(frame)
	local RAF = GetRAF()
	if RAF then
		RAF:OnHide(frame)
	end
end

-- RAF Button Callbacks (called from ScrollBox initializer and XML)
function BetterRecruitListButton_Init(button, elementData)
	local RAF = GetRAF()
	if RAF then
		RAF:RecruitListButton_Init(button, elementData)
	end
end

function BetterRecruitListButton_OnEnter(button)
	local RAF = GetRAF()
	if RAF then
		RAF:RecruitListButton_OnEnter(button)
	end
end

function BetterRecruitListButton_OnClick(button, mouseButton)
	local RAF = GetRAF()
	if RAF then
		RAF:RecruitListButton_OnClick(button, mouseButton)
	end
end

function BetterRecruitActivityButton_OnClick(button)
	local RAF = GetRAF()
	if RAF then
		RAF:RecruitActivityButton_OnClick(button)
	end
end

function BetterRecruitActivityButton_OnEnter(button)
	local RAF = GetRAF()
	if RAF then
		RAF:RecruitActivityButton_OnEnter(button)
	end
end

function BetterRecruitActivityButton_OnLeave(button)
	local RAF = GetRAF()
	if RAF then
		RAF:RecruitActivityButton_OnLeave(button)
	end
end

function BetterRAF_NextRewardButton_OnClick(button, mouseButton)
	local RAF = GetRAF()
	if RAF then
		RAF:NextRewardButton_OnClick(button, mouseButton)
	end
end

function BetterRAF_NextRewardButton_OnEnter(button)
	local RAF = GetRAF()
	if RAF then
		RAF:NextRewardButton_OnEnter(button)
	end
end

function BetterRAF_NextRewardButton_OnLeave(button)
	local RAF = GetRAF()
	if RAF then
		RAF:NextRewardButton_OnLeave(button)
	end
end

function BetterRAF_ClaimOrViewRewardButton_OnClick(button)
	local RAF = GetRAF()
	if RAF then
		RAF:ClaimOrViewRewardButton_OnClick(button)
	end
end

function BetterRAF_DisplayRewardsInChat(rafInfo)
	local RAF = GetRAF()
	if RAF then
		RAF:DisplayRewardsInChat(rafInfo)
	end
end

function BetterRAF_RecruitmentButton_OnClick(button)
	local RAF = GetRAF()
	if RAF then
		RAF:RecruitmentButton_OnClick(button)
	end
end

-- ========================================
-- BOTTOM TAB MANAGEMENT
-- ========================================

-- Handle legacy Guild Tab Click (Classic only)
function BetterFriendsFrame_HandleGuildTabClick()
	PanelTemplates_SetTab(BetterFriendsFrame, 1)
	BetterFriendsFrame_ShowBottomTab(1)
end

-- Function to show specific bottom tab
function BetterFriendsFrame_ShowBottomTab(tabIndex)
	local frame = BetterFriendsFrame
	if not frame then
		return
	end

	-- CRITICAL: Update the Frame's selected tab index so PanelTemplates_GetSelectedTab returns correct value
	-- This fixes 'isFriendsTab' checks in other modules (like FriendsList:UpdateSearchBoxState)
	PanelTemplates_SetTab(frame, tabIndex)

	-- SEARCHBOX VISIBILITY IS NOW MANAGED ENTIRELY BY FriendsList:UpdateScrollBoxExtent()
	-- We do not manually hide it here to prevent race conditions (Scenario 1 fix)

	-- FORCE FONT UPDATE: Ensure Custom Fonts win against ElvUI
	BFL:RefreshBottomTabVisualState(tabIndex)

	-- Use hybrid helper functions
	HideChildFrame(frame.ScrollFrame)
	HideChildFrame(frame.MinimalScrollBar)
	BFL:CloseTaintFreeWhisper()
	HideChildFrame(frame.AddFriendButton)
	HideChildFrame(frame.SendMessageButton)
	HideChildFrame(frame.RecruitmentButton)
	HideChildFrame(frame.WhoFrame)
	HideChildFrame(frame.RaidFrame)
	HideChildFrame(frame.GuildFrame)
	HideChildFrame(frame.SortFrame)
	HideChildFrame(frame.RecentAlliesFrame)
	HideChildFrame(frame.RecruitAFriendFrame)
	HideChildFrame(frame.QuickJoinFrame)

	-- Hide docked Search Builder when switching away from WHO tab
	local WhoFrameModule = BFL:GetModule("WhoFrame")
	if WhoFrameModule and WhoFrameModule.builderDocked and WhoFrameModule.builderDockedContainer then
		WhoFrameModule.builderDockedContainer:Hide()
	end

	-- Hide all friend list buttons explicitly
	-- REMOVED: ScrollBox handles button visibility automatically
	-- if tabIndex ~= 1 then
	-- 	for i = 1, NUM_BUTTONS do
	-- 		local xmlButton = frame.ScrollFrame and frame.ScrollFrame["Button" .. i]
	-- 		if xmlButton then
	-- 			HideChildFrame(xmlButton)
	-- 		end
	-- 	end
	-- end

	-- Hide/show FriendsTabHeader based on tab
	if frame.FriendsTabHeader then
		if tabIndex == 1 then
			ShowChildFrame(frame.FriendsTabHeader)
		else
			HideChildFrame(frame.FriendsTabHeader)
		end
	end

	-- Hide/show Inset based on tab
	if frame.Inset then
		-- Tabs with their own ListInset need the main Inset hidden
		local hideInset = (tabIndex == 2) or (tabIndex == 3)
		if BFL.IsClassic then
			hideInset = hideInset or (tabIndex == 4)
		end
		if hideInset then
			HideChildFrame(frame.Inset)
		else
			ShowChildFrame(frame.Inset)
		end
	end

	-- Show/hide Help button (only visible on Raid tab)
	if frame.HelpButton then
		local isRaidTab = false
		if BFL.IsClassic then
			-- Classic: Raid is Tab 4
			isRaidTab = (tabIndex == 4)
		else
			-- Retail: Raid is Tab 3
			isRaidTab = (tabIndex == 3)
		end

		if isRaidTab then
			ShowChildFrame(frame.HelpButton)
		else
			HideChildFrame(frame.HelpButton)
		end
	end

	if tabIndex == 1 then
		-- Tab 1: Friends/Contacts list
		-- CRITICAL: Check which TOP tab is active (Friends/Recent Allies/RAF)
		-- to show the correct content when switching from Who/Raid back to Contacts
		if not BFL.IsClassic and frame.FriendsTabHeader then
			local activeTopTab = PanelTemplates_GetSelectedTab(frame.FriendsTabHeader) or 1

			if activeTopTab == 1 then
				-- Top Tab 1: Friends
				ShowChildFrame(frame.ScrollFrame)
				ShowChildFrame(frame.MinimalScrollBar)
				ShowChildFrame(frame.AddFriendButton)
				ShowChildFrame(frame.SendMessageButton)
				UpdateFriendsDisplay()
			elseif activeTopTab == 2 then
				-- Top Tab 2: Recent Allies
				if frame.RecentAlliesFrame then
					ShowChildFrame(frame.RecentAlliesFrame)
					local RecentAllies = BFL:GetModule("RecentAllies")
					if RecentAllies then
						RecentAllies:Refresh(frame.RecentAlliesFrame, ScrollBoxConstants.RetainScrollPosition)
					end
				end
			elseif activeTopTab == 3 then
				-- Top Tab 3: Recruit A Friend
				if frame.RecruitAFriendFrame then
					ShowChildFrame(frame.RecruitAFriendFrame)
					ShowChildFrame(frame.RecruitmentButton)
				end
			elseif activeTopTab == 4 then
				-- Top Tab 4: Guild roster
				if frame.GuildFrame and BFL.IsGuildTabEnabled and BFL:IsGuildTabEnabled() then
					ShowChildFrame(frame.GuildFrame)
				end
			end
		else
			-- Classic: Always show Friends list (only 1 top tab exists)
			ShowChildFrame(frame.ScrollFrame)
			ShowChildFrame(frame.MinimalScrollBar)
			ShowChildFrame(frame.AddFriendButton)
			ShowChildFrame(frame.SendMessageButton)
			UpdateFriendsDisplay()
		end
	elseif tabIndex == 2 then
		-- Tab 2: Who frame
		ShowChildFrame(frame.WhoFrame)
		BetterWhoFrame_Update()
		-- Restore docked Search Builder if it was open
		if
			WhoFrameModule
			and WhoFrameModule.builderDocked
			and WhoFrameModule.builderDockedContainer
			and WhoFrameModule.builderFlyout
			and WhoFrameModule.builderFlyout:IsShown()
		then
			WhoFrameModule.builderDockedContainer:Show()
		end
	elseif tabIndex == 3 then
		if BFL.IsClassic then
			-- Classic: Guild Tab - show guild roster
			if BFL.IsGuildTabEnabled and BFL:IsGuildTabEnabled() then
				ShowChildFrame(frame.GuildFrame)
			else
				PanelTemplates_SetTab(frame, 1)
				BetterFriendsFrame_ShowBottomTab(1)
			end
		else
			-- Retail: Raid
			ShowChildFrame(frame.RaidFrame)
		end
	elseif tabIndex == 4 then
		if BFL.IsClassic then
			-- Classic: Raid
			ShowChildFrame(frame.RaidFrame)
		else
			-- Retail: Quick Join
			ShowChildFrame(frame.QuickJoinFrame)
			if BetterQuickJoinFrame_OnShow then
				BetterQuickJoinFrame_OnShow(frame.QuickJoinFrame)
			end
		end
	end

	-- Force update of SearchBox state (Fix for visibility bugs)
	local FriendsList = BFL:GetModule("FriendsList")
	if FriendsList and FriendsList.UpdateSearchBoxState then
		FriendsList:UpdateSearchBoxState()
	end

	-- Update SearchBox placeholder text for the active top tab
	if tabIndex == 1 and frame.FriendsTabHeader then
		local searchBox = frame.FriendsTabHeader.SearchBox
		if searchBox and searchBox.Instructions then
			local activeTopTab = PanelTemplates_GetSelectedTab(frame.FriendsTabHeader) or 1
			UpdateSearchBoxInstruction(searchBox, activeTopTab)
		end
	end

	local GuildFrameModule = BFL:GetModule("GuildFrame")
	if GuildFrameModule and GuildFrameModule.OnTopTabChanged then
		local activeTopTab = frame.FriendsTabHeader and PanelTemplates_GetSelectedTab(frame.FriendsTabHeader) or 1
		GuildFrameModule:OnTopTabChanged(activeTopTab)
	end

	-- Apply custom tab fonts from DB settings (Applied LAST to respect selection state for sizing)
	BFL:ApplyTabFonts()
end

-- ========================================
-- WHO Frame Functions
-- ========================================
-- All WHO Frame functionality is delegated to Modules/WhoFrame.lua
-- XML callbacks reference module methods directly via mixins

-- ========================================
-- Help Button Functions
-- ========================================

-- Help button click handler - shows detailed help about raid roster features
function BetterFriendsFrame_HelpButton_OnClick(self) -- Toggle help frame visibility
	if BFL.HelpFrame then
		BFL.HelpFrame:Toggle()
	end
end

-- ========================================
-- ========================================
-- IGNORE LIST Functions
-- ========================================
-- All Ignore List functionality is delegated to Modules/IgnoreList.lua
-- XML callbacks reference module methods directly via mixins

-- ========================================
-- Recent Allies Functions
-- ========================================
-- All Recent Allies functionality is delegated to Modules/RecentAllies.lua
-- XML callbacks reference module methods directly via mixins

-- ========================================
-- RAF (Recruit-a-Friend) Functions
-- ========================================
-- All RAF functionality is delegated to Modules/RAF.lua
-- XML callbacks reference module methods directly via mixins

-- ========================================
-- RECENT ALLIES CALLBACKS
-- ========================================

function BetterRecentAlliesEntry_OnEnter(self)
	local RecentAllies = BFL and BFL:GetModule("RecentAllies")
	if RecentAllies and RecentAllies.BuildTooltip then
		BFL_Tooltip:SetOwner(self, "ANCHOR_RIGHT")
		RecentAllies:BuildTooltip(self, BFL_Tooltip)
		BFL_Tooltip:Show()
	end
end

function BetterRecentAlliesEntry_OnLeave(self)
	BFL_Tooltip:Hide()
end

-- ========================================
-- SLASH COMMANDS
-- ========================================

-- Reset Classic Guild UI warning flag (for testing)
SLASH_BFLRESET1 = "/bflreset"
SlashCmdList["BFLRESET"] = function(msg)
	if msg == "warning" then
		BetterFriendlistDB.classicGuildUIWarningShown = nil
		AddChatMessage("|cff00ff00BetterFriendlist:|r " .. L.CMD_RESET_FILTER_SUCCESS)
	else
		AddChatMessage("|cff00ff00BetterFriendlist " .. L.CMD_RESET_HEADER .. "|r")
		AddChatMessage("  |cffFFD100/bflreset warning|r - " .. L.CMD_RESET_HELP_WARNING)
	end
end

-- ========================================
-- RAID FRAME & QUICK JOIN XML CALLBACKS
-- ========================================
-- All Raid Frame and Quick Join XML callbacks have been moved to:
--   - UI/RaidFrameCallbacks.lua (268 lines)
--   - UI/QuickJoinCallbacks.lua (264 lines)
-- This keeps BetterFriendlist.lua focused on Friends List logic only.
