--[[## Version: 12.2.2 ## Author: Updated for Midnight Pre-Patch (12.0) by Osiris the Kiwi / Discord: osirisnz / Original authors: mkp_tv / Hayato2846 / Thainii
	FriendGroups - FULL ORIGINAL CODE + SECURE ARCHITECTURE
    - Uses the exact original logic for groups, menus, and sorting.
    - Adds "Lazy Loading" to prevent VisitHouse() taint.
    - Adds Safety Shield to the House List.
]] --

local FGL = {}

-- [[ GUARD CLAUSE: STOP IF NOT CN ]] --
if GetLocale() == "zhCN" then
FGL["SET_CLASS_ICONS"] = "显示职业图标"
FGL["SET_SHOW_NOTE"] = "显示备注"
FGL["SET_STATUS_INDICATOR"] = "显示状态指示器"
FGL["SET_GAME_ICON"] = "显示游戏图标"
FGL["SET_FACTION_COLOR"] = "使用阵营行颜色"
FGL["SET_EUI_SKIN"] = "EllesmereUI 皮肤"
FGL["SET_EUI_SKIN_TT"] = "检测到 EllesmereUI 插件时自动应用 EllesmereUI 外观。"
FGL["EUI_RELOAD_PROMPT"] = "此更改需要重新加载界面。现在重新加载吗？"

-- ============================================================================
-- [[ SETTINGS & OPTIONS MENU ]]
-- ============================================================================
FGL["SETTINGS_ADVANCED"] = "高级"
FGL["SETTINGS_ALT_CHARS"] = "小号角色"
FGL["SETTINGS_APPEARANCE"] = "外观"
FGL["SETTINGS_AUTOMATION"] = "自动化"
FGL["SETTINGS_BEHAVIOR"] = "行为"
FGL["SETTINGS_EXPORT"] = "导出设置"
FGL["SETTINGS_FILTER"] = "过滤器"
FGL["SETTINGS_IMPORT"] = "导入设置"
FGL["SETTINGS_LAST_BACKUP"] = "上次备份：%s"
FGL["SETTINGS_PROFILE"] = "设置备份"
FGL["SETTINGS_PURGE_CACHE"] = "清除小号缓存"
FGL["SETTINGS_RESET"] = "|cffff0000重置为默认|r"
FGL["SETTINGS_RESET_ORDER"] = "重置分组顺序"
FGL["SETTINGS_SIZE"] = "列表大小"
FGL["SETTINGS_TITLE"] = "设置"
FGL["SETTINGS_VERSION"] = "|cFF33FF99[好友分组]|r v%s"
FGL["SET_AUTO_ACCEPT"] = "自动接受组队邀请"
FGL["SET_AUTO_PARTY_SYNC"] = "自动接受小队同步"
FGL["SET_CLASS_COLOR"] = "使用职业颜色"
FGL["SET_COLLAPSE"] = "自动折叠分组"
FGL["SET_FACTION_ICONS"] = "显示阵营图标"
FGL["SET_FAV_GROUP"] = "启用特别关注分组"
FGL["SET_GRAY_FACTION"] = "灰显对立阵营"
FGL["SET_GUILDMATES"] = "启用自动公会队伍"
FGL["SET_HIDE_AFK"] = "隐藏暂离 (AFK)"
FGL["SET_HIDE_EMPTY"] = "隐藏空分组"
FGL["SET_HIDE_MAX_LEVEL"] = "隐藏满级等级"
FGL["SET_HIDE_OFFLINE"] = "隐藏离线"
FGL["SET_INGAME_ONLY"] = "仅显示游戏内好友"
FGL["SET_KNOWN_ALTS"] = "启用小号追踪"
FGL["SET_MOBILE_AFK"] = "将移动端标记为暂离"
FGL["SET_OFFLINE_TRACKER"] = "启用离线追踪分组"
FGL["SET_RETAIL_ONLY"] = "仅显示正式服好友"
FGL["SET_SHOW_BTAG"] = "仅显示战网昵称"
FGL["SET_SHOW_FLAGS"] = "显示服务器旗帜"
FGL["SET_SHOW_REALM"] = "显示服务器名称"
FGL["SET_SIZE_LARGE"] = "大"
FGL["SET_SIZE_MEDIUM"] = "中"
FGL["SET_SIZE_SMALL"] = "小 (默认)"
FGL["SET_SPIRIT_HEADER"] = "灵魂行为"
FGL["SET_SPIRIT_RELEASE"] = "自动释放灵魂"
FGL["SET_SPIRIT_RES"] = "自动接受复活"
FGL["SET_WIDTH_NARROW"] = "窄 (默认)"
FGL["SET_WIDTH_NORMAL"] = "适中"
FGL["SET_WIDTH_WIDE"] = "宽"

-- ============================================================================
-- [[ CONTEXT MENUS ]]
-- ============================================================================
FGL["DROP_ADD"] = "添加到分组"
FGL["DROP_ADD_GUILD"] = "添加到公会分组"
FGL["DROP_CLEAR_MAIN"] = "清除已选大号"
FGL["DROP_COPY_BTAG"] = "复制 战网昵称"
FGL["DROP_COPY_NAME"] = "复制 名字-服务器"
FGL["DROP_CREATE"] = "新建分组"
FGL["DROP_REMOVE"] = "移出分组"
FGL["DROP_REMOVE_GUILD"] = "从公会分组移除"
FGL["DROP_SELECT_MAIN"] = "选择大号"
FGL["DROP_SET_NICKNAME"] = "设置昵称"
FGL["DROP_TITLE"] = "|cFF33FF99[好友分组]|r"
FGL["MENU_CLEAR_BANNER_COLOR"] = "清除横幅颜色"
FGL["MENU_INVITE"] = "邀请分组"
FGL["MENU_MAX_40"] = " (最多 40)"
FGL["MENU_MOVE_DOWN"] = "下移"
FGL["MENU_MOVE_UP"] = "上移"
FGL["MENU_REMOVE"] = "|cffff0000删除分组|r"
FGL["MENU_RENAME"] = "重命名分组"
FGL["MENU_RESET_POSITION"] = "重置位置"
FGL["MENU_SET_BANNER_COLOR"] = "设置横幅颜色"

-- ============================================================================
-- [[ TOOLTIPS ]]
-- ============================================================================
FGL["TOOLTIP_ALTS_FORMAT"] = "%s %s %s 等级 %d"
FGL["TOOLTIP_ALTS_GUILD_SUFFIX"] = " |cFFFFD100<%s>|r"
FGL["TOOLTIP_ALTS_PUBLIC_TITLE_FORMAT"] = "%s 的已知小号"
FGL["TOOLTIP_ALTS_SEEN"] = "最后出现在 %s - %s"
FGL["TOOLTIP_ALTS_TITLE_FORMAT_MAX"] = "%s 的已知小号（最近10个）"
FGL["TOOLTIP_CONTACT_BNET"] = "Battle.net 好友："
FGL["TOOLTIP_CONTACT_DESC"] = "Battle.net 上限为所有游戏共享。\nWoW 好友为此角色独有。"
FGL["TOOLTIP_CONTACT_INVITES"] = "待处理的邀请："
FGL["TOOLTIP_CONTACT_INV_DESC"] = "（这会消耗 BNet 栏位！）"
FGL["TOOLTIP_CONTACT_TITLE"] = "联系人上限"
FGL["TOOLTIP_CONTACT_TOTAL"] = "追踪的独立联系人总数：%d"
FGL["TOOLTIP_CONTACT_WOW"] = "WoW 角色好友："
FGL["TOOLTIP_CUSTOM_GROUP_DESC_1"] = "要将好友手动添加到此群组，请在好友备注中的任意位置输入 #%s。"
FGL["TOOLTIP_CUSTOM_GROUP_TITLE"] = "自定义群组"
FGL["TOOLTIP_EXPORT_1"] = "将你的设置和数据保存为完整备份代码。请妥善保存以便日后恢复。"
FGL["TOOLTIP_EXPORT_2"] = "包括外观、行为、分组顺序、横幅颜色、昵称和已选主角色。"
FGL["TOOLTIP_GROUP_COLOR_PICKER_NOTE"] = "右键单击此群组标题以选择自定义横幅背景颜色。"
FGL["TOOLTIP_GUILD_GROUP_DESC_1"] = "此分组将自动由在线的公会成员填充。"
FGL["TOOLTIP_GUILD_GROUP_DESC_2"] = "要手动将好友加入此分组，请在其好友备注中输入 %s。"
FGL["TOOLTIP_GUILD_GROUP_TITLE"] = "自动公会分组"
FGL["TOOLTIP_IMPORT_1"] = "从代码恢复备份。"
FGL["TOOLTIP_IMPORT_2"] = "这将用导入的设置覆盖你当前的设置。"
FGL["TOOLTIP_KNOWN_ALTS"] = "追踪你的战网好友所玩的角色，并在悬停时显示在面板中。关闭将停止追踪并清除所有已存储的小号数据。"

-- ============================================================================
-- [[ MESSAGES, POPUPS & DEBUG ]]
-- ============================================================================
FGL["DEBUG_DB_SIZE"] = "数据库配置文件：%d (私人战网缓存)"
FGL["DEBUG_GUID_INDEX"] = "GUID 索引条目: %d"
FGL["DEBUG_HEADER"] = "|cFF33FF99[[好友分组] 诊断数据]|r"
FGL["DEBUG_HELP"] = "|cFF33FF99[好友分组]：|r 命令: /fg export, /fg import, /fg debug"
FGL["DEBUG_MEM_USAGE"] = "当前插件内存：%.2f MB"
FGL["DEBUG_SECRET_GUID"] = "GUID 亦加密/不可用: %d"
FGL["DEBUG_SECRET_HEADER"] = "|cFF33FF99机密值遥测|r"
FGL["DEBUG_SECRET_HITS"] = "机密名称匹配: %d"
FGL["DEBUG_SECRET_RESOLVED"] = "通过 GUID 索引解析: %d"
FGL["DEBUG_SECRET_UNRESOLVED"] = "未解析 (无索引匹配): %d"
FGL["MSG_ALT_TRACKING_DISABLED"] = "|cFF33FF99[好友分组]|r：小号追踪已禁用。数据库已清空。"
FGL["MSG_AUTO_ACCEPT_FAILED"] = "|cFF33FF99[好友分组]|r: 暴雪在此区域阻止了自动接受。请手动点击按钮。"
FGL["MSG_AUTO_INVITE"] = "|cFF33FF99[好友分组]|r: %s 邀请你加入队伍。自动接受 |cff00ff00已启用|r"
FGL["MSG_AUTO_RELEASE"] = "|cFF33FF99[好友分组]|r: 你已经死亡。自动释放灵魂 |cff00ff00已启用|r"
FGL["MSG_AUTO_RELEASE_FAILED"] = "|cFF33FF99[好友分组]|r: 暴雪在此区域阻止了自动释放灵魂。请点击释放按钮。"
FGL["MSG_AUTO_RES"] = "|cFF33FF99[好友分组]|r: %s 正在复活你。自动接受 |cff00ff00已启用|r"
FGL["MSG_AUTO_SYNC"] = "|cFF33FF99[好友分组]|r: %s 邀请你进行小队同步。自动接受 |cff00ff00已启用|r"
FGL["MSG_IMPORT_FAIL_CHECKSUM"] = "|cFF33FF99[好友分组]|r: 设置代码已损坏或不完整。"
FGL["MSG_IMPORT_FAIL_FORMAT"] = "|cFF33FF99[好友分组]|r: 这不是有效的设置代码。"
FGL["MSG_IMPORT_FAIL_PROTOCOL"] = "|cFF33FF99[好友分组]|r: 设置代码来自不兼容的版本。"
FGL["MSG_IMPORT_OK"] = "|cFF33FF99[好友分组]|r: 设置已导入。"
FGL["MSG_IMPORT_OK_DATED"] = "|cFF33FF99[好友分组]|r: 已恢复备份（创建于 %s）。"
FGL["MSG_PURGE_CACHE"] = "|cFF33FF99[好友分组]：|r 小号和公会缓存已成功清除。"
FGL["MSG_RESET"] = "|cFF33FF99[好友分组]|r: 设置已重置为默认。"
FGL["POPUP_COPY"] = "按 Ctrl+C 复制:"
FGL["POPUP_ENTER_NAME"] = "输入新分组名称"
FGL["POPUP_ENTER_NICKNAME"] = "为此好友输入自定义昵称："
FGL["POPUP_IMPORT"] = "粘贴备份代码，然后点击接受。\n较大的备份在恢复时可能会使游戏卡顿最多一分钟 - 这是正常现象。"

-- ============================================================================
-- [[ LABELS, GROUPS & FORMATS ]]
-- ============================================================================
FGL["FORMAT_AKA_DISPLAY"] = " |cFF949694aka|r %s"
FGL["FORMAT_GUILD_TAG"] = "%s <%s>"
FGL["GROUP_EMPTY"] = "好友列表为空"
FGL["GROUP_FAVORITES"] = "[特别关注]"
FGL["GROUP_GUILDMATES"] = "[公会]"
FGL["GROUP_NONE"] = "[2ui]"
FGL["GROUP_OFFLINE_1"] = "[离线 1 个月]"
FGL["GROUP_OFFLINE_2"] = "[离线 2 个月]"
FGL["GROUP_OFFLINE_3"] = "[离线 3 个月以上]"
FGL["SEARCH_PLACEHOLDER"] = "[好友分组] 搜索"
FGL["SEARCH_TOOLTIP"] = "[好友分组]: 搜索任何人！名字、服务器、职业甚至备注"
FGL["STATUS_MOBILE"] = "移动端"
FGL["TEXT_BNET_CONTACTS"] = "%d/%d"
FGL["TIME_DAYS_AGO"] = "%d 天前"
FGL["TIME_HOURS_AGO"] = "%d 小时前"
FGL["TIME_JUST_NOW"] = "刚刚"
FGL["TIME_MINUTES_AGO"] = "%d 分钟前"
FGL["UNKNOWN"] = "未知"
FGL["UNKNOWN_ZONE"] = "未知区域"


-- [[ GUARD CLAUSE: STOP IF NOT TW ]] --
elseif GetLocale() == "zhTW" then

FGL["SET_CLASS_ICONS"] = "顯示職業圖示"
FGL["SET_SHOW_NOTE"] = "顯示備註"
FGL["SET_STATUS_INDICATOR"] = "顯示狀態指示器"
FGL["SET_GAME_ICON"] = "顯示遊戲圖示"
FGL["SET_FACTION_COLOR"] = "使用陣營列顏色"
FGL["SET_EUI_SKIN"] = "EllesmereUI 外觀"
FGL["SET_EUI_SKIN_TT"] = "偵測到 EllesmereUI 插件時自動套用 EllesmereUI 外觀。"
FGL["EUI_RELOAD_PROMPT"] = "此變更需要重新載入介面。要現在重新載入嗎？"

-- ============================================================================
-- [[ SETTINGS & OPTIONS MENU ]]
-- ============================================================================
FGL["SETTINGS_ADVANCED"] = "進階"
FGL["SETTINGS_ALT_CHARS"] = "分身角色"
FGL["SETTINGS_APPEARANCE"] = "外觀"
FGL["SETTINGS_AUTOMATION"] = "自動化"
FGL["SETTINGS_BEHAVIOR"] = "行為"
FGL["SETTINGS_EXPORT"] = "匯出設定"
FGL["SETTINGS_FILTER"] = "過濾"
FGL["SETTINGS_IMPORT"] = "匯入設定"
FGL["SETTINGS_LAST_BACKUP"] = "上次備份：%s"
FGL["SETTINGS_PROFILE"] = "設定備份"
FGL["SETTINGS_PURGE_CACHE"] = "清除分身快取"
FGL["SETTINGS_RESET"] = "|cffff0000重置為預設值|r"
FGL["SETTINGS_RESET_ORDER"] = "重置群組順序"
FGL["SETTINGS_SIZE"] = "列表大小"
FGL["SETTINGS_TITLE"] = "設定"
FGL["SETTINGS_VERSION"] = "FriendGroups v%s"
FGL["SET_AUTO_ACCEPT"] = "自動接受組隊邀請"
FGL["SET_AUTO_PARTY_SYNC"] = "自動接受隊伍同步"
FGL["SET_CLASS_COLOR"] = "使用職業顏色"
FGL["SET_COLLAPSE"] = "自動折疊群組"
FGL["SET_FACTION_ICONS"] = "顯示陣營圖示"
FGL["SET_FAV_GROUP"] = "啟用最愛群組"
FGL["SET_GRAY_FACTION"] = "灰顯對立陣營"
FGL["SET_GUILDMATES"] = "啟用自動公會隊伍"
FGL["SET_HIDE_AFK"] = "隱藏暫離 (AFK)"
FGL["SET_HIDE_EMPTY"] = "隱藏空群組"
FGL["SET_HIDE_MAX_LEVEL"] = "隱藏滿級等級"
FGL["SET_HIDE_OFFLINE"] = "隱藏離線"
FGL["SET_INGAME_ONLY"] = "僅顯示遊戲內好友"
FGL["SET_KNOWN_ALTS"] = "啟用分身追蹤"
FGL["SET_MOBILE_AFK"] = "標記手機為暫離"
FGL["SET_OFFLINE_TRACKER"] = "啟用離線追蹤群組"
FGL["SET_RETAIL_ONLY"] = "僅顯示正式服好友"
FGL["SET_SHOW_BTAG"] = "僅顯示 BattleTag"
FGL["SET_SHOW_FLAGS"] = "顯示伺服器旗幟"
FGL["SET_SHOW_REALM"] = "顯示伺服器名稱"
FGL["SET_SIZE_LARGE"] = "大"
FGL["SET_SIZE_MEDIUM"] = "中"
FGL["SET_SIZE_SMALL"] = "小 (預設)"
FGL["SET_SPIRIT_HEADER"] = "靈魂行為"
FGL["SET_SPIRIT_RELEASE"] = "自動釋放靈魂"
FGL["SET_SPIRIT_RES"] = "自動接受復活"
FGL["SET_WIDTH_NARROW"] = "窄 (預設)"
FGL["SET_WIDTH_NORMAL"] = "適中"
FGL["SET_WIDTH_WIDE"] = "寬"

-- ============================================================================
-- [[ CONTEXT MENUS ]]
-- ============================================================================
FGL["DROP_ADD"] = "加入群組"
FGL["DROP_ADD_GUILD"] = "加入公會群組"
FGL["DROP_CLEAR_MAIN"] = "清除已選定的本尊"
FGL["DROP_COPY_BTAG"] = "複製 BattleTag"
FGL["DROP_COPY_NAME"] = "複製 名字-伺服器"
FGL["DROP_CREATE"] = "建立新群組"
FGL["DROP_REMOVE"] = "移出群組"
FGL["DROP_REMOVE_GUILD"] = "從公會群組移除"
FGL["DROP_SELECT_MAIN"] = "選定本尊"
FGL["DROP_SET_NICKNAME"] = "設定暱稱"
FGL["DROP_TITLE"] = "FriendGroups"
FGL["MENU_CLEAR_BANNER_COLOR"] = "清除橫幅顏色"
FGL["MENU_INVITE"] = "邀請群組"
FGL["MENU_MAX_40"] = " (上限 40)"
FGL["MENU_MOVE_DOWN"] = "下移"
FGL["MENU_MOVE_UP"] = "上移"
FGL["MENU_REMOVE"] = "|cffff0000移除群組|r"
FGL["MENU_RENAME"] = "重新命名群組"
FGL["MENU_RESET_POSITION"] = "重置位置"
FGL["MENU_SET_BANNER_COLOR"] = "設定橫幅顏色"

-- ============================================================================
-- [[ TOOLTIPS ]]
-- ============================================================================
FGL["TOOLTIP_ALTS_FORMAT"] = "%s %s %s 等級 %d"
FGL["TOOLTIP_ALTS_GUILD_SUFFIX"] = " |cFFFFD100<%s>|r"
FGL["TOOLTIP_ALTS_PUBLIC_TITLE_FORMAT"] = "%s 的已知分身"
FGL["TOOLTIP_ALTS_SEEN"] = "最後出現在 %s - %s"
FGL["TOOLTIP_ALTS_TITLE_FORMAT_MAX"] = "%s 的已知分身（最近 10 個）"
FGL["TOOLTIP_CONTACT_BNET"] = "Battle.net 好友："
FGL["TOOLTIP_CONTACT_DESC"] = "Battle.net 上限為所有遊戲共用。\nWoW 好友為此角色獨有。"
FGL["TOOLTIP_CONTACT_INVITES"] = "待處理的邀請："
FGL["TOOLTIP_CONTACT_INV_DESC"] = "（這會消耗 BNet 空位！）"
FGL["TOOLTIP_CONTACT_TITLE"] = "聯絡人上限"
FGL["TOOLTIP_CONTACT_TOTAL"] = "追蹤的獨立聯絡人總數：%d"
FGL["TOOLTIP_CONTACT_WOW"] = "WoW 角色好友："
FGL["TOOLTIP_CUSTOM_GROUP_DESC_1"] = "若要手動將好友加入此群組，請在好友備註的任意位置輸入 #%s。"
FGL["TOOLTIP_CUSTOM_GROUP_TITLE"] = "自訂群組"
FGL["TOOLTIP_EXPORT_1"] = "將你的設定和資料儲存為完整備份代碼。請妥善保存以便日後復原。"
FGL["TOOLTIP_EXPORT_2"] = "包括外觀、行為、群組順序、橫幅顏色、暱稱和已選主角色。"
FGL["TOOLTIP_GROUP_COLOR_PICKER_NOTE"] = "右鍵點擊此群組標題以選擇自訂橫幅背景顏色。"
FGL["TOOLTIP_GUILD_GROUP_DESC_1"] = "此群組會由你線上的公會成員自動填入。"
FGL["TOOLTIP_GUILD_GROUP_DESC_2"] = "若要手動將好友加入此群組，請在他們的好友備註中輸入 %s。"
FGL["TOOLTIP_GUILD_GROUP_TITLE"] = "自動公會群組"
FGL["TOOLTIP_IMPORT_1"] = "從代碼復原備份。"
FGL["TOOLTIP_IMPORT_2"] = "這將用匯入的設定覆蓋你目前的設定。"
FGL["TOOLTIP_KNOWN_ALTS"] = "追蹤你的戰網好友所玩的角色，並在滑鼠停留時顯示在面板中。關閉將停止追蹤並清除所有已儲存的分身資料。"

-- ============================================================================
-- [[ MESSAGES, POPUPS & DEBUG ]]
-- ============================================================================
FGL["DEBUG_DB_SIZE"] = "資料庫設定檔：%d（私人 BNet 快取）"
FGL["DEBUG_GUID_INDEX"] = "GUID 索引條目: %d"
FGL["DEBUG_HEADER"] = "|cFF33FF99[FriendGroups 診斷遙測]|r"
FGL["DEBUG_HELP"] = "|cFF33FF99FriendGroups：|r 指令: /fg export, /fg import, /fg debug"
FGL["DEBUG_MEM_USAGE"] = "目前插件記憶體：%.2f MB"
FGL["DEBUG_SECRET_GUID"] = "GUID 亦加密/不可用: %d"
FGL["DEBUG_SECRET_HEADER"] = "|cFF33FF99機密值遙測|r"
FGL["DEBUG_SECRET_HITS"] = "機密名稱匹配: %d"
FGL["DEBUG_SECRET_RESOLVED"] = "透過 GUID 索引解析: %d"
FGL["DEBUG_SECRET_UNRESOLVED"] = "未解析 (無索引匹配): %d"
FGL["MSG_ALT_TRACKING_DISABLED"] = "|cFF33FF99FriendGroups|r：分身追蹤已停用。資料庫已清除。"
FGL["MSG_AUTO_ACCEPT_FAILED"] = "|cFF33FF99FriendGroups|r: 暴雪在此區域阻止了自動接受。請手動點擊按鈕。"
FGL["MSG_AUTO_INVITE"] = "|cFF33FF99FriendGroups|r: %s 邀請你加入隊伍。自動接受 |cff00ff00已啟用|r"
FGL["MSG_AUTO_RELEASE"] = "|cFF33FF99FriendGroups|r: 你已經死亡。自動釋放靈魂 |cff00ff00已啟用|r"
FGL["MSG_AUTO_RELEASE_FAILED"] = "|cFF33FF99FriendGroups|r: 暴雪在此區域阻止了自動釋放靈魂。請點擊釋放按鈕。"
FGL["MSG_AUTO_RES"] = "|cFF33FF99FriendGroups|r: %s 正在復活你。自動接受 |cff00ff00已啟用|r"
FGL["MSG_AUTO_SYNC"] = "|cFF33FF99FriendGroups|r: %s 邀請你進行隊伍同步。自動接受 |cff00ff00已啟用|r"
FGL["MSG_IMPORT_FAIL_CHECKSUM"] = "|cFF33FF99FriendGroups|r: 設定代碼已損毀或不完整。"
FGL["MSG_IMPORT_FAIL_FORMAT"] = "|cFF33FF99FriendGroups|r: 這不是有效的設定代碼。"
FGL["MSG_IMPORT_FAIL_PROTOCOL"] = "|cFF33FF99FriendGroups|r: 設定代碼來自不相容的版本。"
FGL["MSG_IMPORT_OK"] = "|cFF33FF99FriendGroups|r: 設定已匯入。"
FGL["MSG_IMPORT_OK_DATED"] = "|cFF33FF99FriendGroups|r: 已復原備份（建立於 %s）。"
FGL["MSG_PURGE_CACHE"] = "|cFF33FF99FriendGroups：|r 分身與公會快取已成功清除。"
FGL["MSG_RESET"] = "|cFF33FF99FriendGroups|r: 設定已重置為預設值。"
FGL["POPUP_COPY"] = "按 Ctrl+C 複製:"
FGL["POPUP_ENTER_NAME"] = "輸入新群組名稱"
FGL["POPUP_ENTER_NICKNAME"] = "為這名好友輸入自訂暱稱："
FGL["POPUP_IMPORT"] = "貼上備份代碼，然後點擊接受。\n較大的備份在復原時可能會使遊戲卡頓最多一分鐘 - 這是正常現象。"

-- ============================================================================
-- [[ LABELS, GROUPS & FORMATS ]]
-- ============================================================================
FGL["FORMAT_AKA_DISPLAY"] = " |cFF949694aka|r %s"
FGL["FORMAT_GUILD_TAG"] = "%s <%s>"
FGL["GROUP_EMPTY"] = "好友列表是空的"
FGL["GROUP_FAVORITES"] = "[最愛]"
FGL["GROUP_GUILDMATES"] = "[公會]"
FGL["GROUP_NONE"] = "[無群組]"
FGL["GROUP_OFFLINE_1"] = "[離線 1 個月]"
FGL["GROUP_OFFLINE_2"] = "[離線 2 個月]"
FGL["GROUP_OFFLINE_3"] = "[離線 3 個月以上]"
FGL["SEARCH_PLACEHOLDER"] = "搜尋 FriendGroups"
FGL["SEARCH_TOOLTIP"] = "FriendGroups: 搜尋任何人！名字、伺服器、職業甚至備註"
FGL["STATUS_MOBILE"] = "手機"
FGL["TEXT_BNET_CONTACTS"] = "%d/%d"
FGL["TIME_DAYS_AGO"] = "%d 天前"
FGL["TIME_HOURS_AGO"] = "%d 小時前"
FGL["TIME_JUST_NOW"] = "剛剛"
FGL["TIME_MINUTES_AGO"] = "%d 分鐘前"
FGL["UNKNOWN"] = "未知"
FGL["UNKNOWN_ZONE"] = "未知區域"

else

FGL["SET_CLASS_ICONS"] = "Show Class Icons"
FGL["SET_SHOW_NOTE"] = "Show Note"
FGL["SET_STATUS_INDICATOR"] = "Show Status Indicator"
FGL["SET_GAME_ICON"] = "Show Game Icon"
FGL["SET_FACTION_COLOR"] = "Use Faction Row Color"
FGL["SET_EUI_SKIN"] = "EllesmereUI Skin"
FGL["SET_EUI_SKIN_TT"] = "Match the EllesmereUI look automatically when the EllesmereUI addon is detected."
FGL["EUI_RELOAD_PROMPT"] = "This change requires a UI reload. Reload now?"

-- ============================================================================
-- [[ SETTINGS & OPTIONS MENU ]]
-- ============================================================================
FGL["SETTINGS_ADVANCED"] = "Advanced"
FGL["SETTINGS_ALT_CHARS"] = "Alt Characters"
FGL["SETTINGS_APPEARANCE"] = "Appearance"
FGL["SETTINGS_AUTOMATION"] = "Automation"
FGL["SETTINGS_BEHAVIOR"] = "Group Behavior"
FGL["SETTINGS_EXPORT"] = "Export Settings"
FGL["SETTINGS_FILTER"] = "Filter"
FGL["SETTINGS_IMPORT"] = "Import Settings"
FGL["SETTINGS_LAST_BACKUP"] = "Last backup: %s"
FGL["SETTINGS_PROFILE"] = "Settings Backup"
FGL["SETTINGS_PURGE_CACHE"] = "Purge Alt Cache"
FGL["SETTINGS_RESET"] = "|cffff0000Reset to Default|r"
FGL["SETTINGS_RESET_ORDER"] = "Reset Group Order"
FGL["SETTINGS_SIZE"] = "Contact List Size"
FGL["SETTINGS_TITLE"] = "Settings"
FGL["SETTINGS_VERSION"] = "FriendGroups v%s"
FGL["SET_AUTO_ACCEPT"] = "Auto Accept Group Invite"
FGL["SET_AUTO_PARTY_SYNC"] = "Auto Accept Party Sync"
FGL["SET_CLASS_COLOR"] = "Use Class Color Names"
FGL["SET_COLLAPSE"] = "Auto Collapse Groups"
FGL["SET_FACTION_ICONS"] = "Show Faction Icons"
FGL["SET_FAV_GROUP"] = "Enable Favorite Friends Group"
FGL["SET_GRAY_FACTION"] = "Dim Opposing Faction"
FGL["SET_GUILDMATES"] = "Enable Auto Guild Groups"
FGL["SET_HIDE_AFK"] = "Hide All AFK"
FGL["SET_HIDE_EMPTY"] = "Hide Empty Groups"
FGL["SET_HIDE_MAX_LEVEL"] = "Hide Max Level"
FGL["SET_HIDE_OFFLINE"] = "Hide All Offline"
FGL["SET_INGAME_ONLY"] = "Show Only In-Game Friends"
FGL["SET_KNOWN_ALTS"] = "Enable Alt Character Tracking"
FGL["SET_MOBILE_AFK"] = "Mark Mobile As AFK"
FGL["SET_OFFLINE_TRACKER"] = "Enable Offline Tracker Groups"
FGL["SET_RETAIL_ONLY"] = "Show Only Retail Friends"
FGL["SET_SHOW_BTAG"] = "Show Only BattleTag"
FGL["SET_SHOW_FLAGS"] = "Show Realm Flags"
FGL["SET_SHOW_REALM"] = "Show Realm Names"
FGL["SET_SIZE_LARGE"] = "Large"
FGL["SET_SIZE_MEDIUM"] = "Medium"
FGL["SET_SIZE_SMALL"] = "Small (WoW Default)"
FGL["SET_SPIRIT_HEADER"] = "Spirit Behavior"
FGL["SET_SPIRIT_RELEASE"] = "Auto Release Spirit"
FGL["SET_SPIRIT_RES"] = "Auto Accept Resurrection"
FGL["SET_WIDTH_NARROW"] = "Narrow (Default)"
FGL["SET_WIDTH_NORMAL"] = "Normal"
FGL["SET_WIDTH_WIDE"] = "Wide"

-- ============================================================================
-- [[ CONTEXT MENUS ]]
-- ============================================================================
FGL["DROP_ADD"] = "Add to Group"
FGL["DROP_ADD_GUILD"] = "Add to Guild Group"
FGL["DROP_CLEAR_MAIN"] = "Clear Selected Main"
FGL["DROP_COPY_BTAG"] = "Copy BattleTag"
FGL["DROP_COPY_NAME"] = "Copy Name-Realm"
FGL["DROP_CREATE"] = "Create New Group"
FGL["DROP_REMOVE"] = "Remove from Group"
FGL["DROP_REMOVE_GUILD"] = "Remove from Guild Group"
FGL["DROP_SELECT_MAIN"] = "Select Main"
FGL["DROP_SET_NICKNAME"] = "Set Nickname"
FGL["DROP_TITLE"] = "FriendGroups"
FGL["MENU_CLEAR_BANNER_COLOR"] = "Clear Banner Color"
FGL["MENU_INVITE"] = "Invite Group to Party"
FGL["MENU_MAX_40"] = " (Max 40)"
FGL["MENU_MOVE_DOWN"] = "Move Down"
FGL["MENU_MOVE_UP"] = "Move Up"
FGL["MENU_REMOVE"] = "|cffff0000Remove Group|r"
FGL["MENU_RENAME"] = "Rename Group"
FGL["MENU_RESET_POSITION"] = "Reset Position"
FGL["MENU_SET_BANNER_COLOR"] = "Set Banner Color"

-- ============================================================================
-- [[ TOOLTIPS ]]
-- ============================================================================
FGL["TOOLTIP_ALTS_FORMAT"] = "%s %s %s Level %d"
FGL["TOOLTIP_ALTS_GUILD_SUFFIX"] = " |cFFFFD100<%s>|r"
FGL["TOOLTIP_ALTS_PUBLIC_TITLE_FORMAT"] = "%s's Known Alt Characters"
FGL["TOOLTIP_ALTS_SEEN"] = "Last seen in %s - %s"
FGL["TOOLTIP_ALTS_TITLE_FORMAT_MAX"] = "%s's Known Alt Characters (Recent 10)"
FGL["TOOLTIP_CONTACT_BNET"] = "Battle.net Friends:"
FGL["TOOLTIP_CONTACT_DESC"] = "Battle.net limits are shared across all games.\nWoW friends are unique to this character."
FGL["TOOLTIP_CONTACT_INVITES"] = "Pending Invites:"
FGL["TOOLTIP_CONTACT_INV_DESC"] = "(These consume BNet slots!)"
FGL["TOOLTIP_CONTACT_TITLE"] = "Contact Limits"
FGL["TOOLTIP_CONTACT_TOTAL"] = "Total unique contacts tracked: %d"
FGL["TOOLTIP_CONTACT_WOW"] = "WoW Character Friends:"
FGL["TOOLTIP_CUSTOM_GROUP_DESC_1"] = "To manually add a friend to this group, type #%s anywhere in their friend note."
FGL["TOOLTIP_CUSTOM_GROUP_TITLE"] = "Custom Group"
FGL["TOOLTIP_EXPORT_1"] = "Saves a full backup of your settings and data as a code. Keep it somewhere safe to restore later."
FGL["TOOLTIP_EXPORT_2"] = "Includes appearance, behavior, group order, banner colors, nicknames and selected mains."
FGL["TOOLTIP_GROUP_COLOR_PICKER_NOTE"] = "Right-click this group header to select a custom background banner color."
FGL["TOOLTIP_GUILD_GROUP_DESC_1"] = "This group is automatically populated by your online guildmates."
FGL["TOOLTIP_GUILD_GROUP_DESC_2"] = "To manually add a friend to this group, type %s anywhere in their friend note."
FGL["TOOLTIP_GUILD_GROUP_TITLE"] = "Auto Guild Group"
FGL["TOOLTIP_IMPORT_1"] = "Restores a backup from a code."
FGL["TOOLTIP_IMPORT_2"] = "This replaces your current settings with the imported ones."
FGL["TOOLTIP_KNOWN_ALTS"] = "Tracks which characters your Battle.net friends play and lists them in a panel on hover. Turning this off disables tracking and clears all stored alt data."

-- ============================================================================
-- [[ MESSAGES, POPUPS & DEBUG ]]
-- ============================================================================
FGL["DEBUG_DB_SIZE"] = "Database Profiles: %d (Private BNet Cache)"
FGL["DEBUG_GUID_INDEX"] = "GUID index entries: %d"
FGL["DEBUG_HEADER"] = "|cFF33FF99[FriendGroups Diagnostic Telemetry]|r"
FGL["DEBUG_HELP"] = "|cFF33FF99FriendGroups:|r Commands: /fg export, /fg import, /fg debug"
FGL["DEBUG_MEM_USAGE"] = "Current Addon Memory: %.2f MB"
FGL["DEBUG_SECRET_GUID"] = "GUID also secret/unavailable: %d"
FGL["DEBUG_SECRET_HEADER"] = "|cFF33FF99Secret-Value Telemetry|r"
FGL["DEBUG_SECRET_HITS"] = "Secret-name encounters: %d"
FGL["DEBUG_SECRET_RESOLVED"] = "Resolved via GUID index: %d"
FGL["DEBUG_SECRET_UNRESOLVED"] = "Unresolved (no index hit): %d"
FGL["MSG_ALT_TRACKING_DISABLED"] = "|cFF33FF99FriendGroups|r: Alt Tracking disabled. Database has been wiped."
FGL["MSG_AUTO_ACCEPT_FAILED"] = "|cFF33FF99FriendGroups|r: Blizzard prevented auto-accept in this zone. Please click the button manually."
FGL["MSG_AUTO_INVITE"] = "|cFF33FF99FriendGroups|r: %s invites you to a group. Auto Accept |cff00ff00ENABLED|r"
FGL["MSG_AUTO_RELEASE"] = "|cFF33FF99FriendGroups|r: You have died. Auto Release |cff00ff00ENABLED|r"
FGL["MSG_AUTO_RELEASE_FAILED"] = "|cFF33FF99FriendGroups|r: Blizzard prevented auto-release in this zone. Please click the release button."
FGL["MSG_AUTO_RES"] = "|cFF33FF99FriendGroups|r: %s is resurrecting you. Auto Accept |cff00ff00ENABLED|r"
FGL["MSG_AUTO_SYNC"] = "|cFF33FF99FriendGroups|r: %s invites you to Party Sync. Auto Accept |cff00ff00ENABLED|r"
FGL["MSG_IMPORT_FAIL_CHECKSUM"] = "|cFF33FF99FriendGroups|r: The settings code is corrupted or incomplete."
FGL["MSG_IMPORT_FAIL_FORMAT"] = "|cFF33FF99FriendGroups|r: That is not a valid settings code."
FGL["MSG_IMPORT_FAIL_PROTOCOL"] = "|cFF33FF99FriendGroups|r: The settings code is from an incompatible version."
FGL["MSG_IMPORT_OK"] = "|cFF33FF99FriendGroups|r: Settings imported."
FGL["MSG_IMPORT_OK_DATED"] = "|cFF33FF99FriendGroups|r: Backup restored (created %s)."
FGL["MSG_PURGE_CACHE"] = "|cFF33FF99FriendGroups|r: Alt and Guild caches have been successfully purged."
FGL["MSG_RESET"] = "|cFF33FF99FriendGroups|r: Settings reset to default."
FGL["POPUP_COPY"] = "Press Ctrl+C to copy:"
FGL["POPUP_ENTER_NAME"] = "Enter new group name"
FGL["POPUP_ENTER_NICKNAME"] = "Enter custom nickname for this friend:"
FGL["POPUP_IMPORT"] = "Paste a backup code, then click Accept.\nA large backup can freeze the game for up to a minute while it restores - this is normal."

-- ============================================================================
-- [[ LABELS, GROUPS & FORMATS ]]
-- ============================================================================
FGL["FORMAT_AKA_DISPLAY"] = " |cFF949694aka|r %s"
FGL["FORMAT_GUILD_TAG"] = "%s <%s>"
FGL["GROUP_EMPTY"] = "Friends List is empty"
FGL["GROUP_FAVORITES"] = "[Favorites]"
FGL["GROUP_GUILDMATES"] = "[Guild]"
FGL["GROUP_NONE"] = "[No Group]"
FGL["GROUP_OFFLINE_1"] = "[Offline 1 Month]"
FGL["GROUP_OFFLINE_2"] = "[Offline 2 Months]"
FGL["GROUP_OFFLINE_3"] = "[Offline 3+ Months]"
FGL["SEARCH_PLACEHOLDER"] = "FriendGroups Search"
FGL["SEARCH_TOOLTIP"] = "FriendGroups: Search for anyone! Name, Realm, Class and even Notes"
FGL["STATUS_MOBILE"] = "Mobile"
FGL["TEXT_BNET_CONTACTS"] = "%d/%d"
FGL["TIME_DAYS_AGO"] = "%d days ago"
FGL["TIME_HOURS_AGO"] = "%d hours ago"
FGL["TIME_JUST_NOW"] = "Just now"
FGL["TIME_MINUTES_AGO"] = "%d minutes ago"
FGL["UNKNOWN"] = "Unknown"
FGL["UNKNOWN_ZONE"] = "Unknown Zone"
end

--[[
	FriendGroups - FULL ORIGINAL CODE + SECURE ARCHITECTURE
    - Uses the exact original logic for groups, menus, and sorting.
    - Adds "Lazy Loading" to prevent VisitHouse() taint.
    - Adds Safety Shield to the House List.
]] --

-- [[ STATE MANAGEMENT ]] --
local FriendGroups_Loaded = false 

-- ============================================================================
-- [[ DIRTY ROSTER ENGINE (IDLE GC CHURN PREVENTER) ]]
-- ============================================================================
local FriendGroups_RosterDirty = true
local fgRosterEventFrame = CreateFrame("Frame")
fgRosterEventFrame:RegisterEvent("BN_FRIEND_INFO_CHANGED")
fgRosterEventFrame:RegisterEvent("BN_FRIEND_LIST_SIZE_CHANGED")
fgRosterEventFrame:RegisterEvent("BN_FRIEND_INVITE_ADDED")
fgRosterEventFrame:RegisterEvent("BN_FRIEND_INVITE_REMOVED")
fgRosterEventFrame:RegisterEvent("FRIENDLIST_UPDATE")
fgRosterEventFrame:RegisterEvent("PLAYER_FLAGS_CHANGED")
-- 12.2.2b: GROUP_ROSTER_UPDATE refreshes visible rows (invite-restriction / travel pass
-- state) but never changes the group layout, so it requests a refresh without flagging
-- the roster dirty.
fgRosterEventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
fgRosterEventFrame:SetScript("OnEvent", function(self, event, ...)
    if event ~= "GROUP_ROSTER_UPDATE" then
        FriendGroups_RosterDirty = true
    end
    -- 12.2.2b: this frame is now the primary refresh driver. In hook mode the spammy
    -- events are unregistered from FriendsFrame entirely (see EnableFriendGroups), so
    -- Blizzard's native FriendsList_Update no longer fires for them and cannot route a
    -- refresh to us. Visibility, combat and dirty guards all apply when the coalescer
    -- fires, so this is behaviour-identical to the pre-12.2.2 override path.
    FriendGroups_RequestListUpdate()
end)

-- ============================================================================
-- [[ MEMORY OPTIMIZATION UTILITIES ]]
-- ============================================================================
local FriendGroups_RealmStringCache = {}

function FriendGroups_CleanRealmName(realmName)
    if type(realmName) ~= "string" or realmName == "" then return "" end
    if FriendGroups_RealmStringCache[realmName] then
        return FriendGroups_RealmStringCache[realmName]
    end
    local cleanRealm = realmName:gsub("[%s%p]+", "")
    FriendGroups_RealmStringCache[realmName] = cleanRealm
    return cleanRealm
end

local FriendGroups_DPElementPool = {}

local function GetDPElement()
    local t = table.remove(FriendGroups_DPElementPool) or {}
    wipe(t)
    return t
end

-- [[ FORWARD DECLARATIONS ]] --
local EnableFriendGroups
local FriendGroups_FriendsListUpdate
local FriendGroups_FriendsListUpdateFriendButton
local FriendGroups_FriendsListButtonTemplateClick
local SetupGroupedView
local FriendGroups_SaveToAltCache

local ADDON_CHAT_PREFIX = "|cffFFE400[|r|cff3AFF00" .. "FriendGroups" .. "|r|cffFFE400]|r"
local function Print(...)
    local str = ADDON_CHAT_PREFIX .. "|cff00F7FF "
    local count = select("#", ...)
    for i = 1, count do
        str = str .. tostring(select(i, ...))
        if i < count then
            str = str .. " "
        end
    end
    DEFAULT_CHAT_FRAME:AddMessage(str .. "|r")
end

local playerRealmID = GetRealmID();
local playerFactionGroup = UnitFactionGroup("player");
local INVITE_RESTRICTION_NONE = 9
local groupsTotal = {}
local groupsSorted = {}
local groupsCount = {}
local expansionMaxLevel = {}
local searchBoxInit = false
local currentExpansionMaxLevel, FriendGroups_Menu, FriendGroupsFrame, searchOpened
local searchValue = ""
local FriendGroups_SearchBox

-- Data for the Custom Menu
local FriendGroups_ClickedData = {}

local settingsMenuItems = {
    -- SECTION 0: SIZE
    { text = FGL["SETTINGS_SIZE"], notCheckable = true, isTitle = true, submenu = true },
    {
        text = FGL["SET_SIZE_SMALL"],
        checked = function() return (ShiGuangDB.FriendGroupsextra_height or 0) == 0 end,
        func = function()
            ShiGuangDB.FriendGroupsextra_height = 0
            FriendGroups_UpdateSize()
        end
    },
    {
        text = FGL["SET_SIZE_MEDIUM"],
        checked = function() return (ShiGuangDB.FriendGroupsextra_height or 0) == 190 end,
        func = function()
            ShiGuangDB.FriendGroupsextra_height = 190
            FriendGroups_UpdateSize()
        end
    },
    {
        text = FGL["SET_SIZE_LARGE"],
        checked = function() return (ShiGuangDB.FriendGroupsextra_height or 0) == 380 end,
        func = function()
            ShiGuangDB.FriendGroupsextra_height = 380
            FriendGroups_UpdateSize()
        end
    },
    -- Horizontal divider separating list length (above) from list width (below).
    { text = "", isTitle = true },
    {
        text = FGL["SET_WIDTH_NARROW"],
        checked = function() return not ShiGuangDB.FriendGroupswide_list and not ShiGuangDB.FriendGroupswidth_normal end,
        func = function()
            ShiGuangDB.FriendGroupswide_list = false
            ShiGuangDB.FriendGroupswidth_normal = false
            FriendGroups_UpdateSize()
            FriendGroups_FriendsListUpdate(true)
        end
    },
    {
        text = FGL["SET_WIDTH_NORMAL"],
        checked = function() return ShiGuangDB.FriendGroupswidth_normal and not ShiGuangDB.FriendGroupswide_list end,
        func = function()
            ShiGuangDB.FriendGroupswide_list = false
            ShiGuangDB.FriendGroupswidth_normal = true
            FriendGroups_UpdateSize()
            FriendGroups_FriendsListUpdate(true)
        end
    },
    {
        text = FGL["SET_WIDTH_WIDE"],
        checked = function() return ShiGuangDB.FriendGroupswide_list end,
        func = function()
            ShiGuangDB.FriendGroupswide_list = true
            ShiGuangDB.FriendGroupswidth_normal = false
            FriendGroups_UpdateSize()
            FriendGroups_FriendsListUpdate(true)
        end
    },

    -- SECTION 1: FILTERS
    { text = FGL["SETTINGS_FILTER"],    notCheckable = true, isTitle = true },
	{
        text = FGL["SET_HIDE_OFFLINE"],
        keepShownOnClick = true, 
        checked = function() return ShiGuangDB.FriendGroupshide_offline end,
        func = function()
            ShiGuangDB.FriendGroupshide_offline = not ShiGuangDB.FriendGroupshide_offline
            if ShiGuangDB.FriendGroupshide_offline then
                ShiGuangDB.FriendGroupsoffline_tracker = false
            end
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_HIDE_AFK"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupshide_afk end,
        func = function()
            ShiGuangDB.FriendGroupshide_afk = not ShiGuangDB.FriendGroupshide_afk
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_MOBILE_AFK"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_mobile_afk end,
        func = function()
            ShiGuangDB.FriendGroupsshow_mobile_afk = not ShiGuangDB.FriendGroupsshow_mobile_afk
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_HIDE_EMPTY"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupshide_empty_groups end,
        func = function()
            ShiGuangDB.FriendGroupshide_empty_groups = not ShiGuangDB.FriendGroupshide_empty_groups
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_INGAME_ONLY"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsingame_only end,
        func = function()
            ShiGuangDB.FriendGroupsingame_only = not ShiGuangDB.FriendGroupsingame_only
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_RETAIL_ONLY"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_retail end,
        func = function()
            ShiGuangDB.FriendGroupsshow_retail = not ShiGuangDB.FriendGroupsshow_retail
            FriendGroups_FriendsListUpdate()
        end
    },

    -- SECTION 2: APPEARANCE
    { text = FGL["SETTINGS_APPEARANCE"], notCheckable = true, isTitle = true },
    {
        text = FGL["SET_CLASS_ICONS"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_class_icons ~= false end,
        func = function()
            ShiGuangDB.FriendGroupsshow_class_icons = not (ShiGuangDB.FriendGroupsshow_class_icons ~= false)
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_CLASS_COLOR"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupscolour_classes end,
        func = function()
            ShiGuangDB.FriendGroupscolour_classes = not ShiGuangDB.FriendGroupscolour_classes
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_STATUS_INDICATOR"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_status ~= false end,
        func = function()
            ShiGuangDB.FriendGroupsshow_status = not (ShiGuangDB.FriendGroupsshow_status ~= false)
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_SHOW_NOTE"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_note ~= false end,
        func = function()
            ShiGuangDB.FriendGroupsshow_note = not (ShiGuangDB.FriendGroupsshow_note ~= false)
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_SHOW_REALM"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_realm end,
        func = function()
            ShiGuangDB.FriendGroupsshow_realm = not ShiGuangDB.FriendGroupsshow_realm
            FriendGroups_FriendsListUpdate()
        end
    },
    --[[{
        text = FGL["SET_SHOW_FLAGS"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_flags end,
        func = function()
            ShiGuangDB.FriendGroupsshow_flags = not ShiGuangDB.FriendGroupsshow_flags
            FriendGroups_FriendsListUpdate()
        end
    },]]
    {
        text = FGL["SET_FACTION_ICONS"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_faction_icons end,
        func = function()
            ShiGuangDB.FriendGroupsshow_faction_icons = not ShiGuangDB.FriendGroupsshow_faction_icons
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_FACTION_COLOR"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_faction_color ~= false end,
        func = function()
            ShiGuangDB.FriendGroupsshow_faction_color = not (ShiGuangDB.FriendGroupsshow_faction_color ~= false)
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_GAME_ICON"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_game_icon ~= false end,
        func = function()
            ShiGuangDB.FriendGroupsshow_game_icon = not (ShiGuangDB.FriendGroupsshow_game_icon ~= false)
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_HIDE_MAX_LEVEL"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupshide_high_level end,
        func = function()
            ShiGuangDB.FriendGroupshide_high_level = not ShiGuangDB.FriendGroupshide_high_level
            FriendGroups_FriendsListUpdate()
        end
    },

    -- SECTION 3: GROUP SETTINGS
    { text = FGL["SETTINGS_BEHAVIOR"], notCheckable = true, isTitle = true, submenu = true },
    {
        text = FGL["SET_FAV_GROUP"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsadd_favorite_group end,
        func = function()
            ShiGuangDB.FriendGroupsadd_favorite_group = not ShiGuangDB.FriendGroupsadd_favorite_group
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_GUILDMATES"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_guildmates end,
        func = function()
            ShiGuangDB.FriendGroupsshow_guildmates = not ShiGuangDB.FriendGroupsshow_guildmates
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_COLLAPSE"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsopen_one_group end,
        func = function()
            ShiGuangDB.FriendGroupsopen_one_group = not ShiGuangDB.FriendGroupsopen_one_group
            FriendGroups_FriendsListUpdate()
        end
    },
	{
        text = FGL["SET_OFFLINE_TRACKER"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsoffline_tracker end,
        func = function()
            ShiGuangDB.FriendGroupsoffline_tracker = not ShiGuangDB.FriendGroupsoffline_tracker
            if ShiGuangDB.FriendGroupsoffline_tracker then
                ShiGuangDB.FriendGroupshide_offline = false
                ShiGuangDB.FriendGroupshide_afk = false
                ShiGuangDB.FriendGroupsshow_mobile_afk = false
                ShiGuangDB.FriendGroupshide_empty_groups = false
                ShiGuangDB.FriendGroupsingame_only = false
                ShiGuangDB.FriendGroupsshow_retail = false
            end
            FriendGroups_FriendsListUpdate()
        end
    },
	
    -- [[ Everything below moves into the "Advanced" submenu (back-end config). ]]
    { isAdvancedStart = true },

	-- SECTION 4: AUTOMATION
    { text = FGL["SETTINGS_AUTOMATION"], notCheckable = true, isTitle = true },
    {
        text = FGL["SET_AUTO_ACCEPT"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsauto_accept_invite end,
        func = function()
            ShiGuangDB.FriendGroupsauto_accept_invite = not ShiGuangDB.FriendGroupsauto_accept_invite
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_AUTO_PARTY_SYNC"],
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsauto_accept_sync end,
        func = function()
            ShiGuangDB.FriendGroupsauto_accept_sync = not ShiGuangDB.FriendGroupsauto_accept_sync
            FriendGroups_FriendsListUpdate()
        end
    },
    { text = "  " .. FGL["SET_SPIRIT_HEADER"], notCheckable = true, isTitle = true },
    {
        text = FGL["SET_SPIRIT_RES"],
        leftPadding = 16,
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsauto_accept_res end,
        func = function()
            ShiGuangDB.FriendGroupsauto_accept_res = not ShiGuangDB.FriendGroupsauto_accept_res
            if ShiGuangDB.FriendGroupsauto_accept_res then
                ShiGuangDB.FriendGroupsauto_release = false -- Prevent conflicts
            end
            FriendGroups_FriendsListUpdate()
        end
    },
    {
        text = FGL["SET_SPIRIT_RELEASE"],
        leftPadding = 16,
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsauto_release end,
        func = function()
            ShiGuangDB.FriendGroupsauto_release = not ShiGuangDB.FriendGroupsauto_release
            if ShiGuangDB.FriendGroupsauto_release then
                ShiGuangDB.FriendGroupsauto_accept_res = false -- Prevent conflicts
            end
            FriendGroups_FriendsListUpdate()
        end
    },
    
    -- SECTION 5: ALT CHARACTERS & CACHE
    { text = FGL["SETTINGS_ALT_CHARS"], notCheckable = true, isTitle = true },
    {
        text = FGL["SET_KNOWN_ALTS"],
        tooltip = { FGL["TOOLTIP_KNOWN_ALTS"] },
        keepShownOnClick = true,
        checked = function() return ShiGuangDB.FriendGroupsshow_known_alts ~= false end,
        func = function()
            ShiGuangDB.FriendGroupsshow_known_alts = not (ShiGuangDB.FriendGroupsshow_known_alts ~= false)
            if not ShiGuangDB.FriendGroupsshow_known_alts then
                if FriendGroupsAltTooltip then 
                    FriendGroupsAltTooltip:Hide() 
                end
                if ShiGuangDB.FriendGroupsalt_cache then 
                    wipe(ShiGuangDB.FriendGroupsalt_cache) 
                end
                if ShiGuangDB.FriendGroupsguid_index then 
                    wipe(ShiGuangDB.FriendGroupsguid_index) 
                end
                if FGL["MSG_ALT_TRACKING_DISABLED"] then
                    print(FGL["MSG_ALT_TRACKING_DISABLED"])
                end
            end
        end
    },
	{
        text = FGL["SETTINGS_PURGE_CACHE"],
        notCheckable = true,
		func = function()
            -- 1. Purge Known Alts Cache (Private BNet)
            if ShiGuangDB.FriendGroupsalt_cache then
                wipe(ShiGuangDB.FriendGroupsalt_cache)
            end
            
            -- 2. Purge Auto-Guild Caches & Known Guilds Memory
            if ShiGuangDB.FriendGroupsalt_guild_rosters then
                wipe(ShiGuangDB.FriendGroupsalt_guild_rosters)
            end
            if ShiGuangDB.FriendGroupsbnet_guild_map then
                wipe(ShiGuangDB.FriendGroupsbnet_guild_map)
            end
            if ShiGuangDB.FriendGroupsknown_player_guilds then
                wipe(ShiGuangDB.FriendGroupsknown_player_guilds)
            end
            
            -- Hide static panel if it is currently open
            if FriendGroupsAltTooltip then
                FriendGroupsAltTooltip:Hide()
            end
            
            -- 3. Force the native guild listener to flag the cache as dirty
            if C_GuildInfo and C_GuildInfo.GuildRoster then
                C_GuildInfo.GuildRoster()
            end
            
            print(FGL["MSG_PURGE_CACHE"])
        end
    },

    --[[ SECTION: PROFILE SYNC
    { text = FGL["SETTINGS_PROFILE"], notCheckable = true, isTitle = true },
    {
        text = FGL["SETTINGS_EXPORT"],
        notCheckable = true,
        tooltip = { FGL["TOOLTIP_EXPORT_1"], FGL["TOOLTIP_EXPORT_2"] },
        func = function() FriendGroups_ShowExport() end
    },
    {
        text = FGL["SETTINGS_IMPORT"],
        notCheckable = true,
        tooltip = { FGL["TOOLTIP_IMPORT_1"], FGL["TOOLTIP_IMPORT_2"] },
        func = function() StaticPopup_Show("FRIENDGROUPS_IMPORT") end
    },

    -- SECTION 6: SYSTEM & RESET
    { text = "", notCheckable = true, isTitle = true },
    {
        text = FGL["SETTINGS_RESET_ORDER"],
        notCheckable = true,
        func = function()
            if ShiGuangDB.FriendGroupsgroup_order then
                wipe(ShiGuangDB.FriendGroupsgroup_order)
            end
            FriendGroups_FriendsListUpdate()
        end
    },
	{
        text = FGL["SETTINGS_RESET"],
        notCheckable = true,
        func = function()
            CloseDropDownMenus()
            ShiGuangDB.FriendGroupshide_offline = true
            ShiGuangDB.FriendGroupscolour_classes = true
            ShiGuangDB.FriendGroupsshow_class_icons = true
            ShiGuangDB.FriendGroupsshow_note = true
            ShiGuangDB.FriendGroupsshow_status = true
            ShiGuangDB.FriendGroupsshow_faction_icons = true
            ShiGuangDB.FriendGroupsshow_faction_color = true
            ShiGuangDB.FriendGroupsshow_game_icon = true
            ShiGuangDB.FriendGroupsshow_realm = true
            ShiGuangDB.FriendGroupshide_high_level = false
            ShiGuangDB.FriendGroupsadd_favorite_group = true
            ShiGuangDB.FriendGroupsshow_guildmates = false
            ShiGuangDB.FriendGroupsgray_faction = false
            ShiGuangDB.FriendGroupsshow_mobile_afk = false
            ShiGuangDB.FriendGroupsadd_mobile_text = true
            ShiGuangDB.FriendGroupsingame_only = true
            ShiGuangDB.FriendGroupsingame_retail = false
            ShiGuangDB.FriendGroupsshow_btag = false
            ShiGuangDB.FriendGroupsshow_retail = false
            ShiGuangDB.FriendGroupsshow_search = false
            ShiGuangDB.FriendGroupshide_empty_groups = true
            ShiGuangDB.FriendGroupshide_afk = false
            ShiGuangDB.FriendGroupsopen_one_group = false
            ShiGuangDB.FriendGroupsauto_accept_invite = false
            ShiGuangDB.FriendGroupsauto_accept_sync = false
            ShiGuangDB.FriendGroupsauto_accept_res = false
            ShiGuangDB.FriendGroupsauto_release = false
            ShiGuangDB.FriendGroupsoffline_tracker = true
            ShiGuangDB.FriendGroupsshow_known_alts = true
            ShiGuangDB.FriendGroupsextra_height = 190 
            ShiGuangDB.FriendGroupswide_list = false
            ShiGuangDB.FriendGroupswidth_normal = false
            ShiGuangDB.FriendGroupscollapsed = {}
            ShiGuangDB.FriendGroupsgroup_order = {}
            
            FriendGroups_UpdateSize()
            FriendGroups_UpdateContactCap()
            FriendGroups_FriendsListUpdate()
            
            print(FGL["MSG_RESET"])
        end
    },]]
}

--[[
	Init Values
]] --

currentExpansionMaxLevel = GetMaxLevelForPlayerExpansion and GetMaxLevelForPlayerExpansion() or GetMaxLevelForExpansionLevel and GetMaxLevelForExpansionLevel(GetExpansionLevel()) or 80

-- ============================================================================
-- [[ GUILD CACHE ENGINE (OPTIMIZED: LAZY HEARTBEAT & FINGERPRINTING) ]]
-- ============================================================================
local FriendGroups_GuildCacheDirty = true
local FriendGroups_PlayerGuildName = ""
local FriendGroups_LiveGuildSessionDict = {} -- [[ NEW: Fast O(1) Memory-Safe Lookup ]]

local fgGuildEventFrame = CreateFrame("Frame")
fgGuildEventFrame:RegisterEvent("GUILD_ROSTER_UPDATE")
fgGuildEventFrame:RegisterEvent("PLAYER_GUILD_UPDATE")
fgGuildEventFrame:SetScript("OnEvent", function()
    -- [[ DIRTY FLAG BUCKET: Ignore event spam, just flip the switch ]]
    FriendGroups_GuildCacheDirty = true
end)

function FG_IsCharOnline(charKey)
    if not charKey then return false end
    
    local cName = strsplit("-", charKey)
    local wowFriend = C_FriendList.GetFriendInfo(cName)
    if wowFriend and wowFriend.connected then return true end
    
    local numBNetTotal = C_BattleNet and C_BattleNet.GetFriendNum and C_BattleNet.GetFriendNum() or BNGetNumFriends and BNGetNumFriends() or 0
    if numBNetTotal > 0 then
        for i = 1, numBNetTotal do
            local accountInfo = C_BattleNet.GetFriendAccountInfo(i)
            if accountInfo and accountInfo.gameAccountInfo and accountInfo.gameAccountInfo.isOnline then
                if accountInfo.gameAccountInfo.clientProgram == BNET_CLIENT_WOW then
                    local bName = accountInfo.gameAccountInfo.characterName or ""
                    local bRealm = FriendGroups_CleanRealmName(accountInfo.gameAccountInfo.realmName or accountInfo.gameAccountInfo.realmDisplayName or "")
                    if (bName .. "-" .. bRealm) == charKey then return true end
                end
            end
        end
    end
    return false
end

function FriendGroups_BuildGuildCache()
    if not FriendGroups_GuildCacheDirty then return end
    
    if not ShiGuangDB.FriendGroupsknown_player_guilds then
        ShiGuangDB.FriendGroupsknown_player_guilds = {}
    end
    
    local guildName = GetGuildInfo("player")
    FriendGroups_PlayerGuildName = (type(guildName) == "string") and guildName or ""
    
    if FriendGroups_PlayerGuildName ~= "" then
        ShiGuangDB.FriendGroupsknown_player_guilds[FriendGroups_PlayerGuildName] = true
        wipe(FriendGroups_LiveGuildSessionDict)
        
        -- Fallback chain: Modern C_ API first, legacy global second
        local numTotal = (C_GuildInfo and C_GuildInfo.GetNumGuildMembers) and C_GuildInfo.GetNumGuildMembers() or GetNumGuildMembers()
        local myRealm = GetNormalizedRealmName()
        
        for i = 1, numTotal do
            -- Safe capture of the first return value, supporting both future tables and legacy multi-var returns
            local name = (C_GuildInfo and C_GuildInfo.GetGuildRosterInfo) and C_GuildInfo.GetGuildRosterInfo(i) or GetGuildRosterInfo(i)
            
            if name and type(name) == "string" then
                local baseName, realmName = strsplit("-", name)
                if baseName then
                    local targetRealm = realmName or myRealm
                    if targetRealm and type(targetRealm) == "string" then
                        local cleanRealm = FriendGroups_CleanRealmName(targetRealm)
                        local uniqueKey = baseName .. "-" .. cleanRealm
                        
                        FriendGroups_LiveGuildSessionDict[uniqueKey] = true
                        FriendGroups_LiveGuildSessionDict[baseName] = true
                    end
                end
            end
        end
    else
        wipe(FriendGroups_LiveGuildSessionDict)
    end
    
    if ShiGuangDB.FriendGroupsalt_guild_rosters then ShiGuangDB.FriendGroupsalt_guild_rosters = nil end
    if ShiGuangDB.FriendGroupsbnet_guild_map then ShiGuangDB.FriendGroupsbnet_guild_map = nil end
    
    FriendGroups_GuildCacheDirty = false
end

-- [[ THE LAZY HEARTBEAT ]]
local function FriendGroups_LazyGuildScanner()
    if FriendGroups_GuildCacheDirty then
        FriendGroups_BuildGuildCache()
    end
    C_Timer.After(180, FriendGroups_LazyGuildScanner)
end
C_Timer.After(5, FriendGroups_LazyGuildScanner)


--[[
	Helper Functions
]] --

function FriendGroups_DebugLog(tData, strName)
	if not DevTool then
		LoadAddOn("DevTool")
	end
	if DevTool then
		DevTool:AddData(tData, strName)
	end
end

local FriendGroups_OriginalHeight = nil
local FriendGroupsList_OriginalHeight = nil
local FriendGroups_OriginalWidth = nil
local FriendGroupsList_OriginalWidth = nil

-- Fixed amount (in pixels) the contacts window grows by when "Widen Contact List" is on.
-- Sized to comfortably fit a full "aka [MainName]" suffix that the standard width clips.
FriendGroups_WideListExtra = 150

function FriendGroups_UpdateSize()
    -- 1. Store the original Blizzard defaults the very first time we run
    if not FriendGroups_OriginalHeight then
        FriendGroups_OriginalHeight = FriendsFrame:GetHeight()
        FriendGroupsList_OriginalHeight = FriendsListFrame:GetHeight()
    end
    if not FriendGroups_OriginalWidth then
        FriendGroups_OriginalWidth = FriendsFrame:GetWidth()
        FriendGroupsList_OriginalWidth = FriendsListFrame:GetWidth()
    end

    -- 2. Determine target height/width based on saved variables
    local extra = ShiGuangDB.FriendGroupsextra_height or 0
    -- Width: Narrow (0) / Normal (half) / Wide (full). Two booleans encode the
    -- three states; wide_list takes priority over width_normal.
    local extraWidth = 0
    if ShiGuangDB.FriendGroupswide_list then
        extraWidth = FriendGroups_WideListExtra
    elseif ShiGuangDB.FriendGroupswidth_normal then
        extraWidth = math.floor(FriendGroups_WideListExtra / 2)
    end

    -- 3. Apply
    FriendsFrame:SetHeight(FriendGroups_OriginalHeight + extra)
    FriendsListFrame:SetHeight(FriendGroupsList_OriginalHeight + extra)
    FriendsFrame:SetWidth(FriendGroups_OriginalWidth + extraWidth)
    FriendsListFrame:SetWidth(FriendGroupsList_OriginalWidth + extraWidth)

    -- 4. Re-anchor ScrollBox to fill the new space
    FriendsListFrame.ScrollBox:ClearAllPoints()
    FriendsListFrame.ScrollBox:SetPoint("TOPLEFT", FriendsListFrame, "TOPLEFT", 7, -115)
    FriendsListFrame.ScrollBox:SetPoint("BOTTOMRIGHT", FriendsListFrame, "BOTTOMRIGHT", -28, 35)
end

function FriendGroups_Rename(self, oldGroup)
	local input = self:GetEditBox():GetText()
    
	if input == "" or not oldGroup then return end

	-- Migrate any manual ordering rank so the renamed group keeps its position.
	if ShiGuangDB.FriendGroupsgroup_order and ShiGuangDB.FriendGroupsgroup_order[oldGroup] ~= nil then
		ShiGuangDB.FriendGroupsgroup_order[input] = ShiGuangDB.FriendGroupsgroup_order[oldGroup]
		ShiGuangDB.FriendGroupsgroup_order[oldGroup] = nil
	end

	local groups = {}
    -- Modern API prioritization for total friends
    local numBNetTotal = C_BattleNet.GetFriendNum and C_BattleNet.GetFriendNum() or BNGetNumFriends()
    
	for i = 1, numBNetTotal do
        local accountInfo = C_BattleNet.GetFriendAccountInfo(i)
        if accountInfo then
            local presenceID = accountInfo.bnetAccountID
            local noteText = accountInfo.note
            local note = FriendGroups_NoteAndGroups(noteText, groups)
            if groups[oldGroup] then
                groups[oldGroup] = nil
                groups[input] = true
                note = FriendGroups_CreateNote(note, groups)
                
                -- Modern API prioritization for setting notes
                if C_BattleNet.SetFriendNote then
                    C_BattleNet.SetFriendNote(presenceID, note)
                else
                    BNSetFriendNote(presenceID, note)
                end
            end
        end
	end
    
    local numWoWTotal = C_FriendList.GetNumFriends and C_FriendList.GetNumFriends() or 0
	for i = 1, numWoWTotal do
		local friendInfo = C_FriendList.GetFriendInfoByIndex(i)
        if friendInfo then
            local note = friendInfo.notes
            local name = friendInfo.name
            note = FriendGroups_NoteAndGroups(note, groups)

            if groups[oldGroup] then
                groups[oldGroup] = nil
                groups[input] = true
                note = FriendGroups_CreateNote(note, groups)
                C_FriendList.SetFriendNotes(name, note)
            end
        end
	end
	FriendGroups_FriendsListUpdate()
end

function FriendGroups_InviteOrGroup(groupName, invite)
    if invite then
        -- Use the live UI cache so we only invite exactly who is visible in the group
        if not groupsTotal[groupName] then return end

        local inviteDelay = 0
        
        for _, playerData in ipairs(groupsTotal[groupName]) do
            local id = playerData.id
            local buttonType = playerData.buttonType

            if buttonType == FRIENDS_BUTTON_TYPE_BNET then
                local friendAccountInfo = C_BattleNet.GetFriendAccountInfo(id)
                if friendAccountInfo and friendAccountInfo.gameAccountInfo then
                    local gameAccountInfo = friendAccountInfo.gameAccountInfo
                    
                    -- Strictly verify they are online and playing Retail WoW to prevent silent API fails
                    if gameAccountInfo.isOnline and gameAccountInfo.clientProgram == BNET_CLIENT_WOW and gameAccountInfo.wowProjectID == WOW_PROJECT_MAINLINE then
                        local gameAccountID = gameAccountInfo.gameAccountID
                        if gameAccountID then
                            C_Timer.After(inviteDelay, function()
                                BNInviteFriend(gameAccountID)
                            end)
                            inviteDelay = inviteDelay + 0.2
                        end
                    end
                end
            elseif buttonType == FRIENDS_BUTTON_TYPE_WOW then
                local friend_info = C_FriendList.GetFriendInfoByIndex(id)
                if friend_info and friend_info.connected and friend_info.name then
                    local charName = friend_info.name
                    C_Timer.After(inviteDelay, function()
                        C_PartyInfo.InviteUnit(charName)
                    end)
                    inviteDelay = inviteDelay + 0.2
                end
            end
        end
    else
        -- Removing a group still relies on text parsing to strip the hashtag out of the raw notes
        local groups = {}

        local numBNetTotal = C_BattleNet and C_BattleNet.GetFriendNum and C_BattleNet.GetFriendNum() or BNGetNumFriends()
        for i = 1, numBNetTotal do
            local friendAccountInfo = C_BattleNet.GetFriendAccountInfo(i)
            if friendAccountInfo then
                local presenceID = friendAccountInfo.bnetAccountID
                local noteText = friendAccountInfo.note
                local note = FriendGroups_NoteAndGroups(noteText, groups)
                if groups[groupName] then
                    groups[groupName] = nil
                    note = FriendGroups_CreateNote(note, groups)
                    if C_BattleNet and C_BattleNet.SetFriendNote then
                        C_BattleNet.SetFriendNote(presenceID, note)
                    else
                        BNSetFriendNote(presenceID, note)
                    end
                end
            end
        end
        for i = 1, C_FriendList.GetNumFriends() do
            local friend_info = C_FriendList.GetFriendInfoByIndex(i)
            if friend_info then
                local name = friend_info.name
                local noteText = friend_info.notes
                local note = FriendGroups_NoteAndGroups(noteText, groups)

                if groups[groupName] then
                    groups[groupName] = nil
                    note = FriendGroups_CreateNote(note, groups)
                    C_FriendList.SetFriendNotes(name, note)
                end
            end
        end
    end
end

function FriendGroups_AddGroup(note, group)
	local groups = {}
	note = FriendGroups_NoteAndGroups(note, groups)
	groups[""] = nil
	groups[group] = true
	return FriendGroups_CreateNote(note, groups)
end

-- Add a manual <GuildName> tag to a note. Placed in the free-text portion (before the
-- first # hashtag) so it isn't swallowed into a group name. No-op if already present.
function FriendGroups_AddGuildTag(note, guildName)
	note = (type(note) == "string") and note or ""
	if not guildName or guildName == "" then return note end
	local tag = "<" .. guildName .. ">"
	if note:find(tag, 1, true) then return note end
	if note == "" then return tag end
	return tag .. " " .. note
end

-- Strip a manual <GuildName> tag (and one adjacent space) from a note. Plain-text
-- matching so guild names with magic characters are handled safely.
function FriendGroups_RemoveGuildTag(note, guildName)
	note = (type(note) == "string") and note or ""
	if not guildName or guildName == "" then return note end
	local tag = "<" .. guildName .. ">"
	local s, e = note:find(tag, 1, true)
	while s do
		-- Consume one adjacent space (prefer trailing, since AddGuildTag puts it there).
		if note:sub(e + 1, e + 1) == " " then
			e = e + 1
		elseif note:sub(s - 1, s - 1) == " " then
			s = s - 1
		end
		note = note:sub(1, s - 1) .. note:sub(e + 1)
		s, e = note:find(tag, 1, true)
	end
	return note
end

function FriendGroups_Create(self, data)
	local input = self:GetEditBox():GetText()
	if input == "" then
		return
	end
	local note = FriendGroups_AddGroup(data.note, input)
	if data.name then
		data.set(data.name, note)
	else
		data.set(data.id, note)

		ShiGuangDB.FriendGroupscollapsed[input] = true
	end
end

function FriendGroups_NoteAndGroups(note, groups)
    -- 12.0 FIX: Secure note string check to prevent strsplit crash
    if type(note) ~= "string" then note = "" end

	if not note or note == "" then
		return FriendGroups_FillGroups(groups, "")
	end
	if groups then
		return FriendGroups_FillGroups(groups, strsplit("#", note))
	end
	return strsplit("#", note)
end

function FriendGroups_RemoveGroup(note, group)
	local groups = {}
	note = FriendGroups_NoteAndGroups(note, groups)
	groups[""] = nil
	groups[group] = nil

	return FriendGroups_CreateNote(note, groups)
end

function FriendGroups_CreateNote(note, groups)
	local value = ""
	if note then
		value = note
	end
	for group in pairs(groups) do
		value = value .. "#" .. group
	end
	return value
end

function FriendGroups_FillGroups(groups, note, ...)
	wipe(groups)
	local n = select('#', ...)
	for i = 1, n do
		local v = select(i, ...)
		v = strtrim(v)
		groups[v] = true
	end
	if n == 0 then
		groups[""] = true
	end
	return note
end

function FriendGroups_HasValue(tab, val)
	for index, value in ipairs(tab) do
		if value == val then
			return true
		end
	end

	return false
end

function FriendGroups_SplitBattleTag(battleTag)
	if type(battleTag) ~= "string" then return battleTag end

	local sep = "#"
	if sep == nil then
		sep = "%s"
	end
	local t = {}
	for str in string.gmatch(battleTag, "([^" .. sep .. "]+)") do
		table.insert(t, str)
	end
	return t[1]
end

function FriendGroups_GetClassColorCode(class, returnTable)
	if not class then
		return returnTable and FRIENDS_GRAY_COLOR or
			string.format("|cFF%02x%02x%02x", FRIENDS_GRAY_COLOR.r * 255, FRIENDS_GRAY_COLOR.g * 255,
				FRIENDS_GRAY_COLOR.b * 255)
	end

	local initialClass = class
	for k, v in pairs(LOCALIZED_CLASS_NAMES_FEMALE) do
		if class == v then
			class = k
			break
		end
	end

	if class == initialClass then
		for k, v in pairs(LOCALIZED_CLASS_NAMES_MALE) do
			if class == v then
				class = k
				break
			end
		end
	end

	local color = class ~= "" and RAID_CLASS_COLORS[class] or FRIENDS_GRAY_COLOR
	if returnTable then
		return color
	else
		return string.format("|cFF%02x%02x%02x", color.r * 255, color.g * 255, color.b * 255)
	end
end

FriendGroups_ActiveAKA = {}

function FriendGroups_GetBNetButtonNameText(accountName, client, canCoop, characterName, class, level, battleTag, timerunningSeasonID, realmName)
	local nameText

	-- set up player name and character name
	local accountIdentifier = battleTag or accountName
	if ShiGuangDB.FriendGroupsnicknames and accountIdentifier and ShiGuangDB.FriendGroupsnicknames[accountIdentifier] then
		nameText = "|cFF00FF00" .. ShiGuangDB.FriendGroupsnicknames[accountIdentifier] .. "|r"
	elseif accountName then
		if ShiGuangDB.FriendGroupsshow_btag and battleTag then
			nameText = FriendGroups_SplitBattleTag(battleTag)
		else
			nameText = accountName
		end
	else
		nameText = UNKNOWN
	end

	-- append character name
	if characterName then
		if timerunningSeasonID then
			characterName = TimerunningUtil.AddSmallIcon(characterName)
		end

		local levelSuffix
		if (not level) or (ShiGuangDB.FriendGroupshide_high_level and level == currentExpansionMaxLevel) or level == 0 then
			levelSuffix = ""
		else
			levelSuffix = level .. " "  --" " .. level
		end

        -- [[ NEW: Separate AKA string generation (Outside of Suffix Brackets) ]]
        local akaText = ""
        local accountIdentifier = battleTag or accountName
        if accountIdentifier and FriendGroups_ActiveAKA and FriendGroups_ActiveAKA[accountIdentifier] then
            local akaInfo = FriendGroups_ActiveAKA[accountIdentifier]
            -- Only render the AKA if they are not actively logged into the main character.
            -- The match must compare BOTH name and realm: a friend can have a same-named
            -- character on a different realm (e.g. main Snarge-Area52 vs. current
            -- Snarge-Proudmoore), and a name-only check would wrongly hide the aka there.
            local onMainCharacter = (akaInfo.name == characterName)
            if onMainCharacter and akaInfo.realm and realmName and realmName ~= "" then
                onMainCharacter = (FriendGroups_CleanRealmName(akaInfo.realm) == FriendGroups_CleanRealmName(realmName))
            end
            if akaInfo.name and not onMainCharacter then
                local akaFormattedName = "[" .. akaInfo.name .. "]"
                
                -- Apply class or faction colors strictly to the name inside the AKA brackets
                if ShiGuangDB.FriendGroupscolour_classes and akaInfo.class then
                    akaFormattedName = FriendGroups_GetClassColorCode(akaInfo.class) .. akaFormattedName .. FONT_COLOR_CODE_CLOSE
                elseif not canCoop and ShiGuangDB.FriendGroupsgray_faction then
                    akaFormattedName = "|cFF949694" .. akaFormattedName .. "|r"
                end
                
                if FGL["FORMAT_AKA_DISPLAY"] then
                    akaText = string.format(FGL["FORMAT_AKA_DISPLAY"], akaFormattedName)
                end
            end
        end

		if client == BNET_CLIENT_WOW then
			if characterName ~= "" and level ~= 0 then
				if not canCoop and ShiGuangDB.FriendGroupsgray_faction then
					nameText = "|CFF949694" .. nameText .. " [" .. levelSuffix .. characterName .. "]" .. "|r"
				elseif ShiGuangDB.FriendGroupscolour_classes then
					local nameColor = FriendGroups_GetClassColorCode(class)
					nameText = nameText .. " " .. nameColor .. "[" .. levelSuffix.. characterName .. "]"  .. FONT_COLOR_CODE_CLOSE
				else
					nameText = nameText .. " [" .. levelSuffix .. characterName .. "]" .. FONT_COLOR_CODE_CLOSE
				end
			end
		else
			if ENABLE_COLORBLIND_MODE == "1" then
				characterName = characterName
			end
			nameText = nameText .. " " .. FRIENDS_OTHER_NAME_COLOR_CODE .. "[" .. levelSuffix .. "]" .. characterName .. FONT_COLOR_CODE_CLOSE
		end
        
        -- [[ Append the newly formatted AKA text outside the main brackets ]]
        nameText = nameText .. akaText
	end

	return nameText
end

-- ============================================================================
-- [[ MEMORY OPTIMIZATION & CACHING ]]
-- ============================================================================
local FriendGroups_TablePool = {}
local FriendGroups_NoteCache = { [""] = {} }
local FriendGroups_WorkingGroupTable = {}

local function FG_GetTable()
    local t = table.remove(FriendGroups_TablePool) or {}
    wipe(t)
    return t
end

local function FG_ReleaseTable(t)
    if type(t) == "table" then
        wipe(t)
        table.insert(FriendGroups_TablePool, t)
    end
end

local FriendGroups_NoteCacheCount = 0

function FriendGroups_GetPlayerGroups(note)
    -- 12.0 FIX: Secure note string check to prevent string.match crash
    if type(note) ~= "string" then note = "" end
    
    if note == "" then 
        return FriendGroups_NoteCache[""] 
    end

    if FriendGroups_NoteCache[note] then
        return FriendGroups_NoteCache[note]
    end

    -- Safety Flush: Prevent infinite memory bloat from external note-sync addons
    if FriendGroups_NoteCacheCount > 1500 then
        wipe(FriendGroups_NoteCache)
        FriendGroups_NoteCache[""] = {}
        FriendGroups_NoteCacheCount = 0
    end

    local groups = {}
    local formattedNote = string.match(note, "#.*")

    if formattedNote then
        for s in string.gmatch(formattedNote, "[^#]+") do
            table.insert(groups, s)
        end
    end

    FriendGroups_NoteCache[note] = groups
    FriendGroups_NoteCacheCount = FriendGroups_NoteCacheCount + 1
    
    return groups
end

function FriendGroups_GetPlayerData(friendsListData, playerId, playerType)
	for _, playerData in pairs(friendsListData) do
		if playerData and playerData.id and playerData.id == playerId and playerData.buttonType and playerData.buttonType == playerType then
			return playerData
		end
	end

	return nil
end

function FriendGroups_ShowRichPresenceOnly(client, wowProjectID, faction, realmID, areaName)
	if (client ~= BNET_CLIENT_WOW) or (wowProjectID ~= WOW_PROJECT_ID) then
		return true;
	elseif (WOW_PROJECT_ID == WOW_PROJECT_CLASSIC) and ((faction ~= playerFactionGroup) or (realmID ~= playerRealmID)) then
		return true;
	else
		-- Display Rich Presence as a safety fallback if the areaName is explicitly blank or nil.
        -- This accurately catches Plunderstorm, Character Select screens, and transitional loading states.
		if (client == BNET_CLIENT_WOW) and (wowProjectID == WOW_PROJECT_ID) then
			if not areaName or areaName == "" then
				return true;
			end
		end

		return false;
	end;
end

function FriendGroups_GetOnlineInfoText(client, isMobile, rafLinkType, locationText, realmText)
	if not locationText then
		return UNKNOWN;
	end

	if isMobile then
		return LOCATION_MOBILE_APP;
	end
	if (client == BNET_CLIENT_WOW) and (rafLinkType ~= Enum.RafLinkType.None) and not isMobile then
		if rafLinkType == Enum.RafLinkType.Recruit then
			return RAF_RECRUIT_FRIEND:format(locationText);
		else
			return RAF_RECRUITER_FRIEND:format(locationText);
		end
	end

	if ShiGuangDB.FriendGroupsshow_realm and realmText and realmText ~= "" then
		locationText = locationText .. " - " .. realmText
	end

	return locationText;
end

function FriendGroups_GetFriendInfoById(id)
	local accountName, characterName, class, level, isFavoriteFriend, isOnline,
	bnetAccountId, client, canCoop, wowProjectID, lastOnline,
	isAFK, isGameAFK, isDND, isGameBusy, mobile, zoneName, battleTag, factionName,
	gameText, realmName, timerunningSeasonID

    -- 12.0 FIX: Strictly use C_BattleNet. Old global BNet functions are permanently removed.
	if C_BattleNet and C_BattleNet.GetFriendAccountInfo then
		local accountInfo = C_BattleNet.GetFriendAccountInfo(id)
		if accountInfo then
			accountName = accountInfo.accountName
			isFavoriteFriend = accountInfo.isFavorite
			bnetAccountId = accountInfo.bnetAccountID
			isAFK = accountInfo.isAFK
			isDND = accountInfo.isDND
			lastOnline = accountInfo.lastOnlineTime
			battleTag = accountInfo.battleTag

			local gameAccountInfo = accountInfo.gameAccountInfo
			if gameAccountInfo then
				isOnline = gameAccountInfo.isOnline
				isGameAFK = gameAccountInfo.isGameAFK
				isGameBusy = gameAccountInfo.isGameBusy
				mobile = gameAccountInfo.isWowMobile
				characterName = gameAccountInfo.characterName
				class = gameAccountInfo.className
				level = gameAccountInfo.characterLevel
				client = gameAccountInfo.clientProgram
				wowProjectID = gameAccountInfo.wowProjectID
				gameText = gameAccountInfo.richPresence
				zoneName = gameAccountInfo.areaName
				realmName = gameAccountInfo.realmName
				factionName = gameAccountInfo.factionName
				timerunningSeasonID = gameAccountInfo.timerunningSeasonID
			end

			canCoop = CanCooperateWithGameAccount(accountInfo)
		end
	end

	if realmName and realmName ~= "" then
		if zoneName then
			zoneName = zoneName .. " - " .. realmName
		end
	end

	return accountName, characterName, class, level, isFavoriteFriend, isOnline,
		bnetAccountId, client, canCoop, wowProjectID, lastOnline,
		isAFK, isGameAFK, isDND, isGameBusy, mobile, zoneName, gameText, battleTag, factionName, timerunningSeasonID
end

function FriendGroups_GetFactionIcon(factionGroup)
	if (factionGroup and factionGroup ~= "" and factionGroup ~= "Neutral") then
		return "Interface\\FriendsFrame\\PlusManz-" .. factionGroup;
	else
		return ""
	end
end

function FriendGroups_GetStatusString(playerData)
	local status = "Offline"

	if playerData.buttonType == FRIENDS_BUTTON_TYPE_BNET then
		local friendAccountInfo = C_BattleNet.GetFriendAccountInfo(playerData.id)

		if friendAccountInfo then
			local gameAccountInfo = friendAccountInfo.gameAccountInfo

			if friendAccountInfo.isAFK and gameAccountInfo and gameAccountInfo.isOnline then
				status = "AFK"
			end

			if friendAccountInfo.isDND and gameAccountInfo and gameAccountInfo.isOnline then
				status = "DND"
			end

			if not friendAccountInfo.isAFK and not friendAccountInfo.isDND then
				if gameAccountInfo.isOnline then
					status = "Online"

					if gameAccountInfo.isGameBusy then
						status = "DND"
					end

					if gameAccountInfo.isGameAFK then
						status = "AFK"
					end

					if gameAccountInfo.clientProgram == "BSAp" then
						status = status .. "Mobile"
					end

					if gameAccountInfo.clientProgram == BNET_CLIENT_WOW then
						status = status .. "InGame"
					end
				end
			end
		end
	elseif playerData.buttonType == FRIENDS_BUTTON_TYPE_WOW then
		local friendInfo = C_FriendList.GetFriendInfoByIndex(playerData.id)

		if friendInfo.connected then
			status = "OnlineInGame"

			if friendInfo.dnd then
				status = "DNDInGame"
			end

			if friendInfo.afk then
				status = "AFKInGame"
			end
		end
	end

	return status
end

function FriendGroups_SortTableByStatus(playerA, playerB)
	if not playerA then
		playerA = {}
	end

	if not playerB then
		playerB = {}
	end

	-- 1. Presence tier: actively in WoW first, then otherwise-online, then offline.
	local rankA = playerA.isInGame and 1 or (playerA.isOnline and 2 or 3)
	local rankB = playerB.isInGame and 1 or (playerB.isOnline and 2 or 3)
	if rankA ~= rankB then
		return rankA < rankB
	end

	-- 2. Battle.net friends before WoW (in-game) friends within the same tier
	local typeA = (playerA.buttonType == FRIENDS_BUTTON_TYPE_BNET) and 1 or 2
	local typeB = (playerB.buttonType == FRIENDS_BUTTON_TYPE_BNET) and 1 or 2
	if typeA ~= typeB then
		return typeA < typeB
	end

	-- 3. Alphabetical by display name
	local nameA = playerA.sortName or ""
	local nameB = playerB.sortName or ""
	if nameA ~= nameB then
		return nameA < nameB
	end

	-- 4. Deterministic backstop (unique per friend within the same tier/type)
	return (playerA.id or 0) < (playerB.id or 0)
end

-- ============================================================================
-- [[ MANUAL GROUP ORDER ENGINE ]]
-- Layers a manual ordering on top of the automatic hierarchy. Each movable group
-- may carry a fractional rank in ShiGuangDB.FriendGroupsgroup_order (keyed by group
-- name, matching the existing collapsed[] / banner_colors[] convention). Groups with
-- no rank fall back to their automatic position, so untouched lists behave exactly as
-- before. The fixed system anchors below are never movable.
-- ============================================================================
local FriendGroups_AutoPos = {}        -- [groupName] = automatic sort index (1..N)
local FriendGroups_MovableOrder = {}   -- array of movable group names in display order
local FriendGroups_MovableIndex = {}   -- [groupName] = index within FriendGroups_MovableOrder

local function FriendGroups_IsFixedAnchor(groupName)
	return groupName == FGL["GROUP_NONE"]
		or groupName == FGL["GROUP_EMPTY"]
		or groupName == ""
		or groupName == FGL["GROUP_OFFLINE_1"]
		or groupName == FGL["GROUP_OFFLINE_2"]
		or groupName == FGL["GROUP_OFFLINE_3"]
end

local function FriendGroups_GetEffectiveRank(groupName)
	local order = ShiGuangDB.FriendGroupsgroup_order
	if order and order[groupName] ~= nil and not FriendGroups_IsFixedAnchor(groupName) then
		return order[groupName]
	end
	return FriendGroups_AutoPos[groupName] or 0
end

-- Force the friends ScrollBox to repaint a reordered list. The rebuild restores the
-- scroll to the SAME percentage (a no-op that fires no scroll event), so the box keeps
-- showing the old rows until you scroll. We reproduce a real scroll: nudge the offset a
-- hair, then restore it (no interpolation = invisible), which re-acquires the row frames.
local function FG_ForceScrollRedraw()
	local sb = FriendsListFrame and FriendsListFrame.ScrollBox
	if not sb then return end
	if sb.FullUpdate then sb:FullUpdate(true) end
	if sb.GetScrollPercentage and sb.SetScrollPercentage then
		local noInterp = ScrollBoxConstants and ScrollBoxConstants.NoScrollInterpolation
		local p = sb:GetScrollPercentage() or 0
		sb:SetScrollPercentage((p >= 1) and (p - 0.01) or (p + 0.01), noInterp)
		sb:SetScrollPercentage(p, noInterp)
	elseif sb.Update then
		sb:Update()
	end
end

-- Re-sort, then repaint now AND on the next frame. The context menu is still closing
-- when the click handler runs, which can swallow the immediate repaint of the new order
-- (the cause of "had to click move twice"); the deferred pass lands after it closes.
local function FG_RefreshOrderNow()
	if FriendGroups_FriendsListUpdate then FriendGroups_FriendsListUpdate(true) end
	FG_ForceScrollRedraw()
	if C_Timer and C_Timer.After then
		C_Timer.After(0, FG_ForceScrollRedraw)
	end
end

local function FriendGroups_MoveGroup(groupName, direction)
	if not groupName or FriendGroups_IsFixedAnchor(groupName) then return end
	if not ShiGuangDB.FriendGroupsgroup_order then
		ShiGuangDB.FriendGroupsgroup_order = {}
	end

	-- Copy the current movable order, swap by one, then assign DENSE integer ranks to
	-- every movable group. Dense ranks make each click move exactly one position (no
	-- fractional ties with auto-positions that silently leave the order unchanged), and
	-- keep exports collision-free (no movable group falls back to an auto index).
	local arr = {}
	for i = 1, #FriendGroups_MovableOrder do arr[i] = FriendGroups_MovableOrder[i] end

	local idx
	for i = 1, #arr do if arr[i] == groupName then idx = i break end end
	if not idx then return end

	local swapWith = idx + (direction < 0 and -1 or 1)
	if swapWith < 1 or swapWith > #arr then return end
	arr[idx], arr[swapWith] = arr[swapWith], arr[idx]

	local order = ShiGuangDB.FriendGroupsgroup_order
	for i = 1, #arr do order[arr[i]] = i end

	FG_RefreshOrderNow()
end

local function FriendGroups_ResetGroupPosition(groupName)
	if groupName and ShiGuangDB.FriendGroupsgroup_order then
		ShiGuangDB.FriendGroupsgroup_order[groupName] = nil
	end
	FG_RefreshOrderNow()
end

function FriendGroups_SortGroupsCustom(groupA, groupB)
	if groupA == FGL["GROUP_FAVORITES"] then
		return true
	end
    if groupB == FGL["GROUP_FAVORITES"] then
        return false
    end

    local isGuildA = string.find(groupA, FGL["GROUP_GUILDMATES"], 1, true)
    local isGuildB = string.find(groupB, FGL["GROUP_GUILDMATES"], 1, true)

    if isGuildA and not isGuildB then
        return true
    end
    if isGuildB and not isGuildA then
        return false
    end

	if groupA == FGL["GROUP_NONE"] then
		return false
	end
	if groupB == FGL["GROUP_NONE"] then
		return true
	end

	return groupA < groupB
end

-- Phase-2 comparator: order by effective rank (manual override or automatic position),
-- breaking ties by the automatic order and then name for determinism.
function FriendGroups_SortGroupsByRank(groupA, groupB)
	local ea = FriendGroups_GetEffectiveRank(groupA)
	local eb = FriendGroups_GetEffectiveRank(groupB)
	if ea ~= eb then
		return ea < eb
	end

	local pa = FriendGroups_AutoPos[groupA] or 0
	local pb = FriendGroups_AutoPos[groupB] or 0
	if pa ~= pb then
		return pa < pb
	end

	return groupA < groupB
end

local FriendGroups_AssignmentCache = {}
local FriendGroups_CacheGeneration = 0

function FriendGroups_SetGroups(id, buttonType, passedAccountInfo)
    local noteText = ""
    local statusText = "Offline"
    local favorite = false
    local charName, client, isOnline, isRetail, accountIdentifier = "", "", false, false, nil
    local altCacheCount = 0

    -- Inline state resolution to completely bypass redundant API table allocations
    if buttonType == FRIENDS_BUTTON_TYPE_BNET then
        local friendAccountInfo = passedAccountInfo or C_BattleNet.GetFriendAccountInfo(id)
        if friendAccountInfo then
            noteText = friendAccountInfo.note or ""
            favorite = friendAccountInfo.isFavorite
            accountIdentifier = friendAccountInfo.battleTag or friendAccountInfo.accountName

            local gameAccountInfo = friendAccountInfo.gameAccountInfo
            if gameAccountInfo then
                isOnline = gameAccountInfo.isOnline
                client = gameAccountInfo.clientProgram
                isRetail = (gameAccountInfo.wowProjectID == WOW_PROJECT_MAINLINE)
                charName = (type(gameAccountInfo.characterName) == "string") and gameAccountInfo.characterName or ""

                if friendAccountInfo.isAFK and isOnline then statusText = "AFK"
                elseif friendAccountInfo.isDND and isOnline then statusText = "DND"
                elseif isOnline then
                    statusText = "Online"
                    if gameAccountInfo.isGameBusy then statusText = "DND"
                    elseif gameAccountInfo.isGameAFK then statusText = "AFK" end
                    if client == "BSAp" then statusText = statusText .. "Mobile" end
                    if client == BNET_CLIENT_WOW then statusText = statusText .. "InGame" end
                end
            end
            
            if accountIdentifier and type(ShiGuangDB.FriendGroupsalt_cache) == "table" and ShiGuangDB.FriendGroupsalt_cache[accountIdentifier] then
                altCacheCount = #ShiGuangDB.FriendGroupsalt_cache[accountIdentifier]
            end
        end
    elseif buttonType == FRIENDS_BUTTON_TYPE_WOW then
        local info = passedAccountInfo or C_FriendList.GetFriendInfoByIndex(id)
        if info then
            noteText = info.notes or ""
            isOnline = info.connected
            client = BNET_CLIENT_WOW
            charName = (type(info.name) == "string") and info.name or ""

            if isOnline then
                statusText = "OnlineInGame"
                if info.dnd then statusText = "DNDInGame"
                elseif info.afk then statusText = "AFKInGame" end
            end
        end
    end

    if type(noteText) ~= "string" then noteText = "" end

    -- [[ OPTIMIZATION: Use Unique Identifier for Cache instead of dynamic List Index ]]
    local uniqueID = id
    if buttonType == FRIENDS_BUTTON_TYPE_BNET and accountIdentifier then
        uniqueID = accountIdentifier
    elseif buttonType == FRIENDS_BUTTON_TYPE_WOW and charName ~= "" then
        uniqueID = charName
    end
    
    local cacheKey = buttonType .. "_" .. tostring(uniqueID)
    local cache = FriendGroups_AssignmentCache[cacheKey]
    
    local manualMain = (ShiGuangDB.FriendGroupsmanual_mains and accountIdentifier) and ShiGuangDB.FriendGroupsmanual_mains[accountIdentifier] or nil
    
    local isCacheValid = cache 
        and cache.noteText == noteText 
        and cache.statusText == statusText 
        and cache.favorite == favorite 
        and cache.charName == charName 
        and cache.isOnline == isOnline 
        and cache.altCacheCount == altCacheCount
        and cache.guildSetting == ShiGuangDB.FriendGroupsshow_guildmates
        and cache.offlineSetting == ShiGuangDB.FriendGroupsoffline_tracker
        and cache.favSetting == ShiGuangDB.FriendGroupsadd_favorite_group
        and cache.manualMain == manualMain

    if not isCacheValid then
        if buttonType == FRIENDS_BUTTON_TYPE_BNET and accountIdentifier then
            local friendAccountInfo = passedAccountInfo or C_BattleNet.GetFriendAccountInfo(id)
            if friendAccountInfo and friendAccountInfo.gameAccountInfo then
                FriendGroups_SaveToAltCache(accountIdentifier, friendAccountInfo.gameAccountInfo)
                if C_BattleNet.GetFriendNumGameAccounts then
                    local numAccounts = C_BattleNet.GetFriendNumGameAccounts(id)
                    if numAccounts and numAccounts > 1 then
                        for i = 1, numAccounts do
                            local gameAccountInfo = C_BattleNet.GetFriendGameAccountInfo(id, i)
                            if gameAccountInfo then
                                FriendGroups_SaveToAltCache(accountIdentifier, gameAccountInfo)
                            end
                        end
                    end
                end
            end
        end

        local resolvedGroups = {}
        local parsedGroups = FriendGroups_GetPlayerGroups(noteText)
        
        for _, g in ipairs(parsedGroups) do table.insert(resolvedGroups, g) end

        if ShiGuangDB.FriendGroupsadd_favorite_group and favorite then
            table.insert(resolvedGroups, FGL["GROUP_FAVORITES"])
        end

        local akaName, akaClass, akaRealm = nil, nil, nil

        if ShiGuangDB.FriendGroupsshow_guildmates then
            local hasManualGuild = false
            if noteText ~= "" then
                for manualGuildName in string.gmatch(noteText, "<([^>]+)>") do
                    manualGuildName = string.match(manualGuildName, "^%s*(.-)%s*$")
                    if manualGuildName and #manualGuildName >= 2 then
                        local formattedGuildGroup = string.format(FGL["FORMAT_GUILD_TAG"], FGL["GROUP_GUILDMATES"], manualGuildName)
                        local alreadyExists = false
                        for _, g in ipairs(resolvedGroups) do
                            if g == formattedGuildGroup then alreadyExists = true break end
                        end
                        if not alreadyExists then
                            table.insert(resolvedGroups, formattedGuildGroup)
                            hasManualGuild = true
                        end
                    end
                end
            end
            
            local matchedGuild = nil
            local mainGuild = nil
            if buttonType == FRIENDS_BUTTON_TYPE_BNET then
                -- [[ AKA SOURCE: MANUAL MAIN ONLY ]]
                -- The "aka" label is driven solely by a manually-selected main below.
                -- The previous auto-detection (newest cached character by timestamp) was
                -- removed so an "aka" only appears once the user explicitly picks a main.
                if ShiGuangDB.FriendGroupsmanual_mains and accountIdentifier and ShiGuangDB.FriendGroupsmanual_mains[accountIdentifier] then
                    local manualMainKey = ShiGuangDB.FriendGroupsmanual_mains[accountIdentifier]
                    if ShiGuangDB.FriendGroupsalt_cache and ShiGuangDB.FriendGroupsalt_cache[accountIdentifier] then
                        for _, alt in ipairs(ShiGuangDB.FriendGroupsalt_cache[accountIdentifier]) do
                            local currentKey = alt.charName .. "-" .. FriendGroups_CleanRealmName(alt.realm or "")
                            if currentKey == manualMainKey or alt.key == manualMainKey then
                                akaName = alt.charName
                                akaClass = alt.class
                                akaRealm = alt.realm
                                -- Manual main trumps all: its guild defines the guild group,
                                -- regardless of which character the friend is logged in on.
                                if alt.guild and alt.guild ~= "" and alt.guild ~= "NONE" and alt.guild ~= "-" then
                                    mainGuild = alt.guild
                                end
                                break
                            end
                        end
                    end

                    -- Fall back to the synced seed if this box hasn't cached the main yet,
                    -- and keep the seed fresh from any local observation above.
                    if not mainGuild and ShiGuangDB.FriendGroupsmain_guild then
                        local seed = ShiGuangDB.FriendGroupsmain_guild[accountIdentifier]
                        if seed and seed ~= "" and seed ~= "NONE" and seed ~= "-" then
                            mainGuild = seed
                        end
                    end
                    if mainGuild then
                        if not ShiGuangDB.FriendGroupsmain_guild then ShiGuangDB.FriendGroupsmain_guild = {} end
                        ShiGuangDB.FriendGroupsmain_guild[accountIdentifier] = mainGuild
                    end
                end

                if not mainGuild and not hasManualGuild then
                    if isOnline and client == BNET_CLIENT_WOW and isRetail and charName ~= "" then
                        local friendAccountInfo = passedAccountInfo or C_BattleNet.GetFriendAccountInfo(id)
                        if friendAccountInfo and friendAccountInfo.gameAccountInfo then
                            local rName = (type(friendAccountInfo.gameAccountInfo.realmName) == "string") and friendAccountInfo.gameAccountInfo.realmName or ""
                            local fullName = charName
                            if rName ~= "" then fullName = charName .. "-" .. FriendGroups_CleanRealmName(rName) end

                            if FriendGroups_LiveGuildSessionDict[fullName] or FriendGroups_LiveGuildSessionDict[charName] then
                                matchedGuild = FriendGroups_PlayerGuildName
                            end
                        end
                    end
                    
                    if not matchedGuild and accountIdentifier and ShiGuangDB.FriendGroupsalt_cache and ShiGuangDB.FriendGroupsalt_cache[accountIdentifier] then
                        for _, alt in ipairs(ShiGuangDB.FriendGroupsalt_cache[accountIdentifier]) do
                            if alt.guild and alt.guild ~= "" and alt.guild ~= "NONE" and alt.guild ~= "-" then
                                if ShiGuangDB.FriendGroupsknown_player_guilds and ShiGuangDB.FriendGroupsknown_player_guilds[alt.guild] then
                                    matchedGuild = alt.guild
                                    break
                                end
                            end
                            if FriendGroups_LiveGuildSessionDict[alt.key] or FriendGroups_LiveGuildSessionDict[alt.charName] then
                                matchedGuild = FriendGroups_PlayerGuildName
                                break
                            end
                        end
                    end
                end
            elseif buttonType == FRIENDS_BUTTON_TYPE_WOW then
                if not hasManualGuild and charName ~= "" then
                    if FriendGroups_LiveGuildSessionDict[charName] then
                        matchedGuild = FriendGroups_PlayerGuildName
                    end
                end
            end
            
            -- A manually-selected main's guild takes precedence over the heuristic match.
            local effectiveGuild = mainGuild or matchedGuild
            if effectiveGuild then
                local formattedGuildGroup = string.format(FGL["FORMAT_GUILD_TAG"], FGL["GROUP_GUILDMATES"], effectiveGuild)
                local alreadyExists = false
                for _, g in ipairs(resolvedGroups) do
                    if g == formattedGuildGroup then alreadyExists = true break end
                end
                if not alreadyExists then
                    table.insert(resolvedGroups, formattedGuildGroup)
                end
            end
        end

        if ShiGuangDB.FriendGroupsoffline_tracker and buttonType == FRIENDS_BUTTON_TYPE_BNET and statusText == "Offline" then
            local friendAccountInfo = passedAccountInfo or C_BattleNet.GetFriendAccountInfo(id)
            if friendAccountInfo and friendAccountInfo.lastOnlineTime and friendAccountInfo.lastOnlineTime > 0 then
                local daysOffline = (time() - friendAccountInfo.lastOnlineTime) / 86400
                if daysOffline >= 90 then table.insert(resolvedGroups, FGL["GROUP_OFFLINE_3"])
                elseif daysOffline >= 60 then table.insert(resolvedGroups, FGL["GROUP_OFFLINE_2"])
                elseif daysOffline >= 30 then table.insert(resolvedGroups, FGL["GROUP_OFFLINE_1"]) end
            end
        end

        if #resolvedGroups == 0 then table.insert(resolvedGroups, FGL["GROUP_NONE"]) end
        
        FriendGroups_AssignmentCache[cacheKey] = {
            noteText = noteText,
            statusText = statusText,
            favorite = favorite,
            charName = charName,
            isOnline = isOnline,
            altCacheCount = altCacheCount,
            guildSetting = ShiGuangDB.FriendGroupsshow_guildmates,
            offlineSetting = ShiGuangDB.FriendGroupsoffline_tracker,
            favSetting = ShiGuangDB.FriendGroupsadd_favorite_group,
            manualMain = manualMain,
            groups = resolvedGroups,
            akaName = akaName,
            akaClass = akaClass,
            akaRealm = akaRealm
        }
        
        cache = FriendGroups_AssignmentCache[cacheKey]
    end

	-- [[ CACHE PRUNE SUPPORT ]]
    -- Mark this entry as live for the current rebuild generation. Entries for friends who are
    -- no longer present keep an older generation and are pruned at the end of FriendsListUpdate.
    cache.generation = FriendGroups_CacheGeneration
	
    if accountIdentifier then
        FriendGroups_ActiveAKA[accountIdentifier] = nil
        if cache.akaName then
            FriendGroups_ActiveAKA[accountIdentifier] = { name = cache.akaName, class = cache.akaClass, realm = cache.akaRealm }
        end
    end

    for _, groupName in ipairs(cache.groups) do
        local addToTable = false

        if not groupsTotal[groupName] then
            groupsTotal[groupName] = FG_GetTable()
            groupsCount[groupName] = FG_GetTable()
            groupsCount[groupName].Total = 0
            groupsCount[groupName].Online = 0
            table.insert(groupsSorted, groupName)
        end

        if isOnline then
            if (ShiGuangDB.FriendGroupshide_afk and statusText ~= "AFK" and statusText ~= "AFKMobile") or not ShiGuangDB.FriendGroupshide_afk then
                if (ShiGuangDB.FriendGroupsingame_only and client == BNET_CLIENT_WOW) or not ShiGuangDB.FriendGroupsingame_only then
                    if ShiGuangDB.FriendGroupsshow_retail and client == BNET_CLIENT_WOW then
                        if isRetail then addToTable = true end
                    else
                        addToTable = true
                    end
                end
            end
        else
            if not ShiGuangDB.FriendGroupshide_offline and ((ShiGuangDB.FriendGroupsingame_only and client == BNET_CLIENT_WOW) or not ShiGuangDB.FriendGroupsingame_only) then
                addToTable = true
            end
        end

        if searchValue ~= "" then
            addToTable = FriendGroups_Search(id, buttonType, passedAccountInfo)
        end

        if addToTable then
            groupsCount[groupName].Total = groupsCount[groupName].Total + 1
            if statusText ~= "Offline" then
                groupsCount[groupName].Online = groupsCount[groupName].Online + 1
            end
            
			local playerData = FG_GetTable()
            playerData.id = id
            playerData.buttonType = buttonType
            playerData.statusText = statusText

            -- [[ PRESENCE FLAGS (drive the primary sort tier) ]]
            -- isInGame: online AND active client is WoW (actively playing) -> top tier.
            -- isOnline: online in any client (WoW, app, mobile, other game) -> middle tier.
            -- Offline (neither flag) -> bottom tier.
            playerData.isOnline = isOnline
            playerData.isInGame = isOnline and (client == BNET_CLIENT_WOW)

            -- [[ ALPHABETICAL SORT KEY ]]
            -- BNet -> account identifier (BattleTag; its name prefix is the displayed bold name).
            -- WoW  -> character name. Lowercased for case-insensitive ordering. Consumed by the
            -- secondary sort in FriendGroups_SortTableByStatus.
            local sortName = charName
            if buttonType == FRIENDS_BUTTON_TYPE_BNET and accountIdentifier then
                sortName = accountIdentifier
            end
            playerData.sortName = (sortName or ""):lower()

            table.insert(groupsTotal[groupName], playerData)
		end
    end
end

function FriendGroups_Search(playerId, playerButtonType, passedAccountInfo)
    if searchValue == "" then return true end

    local characterName, bnetAccountName, battleTag, noteText, realmName, className, richPresence, regionSearchText, factionSearchText = "", "", "", "", "", "", "", "", ""
    local classMatch = false
    local altMatch = false
    local matchedGuildName = ""
    local hasManualGuild = false
    local searchLower = searchLowerValue
    local searchLen = #searchLower

    if playerButtonType == FRIENDS_BUTTON_TYPE_BNET then
        -- Fast Path: Use passed memory reference instead of querying the API
        local accountInfo = passedAccountInfo or C_BattleNet.GetFriendAccountInfo(playerId)
        if accountInfo and accountInfo.gameAccountInfo then
            bnetAccountName = (type(accountInfo.accountName) == "string") and accountInfo.accountName or ""
            battleTag = (type(accountInfo.battleTag) == "string") and accountInfo.battleTag or ""
            noteText = (type(accountInfo.note) == "string") and accountInfo.note or ""
            characterName = (type(accountInfo.gameAccountInfo.characterName) == "string") and accountInfo.gameAccountInfo.characterName or ""
            realmName = (type(accountInfo.gameAccountInfo.realmName) == "string") and accountInfo.gameAccountInfo.realmName or ""
            className = (type(accountInfo.gameAccountInfo.className) == "string") and accountInfo.gameAccountInfo.className or ""
            richPresence = (type(accountInfo.gameAccountInfo.richPresence) == "string") and accountInfo.gameAccountInfo.richPresence or ""
            factionSearchText = (type(accountInfo.gameAccountInfo.factionName) == "string") and accountInfo.gameAccountInfo.factionName or ""

            local rid = accountInfo.gameAccountInfo.regionID
            --[[local database = (rid == 3) and FriendGroups_RealmDataEU or FriendGroups_RealmData
            
            if realmName ~= "" then
                local cleanRealm = FriendGroups_CleanRealmName(realmName)
                local data = database[cleanRealm]
                if data and data.region then
                    regionSearchText = data.region
                end
            end]]
            
            local accountIdentifier = accountInfo.battleTag or accountInfo.accountName
            if accountIdentifier and type(ShiGuangDB.FriendGroupsalt_cache) == "table" then
                local alts = ShiGuangDB.FriendGroupsalt_cache[accountIdentifier]
                if alts then
                    for _, alt in ipairs(alts) do
                        local aName = (type(alt.searchName) == "string") and alt.searchName or (type(alt.charName) == "string" and alt.charName:lower() or "")
                        local aRealm = (type(alt.searchRealm) == "string") and alt.searchRealm or (type(alt.realm) == "string" and alt.realm:lower() or "")
                        local aClass = (type(alt.searchClass) == "string") and alt.searchClass or (type(alt.class) == "string" and alt.class:lower() or "")
                        local aZone = (type(alt.searchZone) == "string") and alt.searchZone or (type(alt.zone) == "string" and alt.zone:lower() or "")
                        
                        if (aName:find(searchLower, 1, true)) or 
                           (aRealm:find(searchLower, 1, true)) or 
                           (aZone:find(searchLower, 1, true)) or 
                           (aClass ~= "" and aClass:sub(1, searchLen) == searchLower) then
                            altMatch = true
                            break
                        end
                    end
                end
            end
            
            if ShiGuangDB.FriendGroupsshow_guildmates then
                if noteText ~= "" then
                    for manualGuildName in string.gmatch(noteText, "<([^>]+)>") do
                        manualGuildName = string.match(manualGuildName, "^%s*(.-)%s*$")
                        if manualGuildName and #manualGuildName >= 2 then
                            if manualGuildName:lower():find(searchLower, 1, true) then
                                matchedGuildName = manualGuildName
                            end
                            hasManualGuild = true
                        end
                    end
                end
                
                if not hasManualGuild then
                    if accountInfo.gameAccountInfo.isOnline and accountInfo.gameAccountInfo.clientProgram == BNET_CLIENT_WOW and accountInfo.gameAccountInfo.wowProjectID == WOW_PROJECT_MAINLINE then
                        local cName = characterName
                        local rName = realmName
                        if cName ~= "" then
                            local fullName = cName
                            if rName ~= "" then fullName = cName .. "-" .. FriendGroups_CleanRealmName(rName) end
                            
                            if FriendGroups_LiveGuildSessionDict[fullName] or FriendGroups_LiveGuildSessionDict[cName] then
                                matchedGuildName = FriendGroups_PlayerGuildName
                            end
                        end
                    end
                end
            end
        end
    elseif playerButtonType == FRIENDS_BUTTON_TYPE_WOW then
        local info = passedAccountInfo or C_FriendList.GetFriendInfoByIndex(playerId)
        if info then
            characterName = (type(info.name) == "string") and info.name or ""
            noteText = (type(info.notes) == "string") and info.notes or ""
            className = (type(info.className) == "string") and info.className or ""
            factionSearchText = (type(playerFactionGroup) == "string") and playerFactionGroup or ""
            regionSearchText = "Local"
            
            if ShiGuangDB.FriendGroupsshow_guildmates then
                if noteText ~= "" then
                    for manualGuildName in string.gmatch(noteText, "<([^>]+)>") do
                        manualGuildName = string.match(manualGuildName, "^%s*(.-)%s*$")
                        if manualGuildName and #manualGuildName >= 2 then
                            if manualGuildName:lower():find(searchLower, 1, true) then
                                matchedGuildName = manualGuildName
                            end
                            hasManualGuild = true
                        end
                    end
                end
                
                if not hasManualGuild and characterName ~= "" then
                    if FriendGroups_LiveGuildSessionDict[characterName] then
                        matchedGuildName = FriendGroups_PlayerGuildName
                    end
                end
            end
        end
    end

    if className ~= "" and className:lower():sub(1, searchLen) == searchLower then classMatch = true end

    if (bnetAccountName:lower():find(searchLower, 1, true)) or 
       (battleTag:lower():find(searchLower, 1, true)) or 
       (characterName:lower():find(searchLower, 1, true)) or
       (noteText:lower():find(searchLower, 1, true)) or
       (realmName:lower():find(searchLower, 1, true)) or
       (richPresence:lower():find(searchLower, 1, true)) or 
       (regionSearchText:lower():find(searchLower, 1, true)) or 
       (factionSearchText:lower():find(searchLower, 1, true)) or 
       (matchedGuildName ~= "" and matchedGuildName:lower():find(searchLower, 1, true)) or
       classMatch or altMatch then
        return true
    end
    return false
end

function FriendGroups_AddDropDownNew(ownerRegion, rootDescription, contextData)
    if not contextData then return end
    if not FriendsListFrame or not FriendsListFrame:IsMouseOver() then
        return 
    end

    local bnetfriend = false
    local accountInfo = nil
    local bnetIDAccount = contextData.bnetIDAccount
    local wowName = contextData.name

    if bnetIDAccount then
        bnetfriend = true
        accountInfo = C_BattleNet.GetAccountInfoByID(bnetIDAccount)
    elseif wowName then
        bnetfriend = false
        accountInfo = C_FriendList.GetFriendInfo(wowName)
    else
        return
    end

    if not accountInfo then return end

    local note = ""
    if bnetfriend then
        note = accountInfo.note
    else
        note = accountInfo.notes
    end

    local groups = FriendGroups_GetPlayerGroups(note)

    rootDescription:CreateDivider()
    rootDescription:CreateTitle(FGL["DROP_TITLE"])

    rootDescription:CreateButton(FGL["DROP_COPY_NAME"], function(data)
        local textToCopy = ""
        if data.bnetfriend then
            local info = C_BattleNet.GetAccountInfoByID(data.id)
            if info and info.gameAccountInfo and info.gameAccountInfo.characterName then
                local char = info.gameAccountInfo.characterName
                local realm = info.gameAccountInfo.realmName
                local richPresence = info.gameAccountInfo.richPresence
                local friendProject = info.gameAccountInfo.wowProjectID
                local myProject = WOW_PROJECT_ID

                if not realm or realm == "" then
                    realm = info.gameAccountInfo.realmDisplayName
                end
                
                if (not realm or realm == "") and richPresence and type(richPresence) == "string" then
                    local extraction = richPresence:match("%s%-%s(.+)")
                    if extraction then
                        realm = extraction
                    end
                end
                if (not realm or realm == "") and info.gameAccountInfo.clientProgram == BNET_CLIENT_WOW then
                    if friendProject == myProject then
                        realm = GetNormalizedRealmName()
                    end
                end

                if realm and realm ~= "" and type(realm) == "string" then
                    realm = realm:gsub("[%s%p]+", "") 
                    textToCopy = char .. "-" .. realm
                else
                    textToCopy = char
                end
            elseif info and info.battleTag and type(info.battleTag) == "string" then
                textToCopy = info.battleTag
            end
		else
            textToCopy = data.name
            if textToCopy and textToCopy ~= "" and type(textToCopy) == "string" and not string.find(textToCopy, "-") then
                local myRealm = GetNormalizedRealmName()
                if type(myRealm) == "string" and myRealm ~= "" then
                    textToCopy = textToCopy .. "-" .. myRealm:gsub("[%s%p]+", "")
                end
            end
        end
		
        if textToCopy and textToCopy ~= "" then
            local dialog = StaticPopup_Show("FRIENDGROUPS_COPY_POPUP")
            if dialog and dialog.EditBox then
                dialog.EditBox:SetText(textToCopy)
                dialog.EditBox:HighlightText()
                dialog.EditBox:SetFocus()
            end
        end
    end, { id = bnetIDAccount, name = wowName, bnetfriend = bnetfriend })

    if bnetfriend then
        rootDescription:CreateButton(FGL["DROP_COPY_BTAG"], function(data)
            local info = C_BattleNet.GetAccountInfoByID(data.id)
            if info and info.battleTag and type(info.battleTag) == "string" then
                local dialog = StaticPopup_Show("FRIENDGROUPS_COPY_POPUP")
                if dialog and dialog.EditBox then
                    dialog.EditBox:SetText(info.battleTag)
                    dialog.EditBox:HighlightText()
                    dialog.EditBox:SetFocus()
                end
            end
        end, { id = bnetIDAccount })
    end

    local accountIdentifier = accountInfo and (accountInfo.battleTag or accountInfo.accountName)

    if accountIdentifier then
        rootDescription:CreateButton(FGL["DROP_SET_NICKNAME"], function(data)
            StaticPopup_Show("FRIENDGROUPS_SET_NICKNAME", nil, nil, { accountIdentifier = data.accountIdentifier })
        end, { accountIdentifier = accountIdentifier })

        if ShiGuangDB.FriendGroupsshow_known_alts ~= false then
            local mainSubMenu = rootDescription:CreateButton(FGL["DROP_SELECT_MAIN"])
            mainSubMenu:CreateButton(FGL["DROP_CLEAR_MAIN"], function(data)
                if ShiGuangDB.FriendGroupsmanual_mains then
                    ShiGuangDB.FriendGroupsmanual_mains[data.accountIdentifier] = nil
                    if ShiGuangDB.FriendGroupsmain_guild then
                        ShiGuangDB.FriendGroupsmain_guild[data.accountIdentifier] = nil
                    end
                    FriendGroups_FriendsListUpdate(true)
                end
            end, { accountIdentifier = accountIdentifier })

            if ShiGuangDB.FriendGroupsalt_cache and ShiGuangDB.FriendGroupsalt_cache[accountIdentifier] then
                local menuAlts = {}
                for _, altData in ipairs(ShiGuangDB.FriendGroupsalt_cache[accountIdentifier]) do
                    table.insert(menuAlts, altData)
                end
                table.sort(menuAlts, function(a, b) return (a.charName or "") < (b.charName or "") end)

                for _, altData in ipairs(menuAlts) do
                    local engClass = ""
                    if altData.class and (LOCALIZED_CLASS_NAMES_MALE[altData.class] or RAID_CLASS_COLORS[altData.class]) then
                        engClass = altData.class
                    else
                        for k, v in pairs(LOCALIZED_CLASS_NAMES_MALE) do if altData.class == v then engClass = k break end end
                        if engClass == "" then for k, v in pairs(LOCALIZED_CLASS_NAMES_FEMALE) do if altData.class == v then engClass = k break end end end
                    end
                    local colorCode = FriendGroups_GetClassColorCode(engClass ~= "" and engClass or altData.class)
                    local displayLabel = colorCode .. altData.charName .. "-" .. altData.realm .. "|r"
                    local altUniqueKey = altData.charName .. "-" .. FriendGroups_CleanRealmName(altData.realm or "")

                    mainSubMenu:CreateButton(displayLabel, function(data)
                        if not ShiGuangDB.FriendGroupsmanual_mains then ShiGuangDB.FriendGroupsmanual_mains = {} end
                        ShiGuangDB.FriendGroupsmanual_mains[data.accountIdentifier] = data.altKey
                        FriendGroups_FriendsListUpdate(true)
                    end, { accountIdentifier = accountIdentifier, altKey = altUniqueKey })
                end
            end
        end
    end

    rootDescription:CreateButton(FGL["DROP_CREATE"], function(data)
        if data.bnetfriend then
            local info = C_BattleNet.GetAccountInfoByID(data.id)
            if info then
                StaticPopup_Show("FRIENDGROUPS_CREATE", nil, nil, { id = info.bnetAccountID, note = info.note, set = function(id, note)
                    if C_BattleNet and C_BattleNet.SetFriendNote then
                        C_BattleNet.SetFriendNote(id, note)
                    else
                        BNSetFriendNote(id, note)
                    end
                end })
            end
        else
            local info = C_FriendList.GetFriendInfo(data.name)
            if info then
                StaticPopup_Show("FRIENDGROUPS_CREATE", nil, nil, { name = data.name, note = info.notes, set = C_FriendList.SetFriendNotes })
            end
        end
    end, { id = bnetIDAccount, name = wowName, bnetfriend = bnetfriend })

    local add = rootDescription:CreateButton(FGL["DROP_ADD"])

	for _, group in ipairs(groupsSorted) do
        local isGuildGroup = string.find(group, FGL["GROUP_GUILDMATES"], 1, true)
        if not FriendGroups_HasValue(groups, group) and group ~= "" and group ~= FGL["GROUP_FAVORITES"] and group ~= FGL["GROUP_EMPTY"] and group ~= FGL["GROUP_NONE"] 
           and group ~= FGL["GROUP_OFFLINE_1"] and group ~= FGL["GROUP_OFFLINE_2"] and group ~= FGL["GROUP_OFFLINE_3"] and not isGuildGroup then
		   add:CreateButton(group, function(data)
                local newNote = FriendGroups_AddGroup(data.note, data.group)
                if data.bnetfriend then
                    if C_BattleNet and C_BattleNet.SetFriendNote then
                        C_BattleNet.SetFriendNote(data.id, newNote)
                    else
                        BNSetFriendNote(data.id, newNote)
                    end
                else
                    C_FriendList.SetFriendNotes(data.name, newNote)
                end
            end, { group = group, note = note, id = bnetIDAccount, name = wowName, bnetfriend = bnetfriend })
        end
    end

    -- The friend's MANUAL guild tags (<GuildName> in the note) — these can be removed.
    -- Auto-detected (roster) guild membership has no note tag and is not removable.
    local manualGuilds = {}
    if type(note) == "string" then
        for gname in string.gmatch(note, "<([^>]+)>") do
            gname = strtrim(gname)
            if gname ~= "" and not FriendGroups_HasValue(manualGuilds, gname) then
                manualGuilds[#manualGuilds + 1] = gname
            end
        end
    end

    -- [[ ADD TO GUILD GROUP ]] writes a manual <GuildName> tag (works for auto guild
    -- groups too). Lists guild groups the friend isn't already manually tagged in.
    local guildOptions = {}
    for _, group in ipairs(groupsSorted) do
        if string.find(group, FGL["GROUP_GUILDMATES"], 1, true) then
            local gname = string.match(group, "<(.-)>")
            if gname and gname ~= "" and not FriendGroups_HasValue(guildOptions, gname)
               and not FriendGroups_HasValue(manualGuilds, gname) then
                guildOptions[#guildOptions + 1] = gname
            end
        end
    end
    if #guildOptions > 0 then
        local addGuild = rootDescription:CreateButton(FGL["DROP_ADD_GUILD"])
        for _, gname in ipairs(guildOptions) do
            addGuild:CreateButton(gname, function(data)
                local newNote = FriendGroups_AddGuildTag(data.note, data.guildName)
                if data.bnetfriend then
                    if C_BattleNet and C_BattleNet.SetFriendNote then
                        C_BattleNet.SetFriendNote(data.id, newNote)
                    else
                        BNSetFriendNote(data.id, newNote)
                    end
                else
                    C_FriendList.SetFriendNotes(data.name, newNote)
                end
            end, { guildName = gname, note = note, id = bnetIDAccount, name = wowName, bnetfriend = bnetfriend })
        end
    end

    -- [[ REMOVE FROM GUILD GROUP ]] strips a manual <GuildName> tag from the note.
    if #manualGuilds > 0 then
        local removeGuild = rootDescription:CreateButton(FGL["DROP_REMOVE_GUILD"])
        for _, gname in ipairs(manualGuilds) do
            removeGuild:CreateButton(gname, function(data)
                local newNote = FriendGroups_RemoveGuildTag(data.note, data.guildName)
                if data.bnetfriend then
                    if C_BattleNet and C_BattleNet.SetFriendNote then
                        C_BattleNet.SetFriendNote(data.id, newNote)
                    else
                        BNSetFriendNote(data.id, newNote)
                    end
                else
                    C_FriendList.SetFriendNotes(data.name, newNote)
                end
            end, { guildName = gname, note = note, id = bnetIDAccount, name = wowName, bnetfriend = bnetfriend })
        end
    end

    -- [[ REMOVE FROM GROUP ]] only shown when the friend is in a removable group, so it
    -- is hidden for ungrouped (No Group) friends rather than wasting a menu row.
    local removableGroups = {}
    for _, group in ipairs(groupsSorted) do
        local isGuildGroup = string.find(group, FGL["GROUP_GUILDMATES"], 1, true)
        if FriendGroups_HasValue(groups, group)
           and group ~= FGL["GROUP_FAVORITES"] and group ~= FGL["GROUP_OFFLINE_1"] and group ~= FGL["GROUP_OFFLINE_2"] and group ~= FGL["GROUP_OFFLINE_3"] and not isGuildGroup then
            removableGroups[#removableGroups + 1] = group
        end
    end
    if #removableGroups > 0 then
        local remove = rootDescription:CreateButton(FGL["DROP_REMOVE"])
        for _, group in ipairs(removableGroups) do
            remove:CreateButton(group, function(data)
                local newNote = FriendGroups_RemoveGroup(data.note, data.group)
                if data.bnetfriend then
                    if C_BattleNet and C_BattleNet.SetFriendNote then
                        C_BattleNet.SetFriendNote(data.id, newNote)
                    else
                        BNSetFriendNote(data.id, newNote)
                    end
                else
                    C_FriendList.SetFriendNotes(data.name, newNote)
                end
            end, { group = group, note = note, id = bnetIDAccount, name = wowName, bnetfriend = bnetfriend })
        end
    end
end

function FriendGroups_FrameFriendDividerTemplateHeaderClick(self, button, down)
    local frame = self
    if not frame.name then
        frame = self:GetParent()
    end

    if not frame or not frame.name then return end

    local groupName = frame.rawGroupName or frame.name:GetText()

    if groupName == "Search..." then
        if _G["FriendGroupsSearch"] then
            _G["FriendGroupsSearch"]:SetFocus()
        end
    elseif button == "RightButton" then
        if groupName then
            MenuUtil.CreateContextMenu(self, function(ownerRegion, rootDescription)
                local displayTitle = frame.name:GetText() or groupName
                rootDescription:CreateTitle(displayTitle)
                
                local isGuildGroup = string.find(groupName, FGL["GROUP_GUILDMATES"], 1, true)
                local isSystemGroup = (groupName == FGL["GROUP_NONE"] or groupName == FGL["GROUP_FAVORITES"] or groupName == FGL["GROUP_EMPTY"] or groupName == "" or groupName == FGL["GROUP_OFFLINE_1"] or groupName == FGL["GROUP_OFFLINE_2"] or groupName == FGL["GROUP_OFFLINE_3"])

                -- [[ ORDERING: movable groups can be reordered manually ]]
                if not FriendGroups_IsFixedAnchor(groupName) then
                    local moveIdx = FriendGroups_MovableIndex[groupName]
                    local moveCount = #FriendGroups_MovableOrder

                    local moveUpBtn = rootDescription:CreateButton(FGL["MENU_MOVE_UP"], function()
                        FriendGroups_MoveGroup(groupName, -1)
                    end)
                    if not moveIdx or moveIdx <= 1 then moveUpBtn:SetEnabled(false) end

                    local moveDownBtn = rootDescription:CreateButton(FGL["MENU_MOVE_DOWN"], function()
                        FriendGroups_MoveGroup(groupName, 1)
                    end)
                    if not moveIdx or moveIdx >= moveCount then moveDownBtn:SetEnabled(false) end

                    if ShiGuangDB.FriendGroupsgroup_order and ShiGuangDB.FriendGroupsgroup_order[groupName] ~= nil then
                        rootDescription:CreateButton(FGL["MENU_RESET_POSITION"], function()
                            FriendGroups_ResetGroupPosition(groupName)
                        end)
                    end

                    rootDescription:CreateDivider()
                end

                -- [[ PRIMARY ACTION: invite the group's members to a party ]]
                -- Hidden for the virtual fixed anchors (No Group, Empty, Offline).
                if not FriendGroups_IsFixedAnchor(groupName) then
                    local inviteText = FGL["MENU_INVITE"]
                    local inviteDisabled = false
                    if groupsCount[groupName] and groupsCount[groupName].Total and groupsCount[groupName].Total > 40 then
                        inviteDisabled = true
                        inviteText = inviteText .. FGL["MENU_MAX_40"]
                    end
                    local inviteBtn = rootDescription:CreateButton(inviteText, function()
                        FriendGroups_InviteOrGroup(groupName, true)
                    end)
                    if inviteDisabled then inviteBtn:SetEnabled(false) end
                end

                -- Banner colour for any real group (incl. Favorites & Guild), but not
                -- the virtual fixed anchors.
                if not FriendGroups_IsFixedAnchor(groupName) then
                    rootDescription:CreateDivider()
                    rootDescription:CreateButton(FGL["MENU_SET_BANNER_COLOR"], function()
                        local originalHex = ShiGuangDB.FriendGroupsbanner_colors and ShiGuangDB.FriendGroupsbanner_colors[groupName] or nil
                        
                        local function OnColorColorPicked()
                            local r, g, b = ColorPickerFrame:GetColorRGB()
                            if not ShiGuangDB.FriendGroupsbanner_colors then
                                ShiGuangDB.FriendGroupsbanner_colors = {}
                            end
                            local hexColor = string.format("%02x%02x%02x", r * 255, g * 255, b * 255)
                            ShiGuangDB.FriendGroupsbanner_colors[groupName] = hexColor
                            
                            -- Instant live refresh for visible frames while dragging the color picker
                            if FriendsListFrame and FriendsListFrame.ScrollBox and FriendsListFrame.ScrollBox.ForEachFrame then
                                FriendsListFrame.ScrollBox:ForEachFrame(function(f)
                                    if f.rawGroupName == groupName and f.solidBannerTexture then
                                        f.solidBannerTexture:SetColorTexture(r, g, b, 0.4)
                                        f.solidBannerTexture:Show()
                                    end
                                end)
                            end
                        end
                        
                        local function OnColorColorPickerCancelled()
                            if originalHex then
                                ShiGuangDB.FriendGroupsbanner_colors[groupName] = originalHex
                                local r = tonumber(string.sub(originalHex, 1, 2), 16) / 255
                                local g = tonumber(string.sub(originalHex, 3, 4), 16) / 255
                                local b = tonumber(string.sub(originalHex, 5, 6), 16) / 255
                                if FriendsListFrame and FriendsListFrame.ScrollBox and FriendsListFrame.ScrollBox.ForEachFrame then
                                    FriendsListFrame.ScrollBox:ForEachFrame(function(f)
                                        if f.rawGroupName == groupName and f.solidBannerTexture then
                                            f.solidBannerTexture:SetColorTexture(r, g, b, 0.4)
                                            f.solidBannerTexture:Show()
                                        end
                                    end)
                                end
                            else
                                if ShiGuangDB.FriendGroupsbanner_colors then
                                    ShiGuangDB.FriendGroupsbanner_colors[groupName] = nil
                                end
                                if FriendsListFrame and FriendsListFrame.ScrollBox and FriendsListFrame.ScrollBox.ForEachFrame then
                                    FriendsListFrame.ScrollBox:ForEachFrame(function(f)
                                        if f.rawGroupName == groupName and f.solidBannerTexture then
                                            f.solidBannerTexture:Hide()
                                        end
                                    end)
                                end
                            end
                            FriendGroups_FriendsListUpdate(true)
                        end
                        
                        local initR, initG, initB = 1, 1, 1
                        if originalHex then
                            initR = tonumber(string.sub(originalHex, 1, 2), 16) / 255
                            initG = tonumber(string.sub(originalHex, 3, 4), 16) / 255
                            initB = tonumber(string.sub(originalHex, 5, 6), 16) / 255
                        end

                        ColorPickerFrame:SetupColorPickerAndShow({
                            swatchFunc = OnColorColorPicked,
                            cancelFunc = OnColorColorPickerCancelled,
                            opacityFunc = nil,
                            hasOpacity = false,
                            r = initR, g = initG, b = initB
                        })
                    end)
                    
                    if ShiGuangDB.FriendGroupsbanner_colors and ShiGuangDB.FriendGroupsbanner_colors[groupName] then
                        rootDescription:CreateButton(FGL["MENU_CLEAR_BANNER_COLOR"], function()
                            ShiGuangDB.FriendGroupsbanner_colors[groupName] = nil
                            
                            -- Instant live refresh for visible frames
                            if FriendsListFrame and FriendsListFrame.ScrollBox and FriendsListFrame.ScrollBox.ForEachFrame then
                                FriendsListFrame.ScrollBox:ForEachFrame(function(f)
                                    if f.rawGroupName == groupName and f.solidBannerTexture then
                                        f.solidBannerTexture:Hide()
                                    end
                                end)
                            end
                            
                            FriendGroups_FriendsListUpdate(true)
                        end)
                    end
                end

                -- [[ IDENTITY EDIT + DESTRUCTIVE ACTION ]]
                -- Rename sits with the appearance edits; Remove is isolated below a divider
                -- and coloured red (via the locale string) to match the settings reset.
                if not isSystemGroup and not isGuildGroup then
                    rootDescription:CreateButton(FGL["MENU_RENAME"], function()
                        StaticPopup_Show("FRIENDGROUPS_RENAME", nil, nil, groupName)
                    end)

                    rootDescription:CreateDivider()
                    rootDescription:CreateButton(FGL["MENU_REMOVE"], function()
                        FriendGroups_InviteOrGroup(groupName, false)
                    end)
                end
            end)
        end
    else
        FriendGroups_FrameFriendDividerTemplateCollapseClick(self, button, down)
    end
end

function FriendGroups_GetFriendNote(id, buttonType)
	local noteText = ""

	if buttonType == FRIENDS_BUTTON_TYPE_BNET then
		local accountInfo = C_BattleNet.GetFriendAccountInfo(id)

		if accountInfo then
			noteText = accountInfo.note
		end
	elseif buttonType == FRIENDS_BUTTON_TYPE_WOW then
		noteText = C_FriendList.GetFriendInfoByIndex(id) and C_FriendList.GetFriendInfoByIndex(id).notes
	end

	return noteText
end

function FriendGroups_GetFriendFavorite(id, buttonType)
	local isFavorite = false

	if buttonType == FRIENDS_BUTTON_TYPE_BNET then
		local accountInfo = C_BattleNet.GetFriendAccountInfo(id)

		if accountInfo then
			isFavorite = accountInfo.isFavorite
		end
	end

	return isFavorite
end

function FriendGroups_SetGroupsCount()
	for groupName, groupData in pairs(groupsTotal) do
		if not groupsCount[groupName] then
			groupsCount[groupName] = {}
		end

		groupsCount[groupName].Total = 0
		groupsCount[groupName].Online = 0

		for _, playerData in ipairs(groupData) do
			local statusText = FriendGroups_GetStatusString(playerData)

			groupsCount[groupName].Total = groupsCount[groupName].Total + 1
			if statusText ~= "Offline" then
				groupsCount[groupName].Online = groupsCount[groupName].Online + 1
			end
		end
	end
end

-- ============================================================================
-- [[ FRIEND GROUPS FRAME (STATE) ]]
-- ============================================================================
FriendGroupsFrame = CreateFrame("Frame", "FriendGroupsFrame")
FriendGroupsFrame.selectionLocked = false

-- Note: We no longer need the global UIDropDownMenu templates or initialization 
-- functions here because we are using the modern inline MenuUtil API below.


-- Popups
StaticPopupDialogs["FRIENDGROUPS_CREATE"] = {
	text = FGL["POPUP_ENTER_NAME"],
	button1 = ACCEPT,
	button2 = CANCEL,
	hasEditBox = 1,
	OnAccept = function(self)
		local parent = self:GetEditBox():GetParent()
		FriendGroups_Create(parent, parent.data)
		parent:Hide()
	end,
	EditBoxOnEnterPressed = function(self)
		local parent = self:GetParent()
		FriendGroups_Create(parent, parent.data)
		parent:Hide()
	end,
	timeout = 0,
	whileDead = 1,
	hideOnEscape = 1
}
StaticPopupDialogs["FRIENDGROUPS_RENAME"] = {
	text = FGL["POPUP_ENTER_NAME"],
	button1 = ACCEPT,
	button2 = CANCEL,
	hasEditBox = 1,
	OnAccept = function(self)
		local parent = self:GetEditBox():GetParent()
		FriendGroups_Rename(parent, parent.data)
		parent:Hide()
	end,
	EditBoxOnEnterPressed = function(self)
		local parent = self:GetParent()
		FriendGroups_Rename(parent, parent.data)
		parent:Hide()
	end,
	timeout = 0,
	whileDead = 1,
	hideOnEscape = 1
}

-- [[ NEW POPUP: COPY TEXT ]] --
StaticPopupDialogs["FRIENDGROUPS_SET_NICKNAME"] = {
	text = FGL["POPUP_ENTER_NICKNAME"],
	button1 = ACCEPT,
	button2 = CANCEL,
	hasEditBox = 1,
	maxLetters = 20,
	OnAccept = function(self, data)
		local input = self:GetEditBox():GetText()
		if not ShiGuangDB.FriendGroupsnicknames then
			ShiGuangDB.FriendGroupsnicknames = {}
		end
		if input == "" then
			ShiGuangDB.FriendGroupsnicknames[data.accountIdentifier] = nil
		else
			ShiGuangDB.FriendGroupsnicknames[data.accountIdentifier] = input
		end
		FriendGroups_FriendsListUpdate(true)
	end,
	EditBoxOnEnterPressed = function(self)
		local parent = self:GetParent()
		local input = self:GetText()
		if not ShiGuangDB.FriendGroupsnicknames then
			ShiGuangDB.FriendGroupsnicknames = {}
		end
		if input == "" then
			ShiGuangDB.FriendGroupsnicknames[parent.data.accountIdentifier] = nil
		else
			ShiGuangDB.FriendGroupsnicknames[parent.data.accountIdentifier] = input
		end
		FriendGroups_FriendsListUpdate(true)
		parent:Hide()
	end,
	timeout = 0,
	whileDead = 1,
	hideOnEscape = 1
}

StaticPopupDialogs["FRIENDGROUPS_COPY_POPUP"] = {
	text = FGL["POPUP_COPY"],
	button1 = OKAY,
	timeout = 0,
	whileDead = 1,
	hideOnEscape = 1,
	hasEditBox = 1,
	preferredIndex = 3,
	OnShow = function(self)
        -- FIX: Use .EditBox (Capital E)
		if self.EditBox then
			self.EditBox:SetFocus()
			self.EditBox:HighlightText()
		end
	end,
	EditBoxOnEnterPressed = function(self)
		self:GetParent():Hide()
	end,
	EditBoxOnEscapePressed = function(self)
		self:GetParent():Hide()
	end,
}

StaticPopupDialogs["FRIENDGROUPS_IMPORT"] = {
	text = FGL["POPUP_IMPORT"],
	button1 = ACCEPT,
	button2 = CANCEL,
	hasEditBox = 1,
	timeout = 0,
	whileDead = 1,
	hideOnEscape = 1,
	preferredIndex = 3,
	OnShow = function(self)
		local eb = self:GetEditBox()
		if eb then
			eb:SetMaxLetters(0)   -- accept a large pasted backup
			eb:SetText("")
			eb:SetFocus()
		end
	end,
	OnAccept = function(self)
		FriendGroups_HandleImport(self:GetEditBox():GetText())
	end,
	EditBoxOnEnterPressed = function(self)
		local parent = self:GetParent()
		local text = self:GetText()
		parent:Hide()
		FriendGroups_HandleImport(text)
	end,
	EditBoxOnEscapePressed = function(self)
		self:GetParent():Hide()
	end,
}

-- Show the export string in the copy dialog so the user can copy it to other accounts.
function FriendGroups_ShowExport()
	if not FriendGroups_Sync then return end
	local str = FriendGroups_Sync.Export()
	if not str or str == "" then return end
	local dialog = StaticPopup_Show("FRIENDGROUPS_COPY_POPUP")
	if dialog and dialog.EditBox then
		dialog.EditBox:SetMaxLetters(0)   -- a full backup can be large; no length cap
		dialog.EditBox:SetText(str)
		dialog.EditBox:HighlightText()
		dialog.EditBox:SetFocus()
	end
end

-- Apply a pasted export string and report the localized outcome.
function FriendGroups_HandleImport(text)
	if not text or text == "" or not FriendGroups_Sync then return end
	local ok, reason, ts = FriendGroups_Sync.Import(text)
	if ok then
		if ts and ts > 0 then
			print(string.format(FGL["MSG_IMPORT_OK_DATED"], date("%Y-%m-%d %H:%M", ts)))
		else
			print(FGL["MSG_IMPORT_OK"])
		end
	elseif reason == "CHECKSUM" then
		print(FGL["MSG_IMPORT_FAIL_CHECKSUM"])
	elseif reason == "PROTOCOL" then
		print(FGL["MSG_IMPORT_FAIL_PROTOCOL"])
	else
		print(FGL["MSG_IMPORT_FAIL_FORMAT"])
	end
end

--[[
	Functions
]] --

-- Returns true when the given cached alt is this account's manually-selected main.
-- Mirrors the dual-key comparison used by the tooltip/menu (raw alt.key OR the
-- recomputed charName-cleanRealm form) so a pinned main is always protected from the
-- recent-10 cap trim.
function FriendGroups_IsManualMain(accountIdentifier, alt)
    if not accountIdentifier or type(alt) ~= "table" then return false end
    if not (ShiGuangDB.FriendGroupsmanual_mains) then return false end
    local mainKey = ShiGuangDB.FriendGroupsmanual_mains[accountIdentifier]
    if not mainKey then return false end
    if alt.key == mainKey then return true end
    local computedKey = (alt.charName or "") .. "-" .. FriendGroups_CleanRealmName(alt.realm or "")
    return computedKey == mainKey
end

-- [[ CORE ACTIVATOR ]] --
EnableFriendGroups = function()
    if FriendGroups_Loaded then return end
    FriendGroups_Loaded = true
    
		if ShiGuangDB["FriendGroupscollapsed"] == nil then ShiGuangDB["FriendGroupscollapsed"] = {} end
		if ShiGuangDB["FriendGroupshide_offline"] == nil then ShiGuangDB["FriendGroupshide_offline"] = true end
		if ShiGuangDB["FriendGroupscolour_classes"] == nil then ShiGuangDB["FriendGroupscolour_classes"] = true end
		if ShiGuangDB["FriendGroupsshow_class_icons"] == nil then ShiGuangDB["FriendGroupsshow_class_icons"] = true end
		if ShiGuangDB["FriendGroupsshow_note"] == nil then ShiGuangDB["FriendGroupsshow_note"] = true end
		if ShiGuangDB["FriendGroupsshow_status"] == nil then ShiGuangDB["FriendGroupsshow_status"] = true end
		if ShiGuangDB["FriendGroupsshow_faction_icons"] == nil then ShiGuangDB["FriendGroupsshow_faction_icons"] = true end
		if ShiGuangDB["FriendGroupsshow_faction_color"] == nil then ShiGuangDB["FriendGroupsshow_faction_color"] = true end
		if ShiGuangDB["FriendGroupsshow_game_icon"] == nil then ShiGuangDB["FriendGroupsshow_game_icon"] = true end	
		if ShiGuangDB["FriendGroupsshow_realm"] == nil then ShiGuangDB["FriendGroupsshow_realm"] = true end
		if ShiGuangDB["FriendGroupshide_high_level"] == nil then ShiGuangDB["FriendGroupshide_high_level"] = false end
		if ShiGuangDB["FriendGroupsadd_favorite_group"] == nil then ShiGuangDB["FriendGroupsadd_favorite_group"] = true end
		if ShiGuangDB["FriendGroupsgray_faction"] == nil then ShiGuangDB["FriendGroupsgray_faction"] = false end
		if ShiGuangDB["FriendGroupsshow_mobile_afk"] == nil then ShiGuangDB["FriendGroupsshow_mobile_afk"] = false end
		if ShiGuangDB["FriendGroupsadd_mobile_text"] == nil then ShiGuangDB["FriendGroupsadd_mobile_text"] = true end
		if ShiGuangDB["FriendGroupsingame_only"] == nil then ShiGuangDB["FriendGroupsingame_only"] = true end
		if ShiGuangDB["FriendGroupsingame_retail"] == nil then ShiGuangDB["FriendGroupsingame_retail"] = false end
		if ShiGuangDB["FriendGroupsshow_btag"] == nil then ShiGuangDB["FriendGroupsshow_btag"] = false end
		if ShiGuangDB["FriendGroupsshow_retail"] == nil then ShiGuangDB["FriendGroupsshow_retail"] = false end
		if ShiGuangDB["FriendGroupsshow_search"] == nil then ShiGuangDB["FriendGroupsshow_search"] = false end
		if ShiGuangDB["FriendGroupshide_empty_groups"] == nil then ShiGuangDB["FriendGroupshide_empty_groups"] = true end
		if ShiGuangDB["FriendGroupshide_afk"] == nil then ShiGuangDB["FriendGroupshide_afk"] = false end
		if ShiGuangDB["FriendGroupsopen_one_group"] == nil then ShiGuangDB["FriendGroupshide_afk"] = false end
		if ShiGuangDB["FriendGroupsauto_accept_invite"] == nil then ShiGuangDB["FriendGroupsauto_accept_invite"] = false end
		if ShiGuangDB["FriendGroupsauto_accept_sync"] == nil then ShiGuangDB["FriendGroupsauto_accept_sync"] = true end
		if ShiGuangDB["FriendGroupsauto_accept_res"] == nil then ShiGuangDB["FriendGroupshide_afk"] = false end
		if ShiGuangDB["FriendGroupsshow_flags"] == nil then ShiGuangDB["FriendGroupsshow_flags"] = true end
		if ShiGuangDB["FriendGroupsoffline_tracker"] == nil then ShiGuangDB["FriendGroupssoffline_tracker"] = true end
		if ShiGuangDB["FriendGroupswide_list"] == nil then ShiGuangDB["FriendGroupswide_list"] = false end
		if ShiGuangDB["FriendGroupswidth_normal"] == nil then ShiGuangDB["FriendGroupswidth_normal"] = false end

    -- [[ DEFAULTS MIGRATION FOR EXISTING USERS ]] --
    if ShiGuangDB.FriendGroupsshow_flags == nil then
        ShiGuangDB.FriendGroupsshow_flags = true
    end
    if ShiGuangDB.FriendGroupsshow_class_icons == nil then
        ShiGuangDB.FriendGroupsshow_class_icons = true
    end
    if ShiGuangDB.FriendGroupsshow_note == nil then
        ShiGuangDB.FriendGroupsshow_note = true
    end
    if ShiGuangDB.FriendGroupsshow_status == nil then
        ShiGuangDB.FriendGroupsshow_status = true
    end
    if ShiGuangDB.FriendGroupsshow_game_icon == nil then
        ShiGuangDB.FriendGroupsshow_game_icon = true
    end
    if ShiGuangDB.FriendGroupsshow_faction_color == nil then
        -- Faction tint used to be tied to the faction icon; preserve existing look.
        ShiGuangDB.FriendGroupsshow_faction_color = (ShiGuangDB.FriendGroupsshow_faction_icons ~= false)
    end
    if ShiGuangDB.FriendGroupsshow_contact_cap == nil then
        ShiGuangDB.FriendGroupsshow_contact_cap = true
    end
    if ShiGuangDB.FriendGroupsoffline_tracker == nil then
        ShiGuangDB.FriendGroupsoffline_tracker = true
    end
    if ShiGuangDB.FriendGroupsgroup_order == nil then
        ShiGuangDB.FriendGroupsgroup_order = {}
    end
    if ShiGuangDB.FriendGroupsmain_guild == nil then
        ShiGuangDB.FriendGroupsmain_guild = {}
    end

-- 1. Create Search Box
    FriendGroups_SearchBox = CreateFrame("EditBox", "FriendGroupsGlobalSearch", FriendsListFrame, "SearchBoxTemplate")
    FriendGroups_SearchBox:SetSize(20, 20)
    FriendGroups_SearchBox:SetPoint("TOPLEFT", FriendsListFrame, "TOPLEFT", 15, -85) 
    FriendGroups_SearchBox:SetPoint("TOPRIGHT", FriendsListFrame, "TOPRIGHT", -90, -85)
    FriendGroups_SearchBox:SetAutoFocus(false)
    FriendGroups_SearchBox.Instructions:SetText(FGL["SEARCH_PLACEHOLDER"])
    
    FriendGroups_SearchBox:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(SEARCH, 1, 1, 1) 
        GameTooltip:AddLine(FGL["SEARCH_TOOLTIP"], nil, nil, nil, true) 
        GameTooltip:Show()
    end)

    FriendGroups_SearchBox:SetScript("OnLeave", function(self)
        GameTooltip:Hide()
    end)

    local FriendGroups_SearchDebounceTimer = nil
FriendGroups_SearchBox:SetScript("OnTextChanged", function(self)
    SearchBoxTemplate_OnTextChanged(self)
    local text = self:GetText()
    if text ~= searchValue then
        searchValue = text
        searchLowerValue = text:lower() -- Cache it here once!
        
        if FriendGroups_SearchDebounceTimer then
            FriendGroups_SearchDebounceTimer:Cancel()
        end
        FriendGroups_SearchDebounceTimer = C_Timer.NewTimer(0.3, function()
            FriendGroups_FriendsListUpdate(true)
            FriendGroups_SearchDebounceTimer = nil
        end)
    end
end)
    
    -- 2. Create Contact Text Tracker
    FriendGroups_ContactText = FriendsListFrame:CreateFontString("FriendGroupsContactText", "OVERLAY", "GameFontNormalSmall")
    FriendGroups_ContactText:SetPoint("LEFT", FriendGroups_SearchBox, "RIGHT", 8, 0)
	
	-- 3. Create Settings Button
    FriendGroupsGlobalSettings = CreateFrame("Button", "FriendGroupsGlobalSettings", FriendsListFrame)
    FriendGroupsGlobalSettings:SetSize(21, 21)
    FriendGroupsGlobalSettings:SetPoint("TOPRIGHT", FriendsListFrame, "TOPRIGHT", -8, -66)
    FriendGroupsGlobalSettings:SetNormalTexture("Interface\\Buttons\\UI-OptionsButton")
    FriendGroupsGlobalSettings:SetHighlightTexture("Interface\\Buttons\\UI-Common-MouseHilight", "ADD")
	
    FriendGroupsGlobalSettings:SetScript("OnClick", function(self)
        MenuUtil.CreateContextMenu(self, function(ownerRegion, rootDescription)
            -- Sections flagged submenu=true become flyouts (Size, Group Behaviour);
            -- Filter/Appearance stay flat in the root for live tweaking; everything
            -- past the isAdvancedStart marker collects into one "Advanced" flyout.
            -- Dividers separate root sections and the sub-sections inside Advanced.
            local target = rootDescription
            local inAdvanced = false
            local firstRoot = true
            for _, item in ipairs(settingsMenuItems) do
                if item.condition and not item.condition() then
                    -- Item hidden this session (e.g. EllesmereUI not detected).
                elseif item.isAdvancedStart then
                    if not firstRoot then rootDescription:CreateDivider() end
                    target = rootDescription:CreateButton(FGL["SETTINGS_ADVANCED"])
                    local ver = "12.2.2"   --C_AddOns and C_AddOns.GetAddOnMetadata and C_AddOns.GetAddOnMetadata(addonName, "Version")
                    target:CreateTitle(string.format(FGL["SETTINGS_VERSION"], ver or ""))
                    local lastTs = ShiGuangDB.FriendGroupslast_export_time
                    if lastTs and lastTs > 0 then
                        target:CreateTitle(string.format(FGL["SETTINGS_LAST_BACKUP"], date("%Y-%m-%d %H:%M", lastTs)))
                    end
                    inAdvanced = true
                    firstRoot = false
                elseif item.isTitle and item.text ~= "" then
                    if inAdvanced then
                        -- sub-section header inside the Advanced flyout
                        target:CreateDivider()
                        target:CreateTitle(item.text)
                    elseif item.submenu then
                        if not firstRoot then rootDescription:CreateDivider() end
                        target = rootDescription:CreateButton(item.text)
                        firstRoot = false
                    else
                        if not firstRoot then rootDescription:CreateDivider() end
                        target = rootDescription
                        target:CreateTitle(item.text)
                        firstRoot = false
                    end
                elseif item.isTitle then
                    target:CreateDivider()
                elseif item.notCheckable then
                    -- Standard buttons (like Reset or the backup actions)
                    local btn = target:CreateButton(item.text, function()
                        -- Run the function. (Modern menus auto-close on button clicks)
                        if item.func then item.func() end
                    end)
                    if item.tooltip and btn and btn.SetTooltip then
                        btn:SetTooltip(function(tooltip)
                            GameTooltip_SetTitle(tooltip, item.tooltipTitle or item.text)
                            for _, line in ipairs(item.tooltip) do
                                GameTooltip_AddNormalLine(tooltip, line, true)
                            end
                        end)
                    end
                else
                    -- Checkboxes — keep the menu/flyout open for live tweaking
                    local cb = target:CreateCheckbox(
                        item.text,
                        function() return item.checked() end,
                        function() item.func() end
                    )
                    if item.tooltip and cb and cb.SetTooltip then
                        cb:SetTooltip(function(tooltip)
                            GameTooltip_SetTitle(tooltip, item.tooltipTitle or item.text)
                            for _, line in ipairs(item.tooltip) do
                                GameTooltip_AddNormalLine(tooltip, line, true)
                            end
                        end)
                    end
                end
            end
        end)
    end)
    
    FriendGroupsGlobalSettings:SetScript("OnEnter", function(self) 
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(FGL["SETTINGS_TITLE"], 1, 1, 1)
        GameTooltip:Show()
    end)
    
    FriendGroupsGlobalSettings:SetScript("OnLeave", function() GameTooltip:Hide() end)
	
	-- 3. Apply Hooks
    -- [[ SECURE LIST REFRESH (12.2.2 TAINT FIX) ]]
    -- Overwriting the FriendsList_Update global permanently taints the variable: every
    -- secure Blizzard read of it (FriendsFrame_OnEvent, FriendsFrame_CheckBattlenetStatus --
    -- these fire on BNet status churn even with the panel closed) marks that execution as
    -- FriendGroups-tainted. In rated PvP that taint collides with secret values inside the
    -- chat pipeline (HistoryKeeper access IDs, ChatConfig header widths) and FriendGroups
    -- gets blamed for whispers failing to render. A post-hook leaves the global untouched:
    -- Blizzard's update runs securely first (its flat provider briefly occupies the
    -- ScrollBox), then in the SAME execution we re-assert the grouped provider -- nothing
    -- renders in between, so there is no visible flicker -- and schedule a coalesced rebuild.
    hooksecurefunc("FriendsList_Update", function()
        FriendGroups_ReassertGroupedProvider()
        FriendGroups_RequestListUpdate()
    end)

    -- [[ EVENT DIET (12.2.2 PERF FIX) ]]
    -- With the global no longer overridden, Blizzard's native FriendsList_Update runs
    -- again -- and with the panel open it rebuilds its entire provider (one fresh
    -- C_BattleNet.GetFriendAccountInfo table per friend) on EVERY friend presence
    -- change and raid-roster event. On a large friends list in raid/M+ that meant
    -- constant GC churn, high memory use and stutter. Verified against 12.0.7 source:
    -- these two events' FriendsFrame_OnEvent branches call FriendsList_Update() and
    -- nothing else, registration happens once in FriendsFrame_OnLoad, and nothing
    -- re-registers on show/hide. The Dirty Roster Engine listens to the same events
    -- on its own frame and drives the coalesced refresh instead. The rare remaining
    -- triggers (invites, list size changes, OnShow, Battle.net status changes,
    -- periodic FRIENDLIST_UPDATE) still run the native update harmlessly.
    FriendsFrame:UnregisterEvent("BN_FRIEND_INFO_CHANGED")
    FriendsFrame:UnregisterEvent("GROUP_ROSTER_UPDATE")
    
    hooksecurefunc("FriendsFrame_UpdateFriendButton", FriendGroups_FriendsListUpdateFriendButton)
    Menu.ModifyMenu("MENU_UNIT_GLUE_FRIEND", FriendGroups_AddDropDownNew)
    Menu.ModifyMenu("MENU_UNIT_FRIEND", FriendGroups_AddDropDownNew)
    Menu.ModifyMenu("MENU_UNIT_FRIEND_OFFLINE", FriendGroups_AddDropDownNew)
    Menu.ModifyMenu("MENU_UNIT_BN_FRIEND", FriendGroups_AddDropDownNew)
    Menu.ModifyMenu("MENU_UNIT_BN_FRIEND_OFFLINE", FriendGroups_AddDropDownNew)
    hooksecurefunc(FriendsListButtonMixin, "OnClick", FriendGroups_FriendsListButtonTemplateClick)

    -- 4. Setup Scroll View
    SetupGroupedView()
    
	-- [[ UPDATED DEFAULT: Set to 380 (Large) ]] --
    if not ShiGuangDB.FriendGroupsextra_height then
        ShiGuangDB.FriendGroupsextra_height = 190
    end
    FriendGroups_UpdateSize()

    FriendGroups_UpdateContactCap()
    FriendGroups_FriendsListUpdate(true)
end

hooksecurefunc(FriendsTooltip, "Hide", function(self)
    -- [[ ANTI-FLICKER FIX: Ignore hide command if mouse is still on the Friends List ]]
    if FriendsFrame and FriendsFrame:IsMouseOver() then return end
    
    if FriendGroupsAltTooltip then FriendGroupsAltTooltip:Hide() end
    FriendGroups_CurrentHoverAnchor = nil
end)

hooksecurefunc(GameTooltip, "Hide", function(self)
    -- [[ ANTI-FLICKER FIX: Protect against GameTooltip native hide collisions ]]
    if FriendsFrame and FriendsFrame:IsMouseOver() then return end
    if CommunitiesFrame and CommunitiesFrame:IsMouseOver() then return end
    
    if FriendGroupsAltTooltip then FriendGroupsAltTooltip:Hide() end
    FriendGroups_CurrentHoverAnchor = nil
end)

SetupGroupedView = function()
    local view = CreateScrollBoxListLinearView()
    view:SetElementFactory(function(factory, elementData)
        local buttonType = elementData.buttonType;
        if buttonType == FRIENDS_BUTTON_TYPE_DIVIDER then
            factory("FriendGroupsFrameFriendDividerTemplate", FriendGroups_FriendsListUpdateDividerTemplate);
        elseif buttonType == FRIENDS_BUTTON_TYPE_INVITE_HEADER then
            factory("FriendsPendingInviteHeaderButtonTemplate",
                FriendGroups_FriendsFrameUpdateFriendInviteHeaderButton);
        elseif buttonType == FRIENDS_BUTTON_TYPE_INVITE then
            factory("FriendsFrameFriendInviteTemplate", FriendGroups_FriendsFrameUpdateFriendInviteButton);
        elseif buttonType == FRIENDS_BUTTON_TYPE_BNET or buttonType == FRIENDS_BUTTON_TYPE_WOW then
            -- 12.0 FIX: Switched from custom XML button to Blizzard's Native Secure Template
            -- This completely bypasses the Chat/Secret ID taint vector when clicking friends.
            factory("FriendsListButtonTemplate", FriendGroups_FriendsListUpdateFriendButton);
        else
            -- 12.2.2: Blizzard's native FriendsList_Update runs again (post-hook mode) and its
            -- provider can carry element types we never group (e.g. matchmaking party invites).
            -- Render those inert instead of forcing them through the friend-row initializer.
            factory("FriendGroupsFrameFriendDividerTemplate", FriendGroups_FriendsListUpdateInertTemplate);
        end
    end);
    ScrollUtil.InitScrollBoxListWithScrollBar(FriendsListFrame.ScrollBox, FriendsListFrame.ScrollBar, view);
end

-- Fixed left indent for group divider titles. Deliberately constant (does NOT
-- follow the class-icon toggle) so the group headers never shift position when
-- class icons are turned on/off.
local fgRowNameGutter = 38

-- Resolve an English class token ("MAGE") from a localized class name, or pass
-- through if it is already a token. Mirrors FriendGroups_GetClassColorCode.
local function FriendGroups_ResolveClassToken(name)
    if not name or name == "" then return nil end
    if RAID_CLASS_COLORS[name] then return name end
    for k, v in pairs(LOCALIZED_CLASS_NAMES_MALE) do if v == name then return k end end
    for k, v in pairs(LOCALIZED_CLASS_NAMES_FEMALE) do if v == name then return k end end
    return nil
end

-- Standard FriendGroups row layout:
--   * class icon of the friend's CURRENT character on the left, vertically
--     centered so it spans both text rows (like EllesmereUI);
--   * name/info indented a consistent amount to clear the icon;
--   * the online / AFK / DND status icon moved inline to just after the name
--     text (and any "aka [alt]" suffix, which is part of the name string).
-- Called at the end of the friend-button render, and re-invoked by the
-- EllesmereUI skin after it changes the row font (which alters name width).
function FriendGroups_ApplyRowLayout(button)
    if not button or not button.name then return end

    local rowH = (button:GetHeight() or 0) - 4
    local iconSize = (rowH > 0) and rowH or 16
    local showIcons = not (ShiGuangDB.FriendGroupsshow_class_icons == false)
    local leftPad = showIcons and (iconSize + 8) or 6   -- left gutter (reserved for the class icon)

    -- Class icon (current character's class).
    if not button.fgClassIcon then
        button.fgClassIcon = button:CreateTexture(nil, "ARTWORK", nil, 2)
    end
    local icon = button.fgClassIcon
    local token = showIcons and FriendGroups_ResolveClassToken(button.fgClass)
    if token then
        icon:ClearAllPoints()
        icon:SetPoint("LEFT", button, "LEFT", 4, 0)
        icon:SetSize(iconSize, iconSize)
        local atlas = GetClassAtlas and GetClassAtlas(token)
        if atlas then
            icon:SetTexCoord(0, 1, 0, 1)
            icon:SetAtlas(atlas)
        else
            icon:SetTexture("Interface\\TargetingFrame\\UI-Classes-Circles")
            local c = CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS[token]
            if c then icon:SetTexCoord(c[1], c[2], c[3], c[4]) end
        end
        icon:Show()
    else
        icon:Hide()
    end

    -- Status icon sits at the START of the row (after the class icon, before the
    -- name), vertically centered across both text rows. The name/info column
    -- follows, truncating with "..." clear of the right-side icons.
    local STATUS_W = 16
    local showStatus = not (ShiGuangDB.FriendGroupsshow_status == false)
    local nameLeft = leftPad + (showStatus and (STATUS_W + 4) or 0)

    -- Game/client icon (the "W" etc.). When disabled, hide it and shift the faction
    -- icon / realm flag right into its vacated slot so the name/info can grow (the
    -- flag is anchored to the faction icon, so it follows automatically).
    local showGameIcon = not (ShiGuangDB.FriendGroupsshow_game_icon == false)
    local gameIconW = 22
    if button.gameIcon then
        local gw = button.gameIcon:GetWidth()
        if gw and gw >= 1 then gameIconW = gw end
        if not showGameIcon then
            button.gameIcon:Hide()
            if button.facIcon and button.facIcon:IsShown() then
                button.facIcon:ClearAllPoints()
                button.facIcon:SetPoint("RIGHT", button.gameIcon, "RIGHT", 0, 0)
            elseif button.realmFlag and button.realmFlag:IsShown() then
                button.realmFlag:ClearAllPoints()
                button.realmFlag:SetPoint("RIGHT", button.gameIcon, "RIGHT", 0, 0)
            end
        end
    end

    -- Right reserve = invite button + margins, PLUS the game icon (when shown) and
    -- the faction icon / realm flag (only when shown). Toggling any of them off lets
    -- the name/info reclaim that width.
    local rightReserve = 46
    if showGameIcon then
        rightReserve = rightReserve + gameIconW + 2
    end
    if button.facIcon and button.facIcon:IsShown() then
        rightReserve = rightReserve + (button.facIcon:GetWidth() or 20) + 2
    end
    if button.realmFlag and button.realmFlag:IsShown() then
        rightReserve = rightReserve + (button.realmFlag:GetWidth() or 16) + 2
    end

    button.name:ClearAllPoints()
    button.name:SetPoint("TOPLEFT", button, "TOPLEFT", nameLeft, -4)
    button.name:SetPoint("TOPRIGHT", button, "TOPRIGHT", -rightReserve, -4)
    button.name:SetWordWrap(false)
    if button.info then
        button.info:ClearAllPoints()
        button.info:SetPoint("TOPLEFT", button.name, "BOTTOMLEFT", 0, -2)
        button.info:SetPoint("RIGHT", button.name, "RIGHT", 0, 0)
        button.info:SetWordWrap(false)
    end

    if button.status then
        if showStatus then
            button.status:ClearAllPoints()
            button.status:SetSize(STATUS_W, STATUS_W)
            -- "LEFT" anchors at the button's vertical middle -> centered across both rows.
            button.status:SetPoint("LEFT", button, "LEFT", leftPad, 0)
            button.status:Show()
        else
            button.status:Hide()
        end
    end
end

-- ============================================================================
-- [[ CHARACTER-FRIEND ROW PARITY (12.2.2b) ]]
-- ============================================================================
-- Faction lookup for character friends: C_FriendList exposes no faction field, but
-- the friend GUID resolves race -> faction through documented APIs. Positive results
-- are cached (faction is race-bound); unknowns (typically offline friends) are
-- retried because the data becomes available once they are online.
local FriendGroups_GuidFactionCache = {}
local function FriendGroups_GetWowFriendFaction(guid)
    if not guid then return nil end
    local cached = FriendGroups_GuidFactionCache[guid]
    if cached then return cached end
    local location = PlayerLocation and PlayerLocation.CreateFromGUID and PlayerLocation:CreateFromGUID(guid)
    local raceID = location and C_PlayerInfo.GetRace and C_PlayerInfo.GetRace(location)
    local factionInfo = raceID and C_CreatureInfo.GetFactionInfo and C_CreatureInfo.GetFactionInfo(raceID)
    local tag = factionInfo and factionInfo.groupTag
    if tag then FriendGroups_GuidFactionCache[guid] = tag end
    return tag
end

-- Reusable probe table so realm-flag lookups for character rows allocate nothing.
-- Character friends are always in the player's own region.
local FriendGroups_WowRealmProbe = {}

-- Travel-pass replacement for character rows. Blizzard's own OnClick resolves the
-- row id against the *BNet* friends list, so on a character row it would target
-- whichever unrelated BNet friend happens to share the index. Party invites are
-- unprotected API, so a plain script handles the character case.
local function FriendGroups_WowFriendInviteOnClick(self)
    local row = self:GetParent()
    if not (row and row.buttonType == FRIENDS_BUTTON_TYPE_WOW and row.id) then return end
    local finfo = C_FriendList.GetFriendInfoByIndex(row.id)
    if finfo and finfo.connected and finfo.name then
        PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON)
        C_PartyInfo.InviteUnit(finfo.name)
    end
end

FriendGroups_FriendsListUpdateFriendButton = function(button, elementData)
    if not FriendGroups_Loaded then return end

    local isFriendFrame = false
    if button and button.GetParent and FriendsListFrame then
        local current = button:GetParent()
        for i = 1, 15 do
            if not current then break end
            if current == FriendsListFrame then isFriendFrame = true break end
            current = current.GetParent and current:GetParent()
        end
    end
    if not isFriendFrame then return end

    -- 12.2.2b: while Blizzard's transient flat provider occupies the ScrollBox (hook
    -- mode, rare native updates), skip rendering entirely -- the grouped provider is
    -- swapped back in the same execution and every visible row is re-initialized then,
    -- so painting flat data here is pure waste.
    if FriendGroups_ActiveProvider and FriendsListFrame.ScrollBox:GetDataProvider() ~= FriendGroups_ActiveProvider then return end

	local id = elementData.id;
	local buttonType = elementData.buttonType;
	button.buttonType = buttonType;
	button.id = id;
	button.fgClass = nil;   -- current character's class (localized); set below when online
	button.fgNote = nil;    -- raw server note for this friend

	if button.facIcon then button.facIcon:Hide() end
    if button.realmFlag then button.realmFlag:Hide() end 

	local nameText, nameColor, infoText, isFavoriteFriend, statusTexture;
	local hasTravelPassButton = false;
	local isCrossFactionInvite = false;
	local inviteFaction = nil;

	if button.buttonType == FRIENDS_BUTTON_TYPE_WOW then
		local info = C_FriendList.GetFriendInfoByIndex(id);
		button.fgNote = info and info.notes;
		-- 12.2.2b: character-friend rows mirror the BNet row format -- class-coloured
		-- [Name] + level on line 1 (realm stripped), zone - realm on line 2. Cross-realm
		-- friends carry the realm in their name ("Name-Realm"); same-realm friends use
		-- the player's own realm so line 2 reads identically to a BNet row.
		local charName, charRealm = strsplit("-", info.name or "");
		if (info.connected) then
			button.background:SetColorTexture(FRIENDS_WOW_BACKGROUND_COLOR.r, FRIENDS_WOW_BACKGROUND_COLOR.g, FRIENDS_WOW_BACKGROUND_COLOR.b, FRIENDS_WOW_BACKGROUND_COLOR.a);
			if (info.afk) then button.status:SetTexture(FRIENDS_TEXTURE_AFK);
			elseif (info.dnd) then button.status:SetTexture(FRIENDS_TEXTURE_DND);
			else button.status:SetTexture(FRIENDS_TEXTURE_ONLINE); end
			button.fgClass = info.className;

			-- Level suffix: identical rules to FriendGroups_GetBNetButtonNameText.
			local levelSuffix
			if (not info.level) or (ShiGuangDB.FriendGroupshide_high_level and info.level == currentExpansionMaxLevel) or info.level == 0 then
				levelSuffix = ""
			else
				levelSuffix = " " .. info.level
			end

			-- Bracketed, class-coloured character name -- the same treatment the
			-- character portion of a BNet row gets (class conveyed by colour, not text).
			local displayName = "[" .. (charName or info.name) .. "]"
			if ShiGuangDB.FriendGroupscolour_classes and info.className then
				displayName = FriendGroups_GetClassColorCode(info.className) .. displayName .. FONT_COLOR_CODE_CLOSE
			end

			nameText = displayName .. levelSuffix;
			-- Base colour matches BNet rows so the level suffix renders identically;
			-- the name itself is class-coloured inline above.
			nameColor = FRIENDS_BNET_NAME_COLOR;
			infoText = FriendGroups_GetOnlineInfoText(BNET_CLIENT_WOW, info.mobile, info.rafLinkType, info.area, charRealm or GetRealmName());
			
			-- Game icon: BNet rows show the client icon; character friends are by
			-- definition playing Retail WoW.
			C_Texture.SetTitleIconTexture(button.gameIcon, BNET_CLIENT_WOW, Enum.TitleIconVersion.Medium);
			button.gameIcon:SetAlpha(1);
			button.gameIcon:Show();

			local factionName = FriendGroups_GetWowFriendFaction(info.guid) or ""
			isCrossFactionInvite = factionName ~= "" and factionName ~= playerFactionGroup;
			inviteFaction = factionName;

			-- Faction-crested invite button, same as BNet rows (the shared tail applies
			-- the crest atlas; the OnClick swap below guards the BNet id collision).
			hasTravelPassButton = true;
			button.travelPassButton:Enable();

			local factionShown = false
			if ShiGuangDB.FriendGroupsshow_faction_icons and factionName ~= "" then
				if not button.facIcon then
					button.facIcon = button:CreateTexture("facIcon")
					button.facIcon:SetSize(button.gameIcon:GetWidth(), button.gameIcon:GetHeight())
				end
				button.facIcon:ClearAllPoints()
				button.facIcon:SetPoint("RIGHT", button.gameIcon, "LEFT", 0, 0)
				button.facIcon:SetTexture(FriendGroups_GetFactionIcon(factionName))
				button.facIcon:Show()
				factionShown = true
			elseif button.facIcon then
				button.facIcon:Hide()
			end

			-- Faction row tint -- same toggle and colours as BNet rows.
			if ShiGuangDB.FriendGroupsshow_faction_color ~= false then
				if factionName == "Horde" then
					button.background:SetColorTexture(0.7, 0.2, 0.2, 0.2)
				elseif factionName == "Alliance" then
					button.background:SetColorTexture(0.2, 0.2, 0.7, 0.2)
				end
			end

			--[[ Realm flag -- same toggle, textures and anchoring as BNet rows.
			if ShiGuangDB.FriendGroupsshow_flags then
				if not button.realmFlag then
					button.realmFlag = button:CreateTexture("realmFlag")
					button.realmFlag:SetSize(button.gameIcon:GetWidth() * 0.75, button.gameIcon:GetHeight() * 0.75)
				end
				FriendGroups_WowRealmProbe.realmName = charRealm or GetRealmName()
				FriendGroups_WowRealmProbe.regionID = GetCurrentRegion()
				local flagTexture = FriendGroups_GetRealmInfo(FriendGroups_WowRealmProbe)
				if flagTexture then
					button.realmFlag:SetTexture(flagTexture)
					button.realmFlag:Show()
					button.realmFlag:ClearAllPoints()
					if factionShown then
						button.realmFlag:SetPoint("RIGHT", button.facIcon, "LEFT", -1, 0)
					else
						button.realmFlag:SetPoint("RIGHT", button.gameIcon, "LEFT", 0, 0)
					end
				else
					button.realmFlag:Hide()
				end
			elseif button.realmFlag then
				button.realmFlag:Hide()
			end]]
		else
			button.background:SetColorTexture(FRIENDS_OFFLINE_BACKGROUND_COLOR.r, FRIENDS_OFFLINE_BACKGROUND_COLOR.g, FRIENDS_OFFLINE_BACKGROUND_COLOR.b, FRIENDS_OFFLINE_BACKGROUND_COLOR.a);
			button.status:SetTexture(FRIENDS_TEXTURE_OFFLINE);
			nameText = charName or info.name;
			nameColor = FRIENDS_GRAY_COLOR;
			infoText = FRIENDS_LIST_OFFLINE;
			-- Keep the realm visible for offline cross-realm friends (character names
			-- are only unique per realm), honouring the same show_realm toggle.
			if ShiGuangDB.FriendGroupsshow_realm and charRealm and charRealm ~= "" then
				infoText = infoText .. " - " .. charRealm
			end
			
			button.gameIcon:Show();
			C_Texture.SetTitleIconTexture(button.gameIcon, BNET_CLIENT_WOW, Enum.TitleIconVersion.Medium);
			button.gameIcon:SetAlpha(0.3);
		end
		-- 12.2.2b: summon button now docks on the game icon exactly like BNet rows
		-- (the icon yields to the summon button when a summon is available).
		button.summonButton:ClearAllPoints();
		button.summonButton:SetPoint("CENTER", button.gameIcon, "CENTER", 1, 0);
		FriendsFrame_SummonButton_Update(button.summonButton);
		if info.connected then
			local shouldShowSummonButton = FriendsFrame_ShouldShowSummonButton(button.summonButton);
			button.gameIcon:SetShown(not shouldShowSummonButton);
		end

	elseif button.buttonType == FRIENDS_BUTTON_TYPE_BNET then
		local accountInfo = C_BattleNet.GetFriendAccountInfo(id);
		if accountInfo then
			button.fgNote = accountInfo.note;
            -- [[ MASSIVE OPTIMIZATION: Pull localized API data inline instead of running a redundant global function search ]]
			nameText, nameColor, statusTexture = FriendsFrame_GetBNetAccountNameAndStatus(accountInfo);
            
            local accountName = accountInfo.accountName
            isFavoriteFriend = accountInfo.isFavorite
            local battleTag = accountInfo.battleTag
            local canCoop = CanCooperateWithGameAccount(accountInfo)
            
            local client, characterName, class, level, timerunningSeasonID, realmName, factionName
            if accountInfo.gameAccountInfo then
                client = accountInfo.gameAccountInfo.clientProgram
                characterName = accountInfo.gameAccountInfo.characterName
                class = accountInfo.gameAccountInfo.className
                level = accountInfo.gameAccountInfo.characterLevel
                timerunningSeasonID = accountInfo.gameAccountInfo.timerunningSeasonID
                realmName = accountInfo.gameAccountInfo.realmName
                factionName = accountInfo.gameAccountInfo.factionName
            end

			if ShiGuangDB.FriendGroupsshow_mobile_afk and client == 'BSAp' then statusTexture = FRIENDS_TEXTURE_AFK end

			nameText = FriendGroups_GetBNetButtonNameText(accountName, client, canCoop, characterName, class, level, battleTag, timerunningSeasonID, realmName)
			button.status:SetTexture(statusTexture);
            
            factionName = factionName or ""
			isCrossFactionInvite = factionName ~= "" and factionName ~= playerFactionGroup;
			inviteFaction = factionName;

			if accountInfo.gameAccountInfo and accountInfo.gameAccountInfo.isOnline then
				button.fgClass = (client == BNET_CLIENT_WOW) and class or nil;
				button.background:SetColorTexture(FRIENDS_BNET_BACKGROUND_COLOR.r, FRIENDS_BNET_BACKGROUND_COLOR.g, FRIENDS_BNET_BACKGROUND_COLOR.b, FRIENDS_BNET_BACKGROUND_COLOR.a);

				if FriendGroups_ShowRichPresenceOnly(client, accountInfo.gameAccountInfo.wowProjectID, factionName, accountInfo.gameAccountInfo.realmID, accountInfo.gameAccountInfo.areaName) then
					infoText = FriendGroups_GetOnlineInfoText(client, accountInfo.gameAccountInfo.isWowMobile, accountInfo.rafLinkType, accountInfo.gameAccountInfo.richPresence);
				else
					infoText = FriendGroups_GetOnlineInfoText(client, accountInfo.gameAccountInfo.isWowMobile, accountInfo.rafLinkType, accountInfo.gameAccountInfo.areaName, realmName);
				end

				C_Texture.SetTitleIconTexture(button.gameIcon, client, Enum.TitleIconVersion.Medium);
				local fadeIcon = (client == BNET_CLIENT_WOW) and (accountInfo.gameAccountInfo.wowProjectID ~= WOW_PROJECT_ID);
				if fadeIcon then button.gameIcon:SetAlpha(0.6); else button.gameIcon:SetAlpha(1); end

				local shouldShowSummonButton = FriendsFrame_ShouldShowSummonButton(button.summonButton);
				button.gameIcon:SetShown(not shouldShowSummonButton);

				hasTravelPassButton = true;
				local restriction = FriendsFrame_GetInviteRestriction(button.id);
				if restriction == INVITE_RESTRICTION_NONE then button.travelPassButton:Enable(); else button.travelPassButton:Disable(); end

                local factionShown = false
                if ShiGuangDB.FriendGroupsshow_faction_icons then
                    if not button.facIcon then
                        button.facIcon = button:CreateTexture("facIcon")
                        button.facIcon:SetSize(button.gameIcon:GetWidth(), button.gameIcon:GetHeight())
                    end

                    button.facIcon:ClearAllPoints()
                    button.facIcon:SetPoint("RIGHT", button.gameIcon, "LEFT", 0, 0)
                    button.facIcon:SetTexture(FriendGroups_GetFactionIcon(factionName))
                    button.facIcon:Show()
                    factionShown = true
                else
                    if button.facIcon then button.facIcon:Hide() end
                end

                -- Faction row tint -- its own toggle, independent of the faction icon.
                if ShiGuangDB.FriendGroupsshow_faction_color ~= false then
                    if factionName == "Horde" then
                        button.background:SetColorTexture(0.7, 0.2, 0.2, 0.2)
                    elseif factionName == "Alliance" then
                        button.background:SetColorTexture(0.2, 0.2, 0.7, 0.2)
                    end
                end

                if ShiGuangDB.FriendGroupsshow_flags then
                    if not button.realmFlag then
                        button.realmFlag = button:CreateTexture("realmFlag")
                        button.realmFlag:SetSize(button.gameIcon:GetWidth() * 0.75, button.gameIcon:GetHeight() * 0.75)
                    end
                    
                    --[[local flagTexture, _ = FriendGroups_GetRealmInfo(accountInfo.gameAccountInfo)

                    if flagTexture then
                        button.realmFlag:SetTexture(flagTexture)
                        button.realmFlag:Show()
                        button.realmFlag:ClearAllPoints()
                        
                        if factionShown then
                            button.realmFlag:SetPoint("RIGHT", button.facIcon, "LEFT", -1, 0)
                        else
                            button.realmFlag:SetPoint("RIGHT", button.gameIcon, "LEFT", 0, 0)
                        end
                    else
                        button.realmFlag:Hide()
                    end]]
                else
                    if button.realmFlag then button.realmFlag:Hide() end
                end

			else
				button.background:SetColorTexture(FRIENDS_OFFLINE_BACKGROUND_COLOR.r, FRIENDS_OFFLINE_BACKGROUND_COLOR.g, FRIENDS_OFFLINE_BACKGROUND_COLOR.b, FRIENDS_OFFLINE_BACKGROUND_COLOR.a);
				infoText = FriendsFrame_GetLastOnlineText(accountInfo);
				
				button.gameIcon:Show();
				C_Texture.SetTitleIconTexture(button.gameIcon, BNET_CLIENT_APP, Enum.TitleIconVersion.Medium);
				button.gameIcon:SetAlpha(0.3);
			end

			if ShiGuangDB.FriendGroupsadd_mobile_text and infoText == '' and client == 'BSAp' then infoText = FGL["STATUS_MOBILE"] end

			button.summonButton:ClearAllPoints();
			button.summonButton:SetPoint("CENTER", button.gameIcon, "CENTER", 1, 0);
			FriendsFrame_SummonButton_Update(button.summonButton);
		end
	end

	-- 12.2.2b: swap the travel-pass OnClick per row type (see
	-- FriendGroups_WowFriendInviteOnClick) and restore Blizzard's handler whenever a
	-- recycled frame renders a BNet row again.
	if button.travelPassButton then
		local wantWowInvite = (buttonType == FRIENDS_BUTTON_TYPE_WOW)
		if button.fgTravelPassIsWow ~= wantWowInvite then
			button.fgTravelPassIsWow = wantWowInvite
			if not button.fgTravelPassOrigOnClick then
				button.fgTravelPassOrigOnClick = button.travelPassButton:GetScript("OnClick")
			end
			if wantWowInvite then
				button.travelPassButton:SetScript("OnClick", FriendGroups_WowFriendInviteOnClick)
			elseif button.fgTravelPassOrigOnClick then
				button.travelPassButton:SetScript("OnClick", button.fgTravelPassOrigOnClick)
			end
		end
	end

	if hasTravelPassButton then button.travelPassButton:Show(); else button.travelPassButton:Hide(); end

	-- selectionLocked replaces the old insecure nil-writes to FriendsFrame.selectedFriend:
	-- reading Blizzard's selection fields from addon code is taint-safe, writing them was not.
	-- The lock suppresses the highlight after open/close exactly like the old clear did.
	local selected = (not FriendGroupsFrame.selectionLocked) and (FriendsFrame.selectedFriendType == buttonType) and (FriendsFrame.selectedFriend == id);
	FriendsFrame_FriendButtonSetSelection(button, selected);

	if nameText then
		button.name:SetText(nameText);
		button.name:SetTextColor(nameColor.r, nameColor.g, nameColor.b);

		-- [[ WIDE LIST: stretch the name fontstring into the extra horizontal room ]]
		-- The native name region has a fixed template width (it truncates with "..."), so
		-- widening the frame alone is not enough; the fontstring needs its own extra width
		-- to actually reveal long names + full "aka" suffixes. Capture the Blizzard width
		-- once, then grow or restore it to match the current setting.
		if not button.fgOrigNameWidth then
			local nw = button.name:GetWidth()
			if nw and nw > 0 then button.fgOrigNameWidth = nw end
		end
		if button.fgOrigNameWidth then
			if ShiGuangDB.FriendGroupswide_list then
				button.name:SetWidth(button.fgOrigNameWidth + FriendGroups_WideListExtra)
			else
				button.name:SetWidth(button.fgOrigNameWidth)
			end
		end

		if ShiGuangDB.FriendGroupsshow_note ~= false and type(button.fgNote) == "string" and button.fgNote ~= "" then
			-- Show the entire note field verbatim (includes any FG group/guild tags).
			local fgNoteClean = strtrim(button.fgNote)
			if fgNoteClean and fgNoteClean ~= "" then
				infoText = (infoText and infoText ~= "") and (infoText .. "  " .. fgNoteClean) or fgNoteClean
			end
		end
		button.info:SetText(infoText);
		button:Show();
		if isFavoriteFriend then
			button.Favorite:Show();
			button.Favorite:ClearAllPoints()
			button.Favorite:SetPoint("TOPLEFT", button.name, "TOPLEFT", button.name:GetStringWidth(), 0);
		else
			button.Favorite:Hide();
		end

		FriendGroups_ApplyRowLayout(button);
	else
		button:Hide();
	end
	
	if (FriendsTooltip.button == button) or (button:IsMouseMotionFocus()) then button:OnEnter() end

	if hasTravelPassButton and isCrossFactionInvite and not GetCVarBitfield("closedInfoFrames", LE_FRAME_TUTORIAL_CROSS_FACTION_INVITE) then
		local helpTipInfo = {
			text = CROSS_FACTION_INVITE_HELPTIP,
			buttonStyle = HelpTip.ButtonStyle.Close,
			cvarBitfield = "closedInfoFrames",
			bitfieldFlag = LE_FRAME_TUTORIAL_CROSS_FACTION_INVITE,
			targetPoint = HelpTip.Point.RightEdgeCenter,
			alignment = HelpTip.Alignment.Left,
		};
		local crossFactionHelpTipInfo = helpTipInfo;
		local crossFactionHelpTipButton = button;
		HelpTip:Show(FriendsFrame, helpTipInfo, button.travelPassButton);
	end
	
	if hasTravelPassButton then
		-- Show the friend's faction crest on the invite button for BOTH same- and
		-- cross-faction friends (Blizzard only crests cross-faction; we crest either).
		if inviteFaction == "Horde" then
			button.travelPassButton.NormalTexture:SetAtlas("friendslist-invitebutton-horde-normal");
			button.travelPassButton.PushedTexture:SetAtlas("friendslist-invitebutton-horde-pressed");
			button.travelPassButton.DisabledTexture:SetAtlas("friendslist-invitebutton-horde-disabled");
		elseif inviteFaction == "Alliance" then
			button.travelPassButton.NormalTexture:SetAtlas("friendslist-invitebutton-alliance-normal");
			button.travelPassButton.PushedTexture:SetAtlas("friendslist-invitebutton-alliance-pressed");
			button.travelPassButton.DisabledTexture:SetAtlas("friendslist-invitebutton-alliance-disabled");
		else
			button.travelPassButton.NormalTexture:SetAtlas("friendslist-invitebutton-default-normal");
			button.travelPassButton.PushedTexture:SetAtlas("friendslist-invitebutton-default-pressed");
			button.travelPassButton.DisabledTexture:SetAtlas("friendslist-invitebutton-default-disabled");
		end
	end

    if not button.fgAltTooltipHooked then
        button:HookScript("OnEnter", function(self)
            FriendGroups_ShowButtonAltTooltip(self)
        end)
        button:HookScript("OnLeave", function(self)
            if FriendGroupsAltTooltip then FriendGroupsAltTooltip:Hide() end
            FriendGroups_CurrentHoverAnchor = nil
        end)
        button.fgAltTooltipHooked = true
    end

	return nil;
end

local FriendGroups_UpdateQueued = false
local FriendGroups_UpdateTimer = nil
local FriendGroups_LastDataProvider = nil
local FriendGroups_LayoutPool = {}

local function FG_GetLayoutTable()
    local t = table.remove(FriendGroups_LayoutPool) or {}
    wipe(t)
    return t
end
local function FG_ReleaseLayoutTable(t)
    if type(t) == "table" then
        wipe(t)
        table.insert(FriendGroups_LayoutPool, t)
    end
end

-- Global entry point so the separate Sync module can trigger a full rebuild
-- (FriendGroups_FriendsListUpdate itself is a file-local upvalue).
function FriendGroups_RequestFullUpdate()
    -- Invalidate cached group assignments so imported mains/guilds/order recompute.
    if FriendGroups_AssignmentCache then wipe(FriendGroups_AssignmentCache) end
    if FriendGroups_FriendsListUpdate then
        FriendGroups_FriendsListUpdate(true)
    end
    -- Force the visible list to redraw now, so an import applies without needing a scroll.
    if FriendsListFrame and FriendsListFrame.ScrollBox then
        local sb = FriendsListFrame.ScrollBox
        if sb.FullUpdate then
            sb:FullUpdate(true)
        elseif sb.Update then
            sb:Update()
        end
    end
end

function FriendGroups_FriendsListUpdate(forceUpdate)
    -- GUARD: Only run if Enabled and Visible
    if not FriendGroups_Loaded then return end

    if InCombatLockdown() then
        FriendGroups_UpdateQueued = true
        return
    end

    if (not FriendsListFrame:IsShown() and not forceUpdate) then return end

    -- Default to forcing an update if called from internal menus, clicks, or settings
    if forceUpdate == nil then forceUpdate = true end

    -- [[ MASSIVE OPTIMIZATION: IDLE GC CHURN PREVENTER ]]
    if not forceUpdate and not FriendGroups_RosterDirty then
        -- The roster structure hasn't changed. Just refresh the visible buttons (AFK timers, etc.)
        if FriendsListFrame.ScrollBox.ForEachFrame then
            FriendsListFrame.ScrollBox:ForEachFrame(function(frame)
                local elementData = frame:GetElementData()
                if elementData and (elementData.buttonType == FRIENDS_BUTTON_TYPE_BNET or elementData.buttonType == FRIENDS_BUTTON_TYPE_WOW) then
                    FriendGroups_FriendsListUpdateFriendButton(frame, elementData)
                end
            end)
        end
        return
    end

    -- Clear the flag now that we are running a full validated rebuild
    FriendGroups_RosterDirty = false

    -- [[ CACHE PRUNE SUPPORT: advance the generation for this full rebuild. ]]
    -- Every currently-present friend gets re-stamped via FriendGroups_SetGroups below; entries
    -- left on an older generation belong to removed friends and are pruned at the end.
    FriendGroups_CacheGeneration = FriendGroups_CacheGeneration + 1

    local hideGroups = ShiGuangDB.FriendGroupshide_empty_groups or (searchValue ~= "")

    -- Release memory for previous tables
    for _, groupData in pairs(groupsTotal) do
        for _, playerData in ipairs(groupData) do FG_ReleaseTable(playerData) end
        FG_ReleaseTable(groupData)
    end
    for _, countData in pairs(groupsCount) do FG_ReleaseTable(countData) end
    wipe(groupsTotal)
    wipe(groupsCount) 
    wipe(groupsSorted)

    -- Parse natively in a single linear pass & pass refs down
    local numBNetTotal = C_BattleNet.GetFriendNum and C_BattleNet.GetFriendNum() or BNGetNumFriends()
    for i = 1, numBNetTotal do
        local accountInfo = C_BattleNet.GetFriendAccountInfo(i)
        if accountInfo then
            FriendGroups_SetGroups(i, FRIENDS_BUTTON_TYPE_BNET, accountInfo)
        end
    end

    local numWoWTotal = C_FriendList.GetNumFriends()
    for i = 1, numWoWTotal do
        local info = C_FriendList.GetFriendInfoByIndex(i)
        if info then
            FriendGroups_SetGroups(i, FRIENDS_BUTTON_TYPE_WOW, info)
        end
    end

    -- Phase 1: establish the automatic hierarchy order, then record each group's auto index.
    table.sort(groupsSorted, FriendGroups_SortGroupsCustom)
    wipe(FriendGroups_AutoPos)
    for i = 1, #groupsSorted do
        FriendGroups_AutoPos[groupsSorted[i]] = i
    end

    -- Phase 2: apply any manual rank overrides on top of the automatic order.
    table.sort(groupsSorted, FriendGroups_SortGroupsByRank)

    -- Record the movable-group sequence (everything except the fixed system anchors) so the
    -- header chevrons and right-click menu can resolve neighbours and boundary state. Only
    -- groups that are actually SHOWN are included: with "hide empty groups" on, the hidden
    -- empties are skipped, so a move steps over them instead of silently swapping against a
    -- group that isn't on screen.
    wipe(FriendGroups_MovableOrder)
    wipe(FriendGroups_MovableIndex)
    for i = 1, #groupsSorted do
        local gName = groupsSorted[i]
        local shown = (not hideGroups) or (groupsTotal[gName] and #groupsTotal[gName] > 0)
        if shown and not FriendGroups_IsFixedAnchor(gName) then
            FriendGroups_MovableOrder[#FriendGroups_MovableOrder + 1] = gName
            FriendGroups_MovableIndex[gName] = #FriendGroups_MovableOrder
        end
    end

    local targetLayout = FG_GetTable()
    
    local numInvites = C_BattleNet.GetFriendNumInvites and C_BattleNet.GetFriendNumInvites() or BNGetNumFriendInvites()
    if (numInvites > 0) and not GetCVarBool("friendInvitesCollapsed") then
        for i = 1, numInvites do
            local inv = FG_GetLayoutTable()
            inv.id = i
            inv.buttonType = FRIENDS_BUTTON_TYPE_INVITE
            table.insert(targetLayout, inv)
        end
    end

    for _, groupName in ipairs(groupsSorted) do
        if (not hideGroups or (hideGroups and #groupsTotal[groupName] > 0)) then
            if ShiGuangDB.FriendGroupscollapsed[groupName] == nil then
                ShiGuangDB.FriendGroupscollapsed[groupName] = false
            end

            local div = FG_GetLayoutTable()
            div.buttonType = FRIENDS_BUTTON_TYPE_DIVIDER
            div.groupName = groupName
            table.insert(targetLayout, div)

            if not ShiGuangDB.FriendGroupscollapsed[groupName] then
                table.sort(groupsTotal[groupName], FriendGroups_SortTableByStatus)
                for _, playerData in ipairs(groupsTotal[groupName]) do
                    if playerData.buttonType and playerData.id then
                        local p = FG_GetLayoutTable()
                        p.id = playerData.id
                        p.buttonType = playerData.buttonType
                        table.insert(targetLayout, p)
                    end
                end
            end
        end
    end

    if #targetLayout == 0 then
        local empty = FG_GetLayoutTable()
        empty.buttonType = FRIENDS_BUTTON_TYPE_DIVIDER
        empty.groupName = FGL["GROUP_EMPTY"]
        table.insert(targetLayout, empty)
    end

    local dataProvider = FriendsListFrame.ScrollBox:GetDataProvider()
    -- In post-hook mode Blizzard's native update owns the ScrollBox for a moment before we
    -- re-assert; treat its flat provider as "no provider" so we never diff against it and
    -- never recycle its element tables into our layout pool.
    if dataProvider and FriendGroups_ActiveProvider and dataProvider ~= FriendGroups_ActiveProvider then
        dataProvider = nil
    end
    if not dataProvider then
        dataProvider = CreateDataProvider()
        for _, elem in ipairs(targetLayout) do dataProvider:Insert(elem) end
        FriendGroups_ActiveProvider = dataProvider
        FriendsListFrame.ScrollBox:SetDataProvider(dataProvider, true)
    else
        local currentElements = FG_GetTable()
        for index, element in dataProvider:EnumerateEntireRange() do
            table.insert(currentElements, element)
        end
        
        local needsRebuild = false
        if #currentElements ~= #targetLayout then
            needsRebuild = true
        else
            for i = 1, #targetLayout do
                local cur = currentElements[i]
                local tar = targetLayout[i]
                if cur.buttonType ~= tar.buttonType or cur.id ~= tar.id or cur.groupName ~= tar.groupName then
                    needsRebuild = true
                    break
                end
            end
        end

        if needsRebuild then
            local savedScrollPercentage = FriendsListFrame.ScrollBox:GetScrollPercentage() or 0

            for _, element in ipairs(currentElements) do
                FG_ReleaseLayoutTable(element)
            end

            -- [[ O(n) REBUILD: populate a DETACHED provider, then swap it in a single time. ]]
            -- A provider that is not yet attached to the ScrollBox has no registered listeners,
            -- so per-element Insert triggers no layout/OnSizeChanged work. SetDataProvider then
            -- performs exactly one layout pass, instead of one pass per inserted element.
            local rebuiltProvider = CreateDataProvider()
            for _, elem in ipairs(targetLayout) do
                rebuiltProvider:Insert(elem)
            end
            FriendGroups_ActiveProvider = rebuiltProvider
            FriendsListFrame.ScrollBox:SetDataProvider(rebuiltProvider, true)

            if ScrollBoxConstants and ScrollBoxConstants.NoScrollInterpolation then
                FriendsListFrame.ScrollBox:SetScrollPercentage(savedScrollPercentage, ScrollBoxConstants.NoScrollInterpolation)
            else
                FriendsListFrame.ScrollBox:SetScrollPercentage(savedScrollPercentage)
            end
        else
            for _, element in ipairs(targetLayout) do
                FG_ReleaseLayoutTable(element)
            end

            if FriendsListFrame.ScrollBox.ForEachFrame then
                FriendsListFrame.ScrollBox:ForEachFrame(function(frame)
                    local elementData = frame:GetElementData()
                    if elementData and (elementData.buttonType == FRIENDS_BUTTON_TYPE_BNET or elementData.buttonType == FRIENDS_BUTTON_TYPE_WOW) then
                        FriendGroups_FriendsListUpdateFriendButton(frame, elementData)
                    end
                end)
            end
        end
        
        FG_ReleaseTable(currentElements)
    end
    
    FG_ReleaseTable(targetLayout)

    for groupName, _ in pairs(ShiGuangDB.FriendGroupscollapsed) do
        if not groupsTotal[groupName] then
            ShiGuangDB.FriendGroupscollapsed[groupName] = nil
        end
    end

    -- [[ CACHE PRUNE: drop assignment-cache entries for friends no longer present. ]]
    -- Setting an existing key to nil during pairs() traversal is safe in Lua (only adding new
    -- keys mid-traversal is not). This runs only on full rebuilds, after every present friend
    -- has been re-stamped with the current generation above.
    for cacheKey, entry in pairs(FriendGroups_AssignmentCache) do
        if entry.generation ~= FriendGroups_CacheGeneration then
            FriendGroups_AssignmentCache[cacheKey] = nil
        end
    end
    
    FriendGroups_UpdateContactCap() 
end

function FriendGroups_FilterTable(tableData, filterFunction)
	local returnTable = {}

	for key, value in pairs(tableData) do
		if filterFunction(value, key, tableData) then table.insert(returnTable, value) end
	end

	return returnTable
end

--[[function FriendGroups_GetRealmInfo(gameAccountInfo)
    if not gameAccountInfo then return nil, nil end

    -- Secure String Verification up front
    local rawRealm = gameAccountInfo.realmName
    local safeRealm = (type(rawRealm) == "string") and rawRealm or ""
    
    local richPresence = gameAccountInfo.richPresence
    local safePresence = (type(richPresence) == "string") and richPresence or ""

    -- 1. Determine Region Database using API RegionID
    local rid = gameAccountInfo.regionID
    local database = FriendGroups_RealmData -- Default Region 1 (US/Oceanic)
    
    if rid == 3 then 
        database = FriendGroups_RealmDataEU 
    elseif rid == 2 or rid == 4 or rid == 5 then 
        database = FriendGroups_RealmDataAsia
    end

    -- 2. Standard Realm Lookup (Spaces & Punctuation stripped to match our normalized DB)
    if safeRealm ~= "" then
        local cleanRealm = safeRealm:gsub("[%s%p]+", "")
        local data = database[cleanRealm]
        
        if data then
            return "Interface\\AddOns\\FriendGroups\\Textures\\" .. data.icon, data.region
        end
    end
    
    -- 3. Fallback: Parse Rich Presence for Classic or Hidden Realms
    if safePresence ~= "" then
        -- First try extracting the realm placed after the hyphen (standard API format)
        local extraction = safePresence:match("%s%-%s(.+)$")
        
        -- If no hyphen, use the full string (e.g. they are just logged into "Nightslayer" char screen)
        local cleanEx = (extraction or safePresence):gsub("[%s%p]+", "")
        
        local data = database[cleanEx]
        if data then
            return "Interface\\AddOns\\FriendGroups\\Textures\\" .. data.icon, data.region
        end
    end

    return nil, nil
end

-- Safe renderer for element types FriendGroups does not manage (they can only appear
-- while Blizzard's native flat provider transiently occupies the ScrollBox in post-hook
-- mode). Clears every recycled divider region so no stale group header shows through.
function FriendGroups_FriendsListUpdateInertTemplate(frame, elementData)
    frame.rawGroupName = nil
    if frame.name then frame.name:Hide() end
    if frame.info then frame.info:Hide() end
    if frame.collapseButton then frame.collapseButton:Hide() end
end]]

function FriendGroups_FriendsListUpdateDividerTemplate(frame, elementData)
    local groupName = elementData.groupName
    local groupOnline = groupsCount[groupName] and groupsCount[groupName]["Online"] or 0
    local groupTotal = groupsCount[groupName] and groupsCount[groupName]["Total"] or 0

    if groupName and frame.name then
        frame.rawGroupName = groupName -- Store raw name for click handlers
        
        -- Cleanup any old search/settings items if they exist on this recycled frame
        if _G["FriendGroupsSearch"] and _G["FriendGroupsSearch"]:GetParent() == frame then
             _G["FriendGroupsSearch"]:Hide()
        end
        local settingsBtn = _G["FriendGroupsSettingsBtn"]
        if settingsBtn and settingsBtn:GetParent() == frame then 
            settingsBtn:Hide() 
        end
        
        -- Standard Header Setup
        frame.name:Show()
        
        local displayGroupName = groupName
        if groupName == FGL["GROUP_GUILDMATES"] and FriendGroups_PlayerGuildName and FriendGroups_PlayerGuildName ~= "" then
            displayGroupName = string.format(FGL["FORMAT_GUILD_TAG"], groupName, FriendGroups_PlayerGuildName)
        end
        frame.name:SetText(displayGroupName)

        -- Left-align the group title to the same column where friend names begin
        -- (class-icon gutter factored in), instead of the template's centered width.
        frame.name:SetJustifyH("LEFT")
        frame.name:ClearAllPoints()
        frame.name:SetPoint("TOPLEFT", frame, "TOPLEFT", fgRowNameGutter, -4)
        -- Extend to nearly the full usable width, reserving room only for the
        -- right-aligned online/total count. (Anchoring to frame.info is wrong:
        -- its fontstring box is ~226px wide, which squeezed the title to ellipses.)
        frame.name:SetPoint("RIGHT", frame, "RIGHT", -58, 0)

        frame.collapseButton:Show()
        if frame.info then frame.info:Show() end

        if groupName ~= FGL["GROUP_EMPTY"] then
            local groupInfo = string.format("%d/%d", groupOnline, groupTotal)
            
            -- [[ NEW: Hide the "0/" for Offline Virtual Groups ]]
            if groupName == FGL["GROUP_OFFLINE_1"] or groupName == FGL["GROUP_OFFLINE_2"] or groupName == FGL["GROUP_OFFLINE_3"] then
                groupInfo = tostring(groupTotal)
            end
            
            if frame.info then frame.info:SetText(groupInfo) end
            
            if ShiGuangDB.FriendGroupscollapsed[groupName] then
                frame.collapseButton:SetNormalAtlas("Campaign_HeaderIcon_Closed")
            else
                frame.collapseButton:SetNormalAtlas("Campaign_HeaderIcon_Open")
            end
        else
            frame.collapseButton:Hide()
            if frame.info then frame.info:SetText("") end
        end

        frame:SetHighlightTexture("Interface\\Buttons\\UI-PlusButton-Hilight")
        frame:GetHighlightTexture():SetAlpha(0.2)

        if not frame.solidBannerTexture then
            frame.solidBannerTexture = frame:CreateTexture(nil, "BACKGROUND")
            frame.solidBannerTexture:SetAllPoints(frame)
        end

        if ShiGuangDB.FriendGroupsbanner_colors and ShiGuangDB.FriendGroupsbanner_colors[groupName] then
            local hex = ShiGuangDB.FriendGroupsbanner_colors[groupName]
            local r = tonumber(string.sub(hex, 1, 2), 16) / 255
            local g = tonumber(string.sub(hex, 3, 4), 16) / 255
            local b = tonumber(string.sub(hex, 5, 6), 16) / 255
            frame.solidBannerTexture:SetColorTexture(r, g, b, 0.4)
            frame.solidBannerTexture:Show()
        else
            frame.solidBannerTexture:Hide()
        end

        if not frame.fgTooltipHooked then
            frame:HookScript("OnEnter", function(self)
                local hGroupName = self.rawGroupName or (self.name and self.name:GetText())
                if not hGroupName then return end
                
                local isGuildGroup = string.find(hGroupName, FGL["GROUP_GUILDMATES"], 1, true)
                local isSystemGroup = (hGroupName == FGL["GROUP_NONE"] or hGroupName == FGL["GROUP_FAVORITES"] or hGroupName == FGL["GROUP_EMPTY"] or hGroupName == "" or hGroupName == FGL["GROUP_OFFLINE_1"] or hGroupName == FGL["GROUP_OFFLINE_2"] or hGroupName == FGL["GROUP_OFFLINE_3"])

                if isGuildGroup then
                    local guildNameMatch = string.match(hGroupName, "<(.-)>")
                    if guildNameMatch then
                        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                        GameTooltip:SetText(FGL["TOOLTIP_GUILD_GROUP_TITLE"], 1, 0.82, 0)
                        GameTooltip:AddLine(FGL["TOOLTIP_GUILD_GROUP_DESC_1"], 1, 1, 1, true)
                        GameTooltip:AddLine(string.format(FGL["TOOLTIP_GUILD_GROUP_DESC_2"], "<" .. guildNameMatch .. ">"), 1, 0.82, 0, true)
                        GameTooltip:AddLine(" ")
                        GameTooltip:AddLine(FGL["TOOLTIP_GROUP_COLOR_PICKER_NOTE"], 0, 1, 0, true)
                        GameTooltip:Show()
                    end
                elseif not isSystemGroup then
                    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                    GameTooltip:SetText(FGL["TOOLTIP_CUSTOM_GROUP_TITLE"], 1, 0.82, 0)
                    GameTooltip:AddLine(string.format(FGL["TOOLTIP_CUSTOM_GROUP_DESC_1"], hGroupName), 1, 1, 1, true)
                    GameTooltip:AddLine(" ")
                    GameTooltip:AddLine(FGL["TOOLTIP_GROUP_COLOR_PICKER_NOTE"], 0, 1, 0, true)
                    GameTooltip:Show()
                end
            end)
            frame:HookScript("OnLeave", function(self)
                GameTooltip:Hide()
            end)
            frame.fgTooltipHooked = true
        end
    end
end

function FriendGroups_UpdateContactCap()
    if not FriendGroups_ContactText then return end
    
    -- Hardcoded server limits (as Blizzard API does not provide a query for maximums)
    local BNET_MAX_FRIENDS = 600
    local WOW_MAX_FRIENDS = 100

    local numBNetTotal = C_BattleNet and C_BattleNet.GetFriendNum and C_BattleNet.GetFriendNum() or BNGetNumFriends() or 0
    local numBNetInvites = C_BattleNet and C_BattleNet.GetFriendNumInvites and C_BattleNet.GetFriendNumInvites() or BNGetNumFriendInvites() or 0
    local bnetConsumed = numBNetTotal + numBNetInvites

    -- Main text: Strict BNet Tracker
    FriendGroups_ContactText:SetText(string.format(FGL["TEXT_BNET_CONTACTS"], bnetConsumed, BNET_MAX_FRIENDS))
    
    -- Turn RED if at or over the BNet cap, otherwise White
    if bnetConsumed >= BNET_MAX_FRIENDS then
        FriendGroups_ContactText:SetTextColor(1, 0, 0) 
    else
        FriendGroups_ContactText:SetTextColor(1, 1, 1) 
    end
    
    FriendGroups_ContactText:Show()

    -- Lazy Load the invisible hover frame for the tooltip
    if not FriendGroups_ContactHoverFrame then
        FriendGroups_ContactHoverFrame = CreateFrame("Frame", "FriendGroupsContactHoverFrame", FriendsListFrame)
        FriendGroups_ContactHoverFrame:SetPoint("TOPLEFT", FriendGroups_ContactText, "TOPLEFT", 0, 0)
        FriendGroups_ContactHoverFrame:SetPoint("BOTTOMRIGHT", FriendGroups_ContactText, "BOTTOMRIGHT", 0, 0)
        
        FriendGroups_ContactHoverFrame:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(FGL["TOOLTIP_CONTACT_TITLE"], 1, 1, 1)
            GameTooltip:AddLine(FGL["TOOLTIP_CONTACT_DESC"], nil, nil, nil, true)
            GameTooltip:AddLine(" ")
            
            -- Dynamic values inside tooltip
            local bTotal = C_BattleNet and C_BattleNet.GetFriendNum and C_BattleNet.GetFriendNum() or BNGetNumFriends() or 0
            local bInvites = C_BattleNet and C_BattleNet.GetFriendNumInvites and C_BattleNet.GetFriendNumInvites() or BNGetNumFriendInvites() or 0
            local wTotal = C_FriendList.GetNumFriends() or 0
            
            GameTooltip:AddDoubleLine(FGL["TOOLTIP_CONTACT_BNET"], string.format("%d / %d", bTotal, BNET_MAX_FRIENDS), 1, 1, 1)
            GameTooltip:AddDoubleLine(FGL["TOOLTIP_CONTACT_INVITES"], string.format("%d %s", bInvites, FGL["TOOLTIP_CONTACT_INV_DESC"]), 1, 1, 1, 1, 0.5, 0)
            GameTooltip:AddDoubleLine(FGL["TOOLTIP_CONTACT_WOW"], string.format("%d / %d", wTotal, WOW_MAX_FRIENDS), 1, 1, 1)
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine(string.format(FGL["TOOLTIP_CONTACT_TOTAL"], bTotal + wTotal), 1, 0.82, 0)
            
            GameTooltip:Show()
        end)
        
        FriendGroups_ContactHoverFrame:SetScript("OnLeave", function(self)
            GameTooltip:Hide()
        end)
    end
end

function FriendGroups_FrameFriendDividerTemplateCollapseClick(self, button, down)
    -- FIX: Determine if 'self' is the Header (has .name) or the Button (needs .GetParent)
    local frame = self
    if not frame.name then
        frame = self:GetParent()
    end

    -- Safety check: If we still can't find the name, stop to prevent crash
    if not frame or not frame.name then return end

    local groupName = frame.rawGroupName or frame.name:GetText()
    
    if groupName then
        -- Toggle the collapsed state
        if ShiGuangDB.FriendGroupscollapsed[groupName] then
            ShiGuangDB.FriendGroupscollapsed[groupName] = false
        else
            ShiGuangDB.FriendGroupscollapsed[groupName] = true
        end
        
        -- Handle "Open only one group" setting
        if ShiGuangDB.FriendGroupsopen_one_group and not ShiGuangDB.FriendGroupscollapsed[groupName] then
            for key, _ in pairs(ShiGuangDB.FriendGroupscollapsed) do
                if key ~= groupName then
                    ShiGuangDB.FriendGroupscollapsed[key] = true
                end
            end
        end
        
        -- Refresh the list
        FriendGroups_FriendsListUpdate()
    end
end

FriendGroups_FriendsListButtonTemplateClick = function(self, button, down)
    if not FriendGroups_Loaded then return end 
    
    FriendGroupsFrame.selectionLocked = false
    
    C_Timer.After(0.05, function()
        FriendGroups_FriendsListUpdate()
    end)
end

function FriendGroups_FriendsFrameUpdateFriendInviteHeaderButton(button, elementData)
    local numInvites = C_BattleNet.GetFriendNumInvites and C_BattleNet.GetFriendNumInvites() or BNGetNumFriendInvites()
	button:SetFormattedText(FRIEND_REQUESTS, numInvites);
	local collapsed = GetCVarBool("friendInvitesCollapsed");
	if (collapsed) then
		button.DownArrow:Hide();
		button.RightArrow:Show();
	else
		button.DownArrow:Show();
		button.RightArrow:Hide();
	end
end

function FriendGroups_FriendsFrameUpdateFriendInviteButton(button, elementData)
	local id = elementData.id;
	button.buttonType = elementData.buttonType;
	button.id = id;

    local inviteID, accountName
    if C_BattleNet and C_BattleNet.GetFriendInviteInfo then
        local inviteInfo = C_BattleNet.GetFriendInviteInfo(id)
        if inviteInfo then
            inviteID = inviteInfo.inviteID
            accountName = inviteInfo.accountName
        end
    else
        inviteID, accountName = BNGetFriendInviteInfo(id)
    end

	button.Name:SetText(accountName);
	button.inviteID = inviteID;
	button.inviteIndex = button.id;
end

-- ============================================================================
-- [[ FRIENDGROUPS SECURE HOUSING PROXY ]]
-- ============================================================================
local FG_Osirisnz_HousingProxy = nil
local FG_Osirisnz_HookedButtons = {}

local function Osirisnz_ActuallyWorkingVisitHouse()
    if FG_Osirisnz_HousingProxy then return FG_Osirisnz_HousingProxy end
    if InCombatLockdown() then return nil end 

    -- CRITICAL: Uses SecureActionButtonTemplate AND SecureHandlerStateTemplate
    FG_Osirisnz_HousingProxy = CreateFrame("Button", "FriendGroups_Osirisnz_SecureHouseProxy", UIParent, "SecureActionButtonTemplate, SecureHandlerStateTemplate")
    FG_Osirisnz_HousingProxy:SetFrameStrata("DIALOG")
    FG_Osirisnz_HousingProxy:SetFrameLevel(9999)
    FG_Osirisnz_HousingProxy:Hide()
    FG_Osirisnz_HousingProxy:RegisterForClicks("AnyUp", "AnyDown")
    FG_Osirisnz_HousingProxy:SetAttribute("type", "visithouse")

    -- Securely hide on combat start without causing Lua taint
    RegisterStateDriver(FG_Osirisnz_HousingProxy, "combatstate", "[combat] combat; nocombat")
    FG_Osirisnz_HousingProxy:SetAttribute("_onstate-combatstate", [[
        if newstate == "combat" then
            self:Hide()
            self:ClearAllPoints()
        end
    ]])

    FG_Osirisnz_HousingProxy:SetScript("OnEnter", function(self)
        if self.nativeButton then
            self.nativeButton:LockHighlight()
        end
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(HOUSING_VISIT_HOUSE or "Visit House", 1, 1, 1)
        GameTooltip:Show()
    end)

    FG_Osirisnz_HousingProxy:SetScript("OnLeave", function(self)
        GameTooltip:Hide()
        self:Hide()
        self:ClearAllPoints()
        if self.nativeButton then
            self.nativeButton:UnlockHighlight()
        end
        self.nativeButton = nil
    end)
    
    FG_Osirisnz_HousingProxy:SetScript("OnMouseDown", function(self)
        if self.nativeButton then self.nativeButton:SetButtonState("PUSHED") end
    end)
    
    FG_Osirisnz_HousingProxy:SetScript("OnMouseUp", function(self)
        if self.nativeButton then self.nativeButton:SetButtonState("NORMAL") end
    end)

    return FG_Osirisnz_HousingProxy
end

local function Osirisnz_OnHouseButtonEnter(nativeButton)
    if InCombatLockdown() then return end 

    local parentRow = nativeButton:GetParent()
    local houseInfo = parentRow and parentRow.houseInfo
    
    if not houseInfo or not houseInfo.neighborhoodGUID or not houseInfo.houseGUID then return end

    local proxy = Osirisnz_ActuallyWorkingVisitHouse()
    if not proxy then return end

    proxy:ClearAllPoints()
    proxy:SetAllPoints(nativeButton)

    proxy:SetAttribute("house-neighborhood-guid", houseInfo.neighborhoodGUID)
    proxy:SetAttribute("house-guid", houseInfo.houseGUID)
    proxy:SetAttribute("house-plot-id", houseInfo.plotID)

    proxy.nativeButton = nativeButton
    
    proxy:Show()
    if proxy:GetScript("OnEnter") then
        proxy:GetScript("OnEnter")(proxy)
    end
end

local function Osirisnz_InitHousingScrollBox()
    local houseFrame = _G.HouseListFrame
    if not houseFrame or not houseFrame.ScrollBox then return end

    houseFrame.ScrollBox:RegisterCallback("OnInitializedFrame", function(_, frame)
        local btn = frame.VisitHouseButton
        if btn and not FG_Osirisnz_HookedButtons[btn] then
            btn:HookScript("OnEnter", Osirisnz_OnHouseButtonEnter)
            FG_Osirisnz_HookedButtons[btn] = true
        end
    end)
end

-- ============================================================================
-- [[ MAIN INITIALIZATION ]]
-- ============================================================================
local frame = CreateFrame("frame", "FriendGroups")
frame:RegisterEvent("PLAYER_LOGIN")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
frame:RegisterEvent("ADDON_LOADED")

frame:SetScript("OnEvent", function(self, event, arg1, ...)
if event == "ADDON_LOADED" and arg1 == "OrzUI" then
        -- Intentionally silent on load (no login chat message)

    elseif event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then
        
        local myBattleTag = nil
        if C_BattleNet and C_BattleNet.GetAccountInfoByGUID then
            local accInfo = C_BattleNet.GetAccountInfoByGUID(UnitGUID("player"))
            if accInfo then myBattleTag = accInfo.battleTag end
        end
        ShiGuangDB.FriendGroupsMyBattleTag = myBattleTag
        
    elseif event == "PLAYER_LOGIN" then
        
        if ShiGuangDB.FriendGroupsshow_guildmates == nil then ShiGuangDB.FriendGroupsshow_guildmates = true end
        -- 12.2.2: the legacy list-refresh fallback was removed after the alpha; clear
        -- the orphaned saved variable so no tester stays silently on the old mode.
        ShiGuangDB.FriendGroupslegacy_list_update = nil
        
        -- [[ MASTER WIPE WHEN TRACKING IS DISABLED ]]
        -- When alt tracking is enabled we intentionally retain every cached character
        -- indefinitely (no time-based expiry) so the known-alt history grows as complete
        -- as possible. Data only leaves the cache via the recent-10 cap, the manual purge
        -- button, or disabling tracking (the wipe below).
        if ShiGuangDB.FriendGroupsshow_known_alts == false then
            if ShiGuangDB.FriendGroupsalt_cache then wipe(ShiGuangDB.FriendGroupsalt_cache) end
            if ShiGuangDB.FriendGroupsguid_index then wipe(ShiGuangDB.FriendGroupsguid_index) end
        end

        EnableFriendGroups()

        -- 12.2.2 TAINT FIX: never write FriendsFrame.selectedFriend/-Type from addon code
        -- (insecure writes taint fields Blizzard's secure handlers read). selectionLocked
        -- alone now provides the "selection cleared on open/close" visual behaviour.
        FriendsFrame:HookScript("OnHide", function()
            FriendGroupsFrame.selectionLocked = true
        end)
        FriendsFrame:HookScript("OnShow", function()
            FriendGroupsFrame.selectionLocked = true
            FriendGroups_FriendsListUpdate(true)
        end)
        
        if C_AddOns.IsAddOnLoaded("Blizzard_HouseList") then Osirisnz_InitHousingScrollBox() end
        if C_AddOns.IsAddOnLoaded("Blizzard_Communities") then FriendGroups_InitCommunitiesHook() end

    elseif event == "ADDON_LOADED" and arg1 == "Blizzard_HouseList" then
        Osirisnz_InitHousingScrollBox()
    elseif event == "ADDON_LOADED" and arg1 == "Blizzard_Communities" then
        FriendGroups_InitCommunitiesHook()
    end
end)

-- ============================================================================
-- [[ AUTOMATION LOGIC ]]
-- ============================================================================

local function FG_IsPlayerBusy()
    if InCombatLockdown() then return true end
    
    local inInstance, instanceType = IsInInstance()
    if inInstance then
        if instanceType == "pvp" or instanceType == "arena" then return true end
        if instanceType == "party" and C_ChallengeMode and C_ChallengeMode.IsChallengeModeActive() then return true end
        if IsEncounterInProgress() then return true end
    end
    
    return false
end

local FriendGroups_Automation = CreateFrame("Frame")
FriendGroups_Automation:RegisterEvent("PARTY_INVITE_REQUEST")
FriendGroups_Automation:RegisterEvent("RESURRECT_REQUEST")
FriendGroups_Automation:RegisterEvent("PLAYER_DEAD")

pcall(function()
    FriendGroups_Automation:RegisterEvent("QUEST_SESSION_CREATED")
end)

FriendGroups_Automation:SetScript("OnEvent", function(self, event, ...)
    -- 1. Auto Accept Group Invites
    if event == "PARTY_INVITE_REQUEST" then
        if FG_IsPlayerBusy() then return end 
        
        local inviterName = ...
        if ShiGuangDB.FriendGroupsauto_accept_invite then
            if FGL["MSG_AUTO_INVITE"] then
                DEFAULT_CHAT_FRAME:AddMessage(string.format(FGL["MSG_AUTO_INVITE"], inviterName or "Unknown"))
            end
            
            -- Removed Timer wrapper to preserve secure hardware event payload
            -- 12.2.2 TAINT FIX: no StaticPopup_Hide here -- Blizzard's own secure handlers
            -- dismiss the invite popups once the invite resolves; hiding them from addon
            -- code manipulated secure dialog state from a tainted path.
            local success = pcall(AcceptGroup)
            if not success then
                if FGL["MSG_AUTO_ACCEPT_FAILED"] then
                    DEFAULT_CHAT_FRAME:AddMessage(FGL["MSG_AUTO_ACCEPT_FAILED"])
                end
            end
        end

    -- 2. Auto Accept Resurrection
    elseif event == "RESURRECT_REQUEST" then
        local inviterName = ...
        if ShiGuangDB.FriendGroupsauto_accept_res then
            if FGL["MSG_AUTO_RES"] then
                DEFAULT_CHAT_FRAME:AddMessage(string.format(FGL["MSG_AUTO_RES"], inviterName or "Unknown"))
            end
            
            -- Removed Timer wrapper to preserve secure hardware event payload
            -- 12.2.2 TAINT FIX: no StaticPopup_Hide here -- the resurrect dialog dismisses
            -- itself through Blizzard's secure handlers once the resurrect is accepted.
            local success = pcall(AcceptResurrect)
            if not success then
                if FGL["MSG_AUTO_ACCEPT_FAILED"] then
                    DEFAULT_CHAT_FRAME:AddMessage(FGL["MSG_AUTO_ACCEPT_FAILED"])
                end
            end
        end
        
    -- 3. Auto Release Spirit
    elseif event == "PLAYER_DEAD" then
        if ShiGuangDB.FriendGroupsauto_release then
            if FGL["MSG_AUTO_RELEASE"] then
                DEFAULT_CHAT_FRAME:AddMessage(FGL["MSG_AUTO_RELEASE"])
            end

            local selfResOptions = C_DeathInfo.GetSelfResurrectOptions()
            if not selfResOptions or #selfResOptions == 0 then 
                local success = pcall(RepopMe)
                if not success then
                    if FGL["MSG_AUTO_RELEASE_FAILED"] then
                        DEFAULT_CHAT_FRAME:AddMessage(FGL["MSG_AUTO_RELEASE_FAILED"])
                    end
                end
            end
        end
        
    -- 4. Auto Accept Party Sync
    elseif event == "QUEST_SESSION_CREATED" then
        if ShiGuangDB.FriendGroupsauto_accept_sync then
            if UnitIsGroupLeader("player") then return end
            if InCombatLockdown() then return end
            
            local leaderName = "Party Leader"
            if IsInGroup() then
                for i = 1, 4 do
                    local unit = "party"..i
                    if UnitIsGroupLeader(unit) then
                        leaderName = UnitName(unit) or leaderName
                        break
                    end
                end
            end

            if FGL["MSG_AUTO_SYNC"] then
                DEFAULT_CHAT_FRAME:AddMessage(string.format(FGL["MSG_AUTO_SYNC"], leaderName))
            end

            if C_QuestSession and C_QuestSession.SendSessionBeginResponse then
                C_QuestSession.SendSessionBeginResponse(true)
            end
        end
    end
end)

-- ============================================================================
-- [[ KNOWN ALTS SPYGLASS ENGINE & STATIC PANEL ]]
-- ============================================================================

FriendGroups_SaveToAltCache = function(accountIdentifier, gameInfo)
    if ShiGuangDB.FriendGroupsshow_known_alts == false then return end
    if not accountIdentifier or not gameInfo then return end
    if not gameInfo.isOnline or gameInfo.clientProgram ~= BNET_CLIENT_WOW then return end

    -- [[ FRESH-INSTALL GUARD ]]
    -- SetGroups can reach this writer during the very first list build, before the
    -- background tracker (BN_FRIEND_INFO_CHANGED) has lazily created alt_cache. Mirror
    -- the tracker's own guard so the table always exists before it is indexed below,
    -- preventing a nil-index on a fresh install with no saved cache yet.
    --if not FriendGroups_SavedVars then return end
    if type(ShiGuangDB.FriendGroupsalt_cache) ~= "table" then
        ShiGuangDB.FriendGroupsalt_cache = {}
    end
    
    local charName = (type(gameInfo.characterName) == "string") and gameInfo.characterName or ""
    if charName == "" then return end
    
    local rawRealm = gameInfo.realmName
    if not rawRealm or rawRealm == "" then rawRealm = gameInfo.realmDisplayName end
    if (not rawRealm or rawRealm == "") and type(gameInfo.richPresence) == "string" then
        local extraction = gameInfo.richPresence:match("%s%-%s(.+)")
        if extraction then rawRealm = extraction end
    end
    if not rawRealm or rawRealm == "" then return end

    local displayRealm = (type(rawRealm) == "string") and rawRealm or ""
    local normalizedRealm = FriendGroups_CleanRealmName(displayRealm) 
    local uniqueKey = charName .. "-" .. normalizedRealm
    
    local class = (type(gameInfo.className) == "string") and gameInfo.className or ""
    local faction = (type(gameInfo.factionName) == "string") and gameInfo.factionName or ""
    local zone = (type(gameInfo.areaName) == "string") and gameInfo.areaName or FGL["UNKNOWN"]
    local level = tonumber(gameInfo.characterLevel) or 1
    local project = gameInfo.wowProjectID or WOW_PROJECT_MAINLINE

    local detectedGuild = nil
    if FriendGroups_LiveGuildSessionDict[uniqueKey] or FriendGroups_LiveGuildSessionDict[charName] then
        detectedGuild = FriendGroups_PlayerGuildName
    end
    
    if type(ShiGuangDB.FriendGroupsalt_cache[accountIdentifier]) ~= "table" then
        ShiGuangDB.FriendGroupsalt_cache[accountIdentifier] = {}
    end
    
    local altList = ShiGuangDB.FriendGroupsalt_cache[accountIdentifier]
    local existingIndex = nil
    
    for i = 1, #altList do
        local existingRealm = altList[i].realm or ""
        local existingNorm = FriendGroups_CleanRealmName(existingRealm)
        local existingMigratedKey = altList[i].charName .. "-" .. existingNorm

        if altList[i].key == uniqueKey or existingMigratedKey == uniqueKey or altList[i].key == (charName .. "-") then
            existingIndex = i
            break
        end
    end
    
    if existingIndex then
        local existingAlt = altList[existingIndex]
        local dataChanged = false
        
        if existingAlt.level ~= level then existingAlt.level = level; dataChanged = true end
        if existingAlt.zone ~= zone then existingAlt.zone = zone; dataChanged = true end
        if existingAlt.class ~= class then existingAlt.class = class; dataChanged = true end
        if existingAlt.faction ~= faction then existingAlt.faction = faction; dataChanged = true end
        if existingAlt.realm ~= displayRealm then existingAlt.realm = displayRealm; dataChanged = true end
        if existingAlt.project ~= project then existingAlt.project = project; dataChanged = true end
        
        -- Pre-calculate and store search strings to prevent GC search stutter
        existingAlt.searchName = charName:lower()
        existingAlt.searchRealm = displayRealm:lower()
        existingAlt.searchClass = class:lower()
        existingAlt.searchZone = zone:lower()

        -- STRICT PRESERVATION: Only update if a valid guild was detected. Never overwrite with nil.
        if detectedGuild and existingAlt.guild ~= detectedGuild then 
            existingAlt.guild = detectedGuild; dataChanged = true 
        end
        
        if dataChanged then
            existingAlt.timestamp = time()
        end
    else
        table.insert(altList, 1, {
            key = uniqueKey,
            charName = charName,
            realm = displayRealm, 
            level = level,
            class = class,
            faction = faction,
            zone = zone,
            guild = detectedGuild,
            project = project,
            timestamp = time(),
            -- Pre-calculate and store search strings
            searchName = charName:lower(),
            searchRealm = displayRealm:lower(),
            searchClass = class:lower(),
            searchZone = zone:lower()
        })
        
        -- [[ RECENT-10 CAP (MAIN-PROTECTED) ]]
        -- Keep at most 10 cached characters. A manually-selected main counts as one of the
        -- 10 but must never be evicted, so drop the oldest NON-main entry (the list is
        -- newest-first) until the cap is met. The 'removed' guard prevents an infinite loop
        -- in the impossible case that no removable entry remains.
        while #altList > 10 do
            local removed = false
            for i = #altList, 1, -1 do
                if not FriendGroups_IsManualMain(accountIdentifier, altList[i]) then
                    table.remove(altList, i)
                    removed = true
                    break
                end
            end
            if not removed then break end
        end
    end
end

-- 2. The Silent Tracker (Catches background changes)
local FriendGroups_BNetIndexMap = {}

local function FriendGroups_UpdateBNetIndexMap()
    wipe(FriendGroups_BNetIndexMap)
    local numBNetTotal = C_BattleNet.GetFriendNum and C_BattleNet.GetFriendNum() or BNGetNumFriends()
    for i = 1, numBNetTotal do
        local accountInfo = C_BattleNet.GetFriendAccountInfo(i)
        if accountInfo and accountInfo.bnetAccountID then
            FriendGroups_BNetIndexMap[accountInfo.bnetAccountID] = i
        end
    end
end

-- ============================================================================
-- [[ SECURE BACKGROUND TRACKER (STALE-SEAT PROTECTED) ]]
-- ============================================================================
local FriendGroups_AltTracker = CreateFrame("Frame")
FriendGroups_AltTracker:RegisterEvent("BN_FRIEND_INFO_CHANGED")
FriendGroups_AltTracker:SetScript("OnEvent", function(self, event, ...)
    -- [[ COMBAT CHURN GUARD ]]
    -- Skip background alt-caching while in combat. BN_FRIEND_INFO_CHANGED fires continuously in
    -- populated content; performing API lookups + SavedVars writes per event during combat is
    -- wasted work. No data is lost: the alt cache is rebuilt for every BNet friend on the next
    -- full list rebuild (FriendGroups_SetGroups -> FriendGroups_SaveToAltCache).
    if InCombatLockdown() then return end

    if ShiGuangDB.FriendGroupsshow_known_alts == false then return end
    if type(ShiGuangDB.FriendGroupsalt_cache) ~= "table" then ShiGuangDB.FriendGroupsalt_cache = {} end

    local arg1 = ... 
    if not arg1 then return end
    
    -- Secure API Lookup by strict ID guarantees we never pull the wrong person's data
    local accountInfo = C_BattleNet.GetAccountInfoByID(arg1)
    if not accountInfo then 
        accountInfo = C_BattleNet.GetFriendAccountInfo(arg1) -- Legacy fallback just in case
    end
    if not accountInfo then return end
    
    local accountIdentifier = accountInfo.battleTag or accountInfo.accountName
    if not accountIdentifier then return end

    -- Secure Path: Save only the verified primary game account attached to this strict ID.
    -- Multibox alts are safely handled synchronously during the UI rebuild, bypassing stale seat errors.
    if accountInfo.gameAccountInfo then
        FriendGroups_SaveToAltCache(accountIdentifier, accountInfo.gameAccountInfo)
    end
end)

-- 3. The Static Side-Panel Injector
-- FIX: Changed parent to UIParent and forced FrameStrata so it is impossible to hide behind the Guild window
local FriendGroupsAltTooltip = CreateFrame("GameTooltip", "FriendGroupsAltTooltip", UIParent, "GameTooltipTemplate")
FriendGroupsAltTooltip:SetFrameStrata("TOOLTIP")
FriendGroupsAltTooltip:SetFrameLevel(9999)

local FriendGroups_CurrentHoverCharKey = nil
local FriendGroups_CurrentHoverBNetID = nil
local FriendGroups_CurrentHoverAnchor = nil
-- Note for the currently hovered account, so tooltip refreshes/redraws (which
-- call DrawAltTooltip without a note) keep showing it instead of flickering off.
local FriendGroups_CurrentHoverNote = nil

-- Raw BNet note (verbatim) for an account identifier (battleTag / accountName), so
-- the guild + communities alt tooltip shows the SAME note as the contact-list one.
-- accountIdentifier matches what the hover handlers store (the alt_cache key).
local function FriendGroups_GetNoteByAccount(accountIdentifier)
    if type(accountIdentifier) ~= "string" then return nil end
    local num = (C_BattleNet.GetFriendNum and C_BattleNet.GetFriendNum())
        or (BNGetNumFriends and BNGetNumFriends()) or 0
    for i = 1, num do
        local info = C_BattleNet.GetFriendAccountInfo(i)
        if info and (info.battleTag == accountIdentifier or info.accountName == accountIdentifier) then
            if type(info.note) == "string" and info.note ~= "" then return strtrim(info.note) end
            return nil
        end
    end
    return nil
end

-- Reverse lookup to instantly find your own private BNet friends in the Guild Roster
local function FriendGroups_FindBNetIDByCharKey(charKey)
    if not ShiGuangDB.FriendGroupsalt_cache then return nil end
    for bnetID, alts in pairs(ShiGuangDB.FriendGroupsalt_cache) do
        for _, alt in ipairs(alts) do
            if alt.key == charKey then
                return bnetID
            end
        end
    end
    return nil
end

local function FriendGroups_DrawAltTooltip(anchorFrame, accountIdentifier, charKey, baselineData, noteText)
    if not FriendGroups_Loaded or ShiGuangDB.FriendGroupsshow_known_alts == false then
        FriendGroupsAltTooltip:Hide()
        return
    end

    -- Contextual UI Title Logic
    local hoverName = FGL["UNKNOWN"]
    if baselineData and baselineData.charName then
        hoverName = baselineData.charName
    elseif charKey then
        hoverName = strsplit("-", charKey)
    end
    
    -- [[ MASTER MERGE DICTIONARY ]]
    local masterDict = {}

    local function MergeIntoMaster(alt)
        if not alt or not alt.charName or not alt.realm then return end
        
        -- Force a universal, lowercase key to prevent case-sensitive splits
        local renderKey = string.lower(alt.charName .. "-" .. alt.realm:gsub("[%s%p]+", ""))
        
        if not masterDict[renderKey] then
            masterDict[renderKey] = {
                key = renderKey, charName = alt.charName, realm = alt.realm,
                class = alt.class, faction = alt.faction, level = alt.level,
                zone = alt.zone, guild = alt.guild, timestamp = alt.timestamp
            }
        else
            local existing = masterDict[renderKey]
            
            -- ALWAYS preserve a valid guild tag
            if alt.guild and alt.guild ~= "NONE" and alt.guild ~= "-" and alt.guild ~= "" then
                if not existing.guild or existing.guild == "NONE" or existing.guild == "-" or existing.guild == "" then
                    existing.guild = alt.guild
                end
            end

            -- Normal Timestamp Election for dynamic stats (Level, Zone)
            if (alt.timestamp or 0) > (existing.timestamp or 0) then
                existing.level = alt.level
                existing.zone = alt.zone
                existing.faction = alt.faction
                existing.timestamp = alt.timestamp
            end
        end
    end

    -- 1. PULL FROM LOCAL BNET CACHE
    if accountIdentifier and ShiGuangDB.FriendGroupsalt_cache and ShiGuangDB.FriendGroupsalt_cache[accountIdentifier] then
        for _, alt in ipairs(ShiGuangDB.FriendGroupsalt_cache[accountIdentifier]) do 
            MergeIntoMaster(alt) 
        end
    end

    -- 2. PULL FROM LIVE BASELINE DATA
    if baselineData then
        MergeIntoMaster(baselineData)
    end

    -- Flatten the merged dictionary back into a renderable array
    local alts = {}
    for _, alt in pairs(masterDict) do
        table.insert(alts, alt)
    end

    if #alts > 0 then
        -- SMART FALLBACK FIX: Heal missing name from cache
        if hoverName == FGL["UNKNOWN"] then
            local renderCharKey = string.lower(charKey or "")
            local foundHover = masterDict[renderCharKey]
            if foundHover and foundHover.charName and foundHover.charName ~= "" then
                hoverName = foundHover.charName
            elseif alts[1] and alts[1].charName and alts[1].charName ~= "" then
                hoverName = alts[1].charName
            end
        end

        local displayTitle = string.format(FGL["TOOLTIP_ALTS_PUBLIC_TITLE_FORMAT"], hoverName)
        if #alts >= 10 and FGL["TOOLTIP_ALTS_TITLE_FORMAT_MAX"] then
            displayTitle = string.format(FGL["TOOLTIP_ALTS_TITLE_FORMAT_MAX"], hoverName)
        end

        FriendGroupsAltTooltip:SetOwner(anchorFrame, "ANCHOR_NONE")

        if anchorFrame == FriendsFrame then
            FriendGroupsAltTooltip:SetPoint("TOPLEFT", FriendsFrame, "TOPRIGHT", 2, 0)
        elseif CommunitiesFrame and CommunitiesFrame:IsShown() then
            FriendGroupsAltTooltip:SetPoint("TOPLEFT", CommunitiesFrame, "TOPRIGHT", 2, 0)
        else
            FriendGroupsAltTooltip:SetPoint("TOPLEFT", anchorFrame, "TOPRIGHT", 2, 0)
        end

        -- Secure Font Reset: Prevent recycled tooltip lines from staying permanently shrunk
        for i = 1, 99 do
            local line = _G["FriendGroupsAltTooltipTextLeft" .. i]
            if not line then break end
            
            local fontObj = (i == 1) and GameTooltipHeaderText or GameTooltipText
            if fontObj then
                -- Re-assign the FontObject natively. This clears any manual 6px SetFont overrides
                -- and fully restores the Blizzard font fallback chain for Cyrillic/Asian characters.
                line:SetFontObject(fontObj)
            end
        end

		FriendGroupsAltTooltip:ClearLines()
        FriendGroupsAltTooltip:AddLine(displayTitle, 1, 0.82, 0)

        -- Note (directly below the header, gold). Always shown here when present.
        -- Fall back to the stashed hover note so timer refreshes / the native
        -- FriendsTooltip:Show redraw (both call us without a note) don't drop it.
        local showNote = noteText or FriendGroups_CurrentHoverNote
        if showNote and showNote ~= "" then
            FriendGroupsAltTooltip:AddLine(showNote, 1, 0.82, 0, true)
        end

        -- [[ BNET SUBTITLE ]] Show the owning Battle.net BattleTag in the familiar BNet blue.
        -- accountIdentifier is the BattleTag (Name#1234) when one exists; the '#' test keeps
        -- legacy accountName values from rendering as a junk subtitle.
        if accountIdentifier
           and not (issecretvalue and issecretvalue(accountIdentifier))
           and type(accountIdentifier) == "string"
           and accountIdentifier:find("#") then
            local btagColor = FRIENDS_BNET_NAME_COLOR or BATTLENET_FONT_COLOR or CreateColor(0.510, 0.773, 1.0)
            FriendGroupsAltTooltip:AddLine(accountIdentifier, btagColor.r, btagColor.g, btagColor.b)
        end
		
        local sortedAlts = {}
        for _, alt in ipairs(alts) do table.insert(sortedAlts, alt) end
        
        table.sort(sortedAlts, function(a, b)
            local aIsMain = false
            local bIsMain = false
            if ShiGuangDB.FriendGroupsmanual_mains and accountIdentifier then
                local manualMainKey = ShiGuangDB.FriendGroupsmanual_mains[accountIdentifier]
                local aKey = a.charName .. "-" .. FriendGroups_CleanRealmName(a.realm or "")
                local bKey = b.charName .. "-" .. FriendGroups_CleanRealmName(b.realm or "")
                if aKey == manualMainKey or a.key == manualMainKey then aIsMain = true end
                if bKey == manualMainKey or b.key == manualMainKey then bIsMain = true end
            end
            if aIsMain and not bIsMain then return true end
            if bIsMain and not aIsMain then return false end

            local timeA = a.timestamp or 0
            local timeB = b.timestamp or 0
            if timeA == timeB then
                if a.level == b.level then return a.charName < b.charName end
                return a.level > b.level
            end
            return timeA > timeB
        end)

        local rowCount = 0
        for _, alt in ipairs(sortedAlts) do
            if rowCount >= 10 then break end
            rowCount = rowCount + 1

            -- Create a custom 50% height gap instead of a full line
            FriendGroupsAltTooltip:AddLine(" ")
            local gapLine = _G["FriendGroupsAltTooltipTextLeft" .. FriendGroupsAltTooltip:NumLines()]
            if gapLine then
                -- Safely swap to a smaller native FontObject. 
                -- Avoids explicit SetFont calls that poison global objects.
                gapLine:SetFontObject("SystemFont_Tiny")
            end

            local engClass = ""
            if alt.class and (LOCALIZED_CLASS_NAMES_MALE[alt.class] or RAID_CLASS_COLORS[alt.class]) then
                engClass = alt.class
            else
                for k, v in pairs(LOCALIZED_CLASS_NAMES_MALE) do if alt.class == v then engClass = k break end end
                if engClass == "" then for k, v in pairs(LOCALIZED_CLASS_NAMES_FEMALE) do if alt.class == v then engClass = k break end end end
            end

            local classIconStr = ""
            if engClass ~= "" then
                local atlas = GetClassAtlas(engClass)
                if atlas then classIconStr = "|A:" .. atlas .. ":16:16|a" else classIconStr = "|A:classicon-" .. string.lower(engClass) .. ":16:16|a" end
            end
            
            local nameColor = FriendGroups_GetClassColorCode(engClass ~= "" and engClass or alt.class)
            local coloredName = nameColor .. alt.charName .. (alt.realm ~= "" and ("-" .. alt.realm) or "") .. "|r"

            local factionPath = FriendGroups_GetFactionIcon(alt.faction)
            local factionIconStr = factionPath ~= "" and ("|T" .. factionPath .. ":16|t") or ""

            local diff = time() - (alt.timestamp or time())
            if diff < 0 then diff = 0 end

            local timeStr = ""
            if diff < 60 then timeStr = FGL["TIME_JUST_NOW"]
            elseif diff < 3600 then timeStr = string.format(FGL["TIME_MINUTES_AGO"], math.floor(diff / 60))
            elseif diff < 86400 then timeStr = string.format(FGL["TIME_HOURS_AGO"], math.floor(diff / 3600))
            else timeStr = string.format(FGL["TIME_DAYS_AGO"], math.floor(diff / 86400)) end

            local isCurrentMain = false
            if ShiGuangDB.FriendGroupsmanual_mains and accountIdentifier then
                local manualMainKey = ShiGuangDB.FriendGroupsmanual_mains[accountIdentifier]
                local currentKey = alt.charName .. "-" .. FriendGroups_CleanRealmName(alt.realm or "")
                if currentKey == manualMainKey or alt.key == manualMainKey then isCurrentMain = true end
            end

            local leaderFlagStr = isCurrentMain and "|TInterface\\AddOns\\OrzUI\\Media\\Modules\\FriendGroups\\check.tga:16|t " or ""
            local line1 = leaderFlagStr .. string.format(FGL["TOOLTIP_ALTS_FORMAT"], classIconStr, coloredName, factionIconStr, alt.level)
            
            if alt.guild and alt.guild ~= "NONE" and alt.guild ~= "-" and alt.guild ~= "" then
                if FGL["TOOLTIP_ALTS_GUILD_SUFFIX"] then
                    line1 = line1 .. string.format(FGL["TOOLTIP_ALTS_GUILD_SUFFIX"], alt.guild)
                end
            end

            local line2 = string.format(FGL["TOOLTIP_ALTS_SEEN"], alt.zone, timeStr)

            FriendGroupsAltTooltip:AddLine(line1, 1, 1, 1, false)
            FriendGroupsAltTooltip:AddLine(line2, 0.6, 0.6, 0.6, false)
        end

        FriendGroupsAltTooltip:Show()

        -- [[ DOCK FIX ]] Pin the native hover tooltip beneath the alt panel (friends + guild).
        FriendGroups_DockNativeTooltipBelowPanel(anchorFrame)
    else
        FriendGroupsAltTooltip:Hide()
        -- Panel suppressed (e.g. a guildmate who is not a BNet friend): keep the native
        -- roster tooltip parked where the panel would be so it does not jump sides as you
        -- scroll. No-op for the friends list and when no native tooltip is on screen.
        FriendGroups_DockNativeTooltipBelowPanel(anchorFrame)
    end
end

function FriendGroups_RefreshAltTooltip()
    if FriendGroups_CurrentHoverAnchor then
        FriendGroups_DrawAltTooltip(FriendGroups_CurrentHoverAnchor, FriendGroups_CurrentHoverBNetID, FriendGroups_CurrentHoverCharKey)
    end
end

-- [[ NATIVE TOOLTIP DOCK / PARK ]]
-- Keeps the native hover tooltip pinned to the Known Alts panel's slot so it never
-- overlaps the panel and never jumps sides while scrolling a roster.
--   * Panel shown  -> dock the native tooltip directly beneath the panel (with a
--                     bottom-of-screen overflow flip to grow upward instead).
--   * Panel hidden -> on the guild / communities roster only, park the native tooltip
--                     where the panel WOULD be (just right of the roster). This covers
--                     guildmates who are not BNet friends, whose panel is suppressed:
--                     without this the tooltip snaps back to Blizzard's default anchor
--                     and flicks between left and right row-to-row as you scroll.
-- anchorFrame mirrors the value the panel was (or would be) drawn against:
--   FriendsFrame -> FriendsTooltip (dedicated frame, friends list)
--   anything else -> GameTooltip   (shared global, guild / communities roster)
function FriendGroups_DockNativeTooltipBelowPanel(anchorFrame)
    if not FriendGroups_Loaded then return end
    if ShiGuangDB.FriendGroupsshow_known_alts == false then return end

    local native = (anchorFrame == FriendsFrame) and FriendsTooltip or GameTooltip

    -- Never move a tooltip that is not currently on screen for this hover.
    if not (native and native:IsShown()) then return end

    -- [[ PANEL HIDDEN: PARK ON THE RIGHT ]]
    if not (FriendGroupsAltTooltip and FriendGroupsAltTooltip:IsShown()) then
        -- Only the guild / communities roster suppresses the panel; the friends list
        -- always renders one, so we never reposition the dedicated FriendsTooltip here.
        if anchorFrame ~= FriendsFrame then
            local target = (CommunitiesFrame and CommunitiesFrame:IsShown()) and CommunitiesFrame or anchorFrame
            if target then
                native:ClearAllPoints()
                native:SetPoint("TOPLEFT", target, "TOPRIGHT", 2, 0)
            end
        end
        return
    end

    -- [[ PANEL SHOWN: DOCK BENEATH IT ]]
    local GAP = 4

    -- If the panel's rect is not yet resolvable this frame, fall back to a plain
    -- dock-beneath (the overflow flip simply re-evaluates on the next hover).
    local panelBottom = FriendGroupsAltTooltip:GetBottom()
    if not panelBottom then
        native:ClearAllPoints()
        native:SetPoint("TOPLEFT", FriendGroupsAltTooltip, "BOTTOMLEFT", 0, -GAP)
        return
    end

    -- Convert to absolute screen pixels to test for bottom-of-screen overflow.
    local panelBottomPx  = panelBottom * FriendGroupsAltTooltip:GetEffectiveScale()
    local nativeHeightPx = native:GetHeight() * native:GetEffectiveScale()
    local gapPx          = GAP * native:GetEffectiveScale()

    native:ClearAllPoints()
    if (panelBottomPx - nativeHeightPx - gapPx) < 0 then
        -- Not enough room below; grow upward from the panel's top edge instead.
        native:SetPoint("BOTTOMLEFT", FriendGroupsAltTooltip, "TOPLEFT", 0, GAP)
    else
        native:SetPoint("TOPLEFT", FriendGroupsAltTooltip, "BOTTOMLEFT", 0, -GAP)
    end
end

-- [[ GUILD / COMMUNITIES DOCK ENFORCER ]]
-- The guild & communities roster uses the shared global GameTooltip, which Blizzard
-- (and decorators such as Raider.IO) re-anchor on every Show as their data resolves.
-- A one-shot reposition at hover time is overwritten by the next Show, so we re-apply
-- the dock AFTER every GameTooltip Show -- but only while a guild/community roster row
-- is actively hovered. FriendGroups_CurrentHoverAnchor is the roster row owner during a
-- guild hover and is never FriendsFrame, so the friends list (dedicated FriendsTooltip)
-- and all unrelated GameTooltips are excluded. SetPoint/ClearAllPoints do not trigger
-- Show, so this never recurses.
hooksecurefunc(GameTooltip, "Show", function(self)
    if not FriendGroups_Loaded then return end
    if ShiGuangDB.FriendGroupsshow_known_alts == false then return end

    local anchor = FriendGroups_CurrentHoverAnchor
    if not anchor or anchor == FriendsFrame then return end

    -- Keep the roster's native tooltip anchored to the panel's slot whether the panel is
    -- shown (BNet friend -> dock beneath) or suppressed (plain guildmate -> park on the
    -- right). Re-applied after every Show because Blizzard / Raider.IO re-anchor the
    -- shared GameTooltip as their data resolves.
    FriendGroups_DockNativeTooltipBelowPanel(anchor)
end)

-- [[ DIRECT BUTTON HANDLER ]]
function FriendGroups_ShowButtonAltTooltip(button)
    if not FriendGroups_Loaded or ShiGuangDB.FriendGroupsshow_known_alts == false then FriendGroupsAltTooltip:Hide() return end

    if not button or not button.id then return end

    FriendGroups_CurrentHoverAnchor = FriendsFrame
    FriendGroups_CurrentHoverBNetID = nil
    FriendGroups_CurrentHoverCharKey = nil

    local baselineData = nil

    if button.buttonType == FRIENDS_BUTTON_TYPE_BNET then
        local accountInfo = C_BattleNet.GetFriendAccountInfo(button.id)
        if not accountInfo then return end

        local accountIdentifier = accountInfo.battleTag or accountInfo.accountName
        if not accountIdentifier then return end

        FriendGroups_SaveToAltCache(accountIdentifier, accountInfo.gameAccountInfo)
        
        -- [[ MULTIBOX FIX: Automatically harvest all secondary open wow clients natively ]]
        if C_BattleNet.GetFriendNumGameAccounts then
            local numAccounts = C_BattleNet.GetFriendNumGameAccounts(button.id)
            if numAccounts and numAccounts > 1 then
                for i = 1, numAccounts do
                    local gameAccountInfo = C_BattleNet.GetFriendGameAccountInfo(button.id, i)
                    if gameAccountInfo then
                        FriendGroups_SaveToAltCache(accountIdentifier, gameAccountInfo)
                    end
                end
            end
        end

        FriendGroups_CurrentHoverBNetID = accountIdentifier

        if accountInfo.gameAccountInfo and accountInfo.gameAccountInfo.characterName then
            local rawRealm = accountInfo.gameAccountInfo.realmName
            if not rawRealm or rawRealm == "" then rawRealm = accountInfo.gameAccountInfo.realmDisplayName end
            if rawRealm and rawRealm ~= "" then
                local cName = accountInfo.gameAccountInfo.characterName
                local rName = rawRealm:gsub("[%s%p]+", "")
                FriendGroups_CurrentHoverCharKey = cName .. "-" .. rName
                
                baselineData = {
                    charName = cName,
                    realm = rawRealm,
                    class = accountInfo.gameAccountInfo.className or "",
                    faction = accountInfo.gameAccountInfo.factionName or "",
                    level = tonumber(accountInfo.gameAccountInfo.characterLevel) or 1,
                    zone = accountInfo.gameAccountInfo.areaName or FGL["UNKNOWN_ZONE"],
                    timestamp = time()
                }
            end
        end

        local fgNoteClean = nil
        if type(accountInfo.note) == "string" and accountInfo.note ~= "" then
            fgNoteClean = strtrim(accountInfo.note)
        end
        FriendGroups_CurrentHoverNote = fgNoteClean
        FriendGroups_DrawAltTooltip(FriendsFrame, FriendGroups_CurrentHoverBNetID, FriendGroups_CurrentHoverCharKey, baselineData, fgNoteClean)
    end
end

hooksecurefunc(FriendsTooltip, "Show", function(self)
    if not FriendGroups_Loaded or ShiGuangDB.FriendGroupsshow_known_alts == false then FriendGroupsAltTooltip:Hide() return end

    local button = self.button
    if not button or not button.id then return end

    FriendGroups_CurrentHoverAnchor = FriendsFrame
    FriendGroups_CurrentHoverBNetID = nil
    FriendGroups_CurrentHoverCharKey = nil

    local baselineData = nil

    if button.buttonType == FRIENDS_BUTTON_TYPE_BNET then
        local accountInfo = C_BattleNet.GetFriendAccountInfo(button.id)
        if not accountInfo then return end

        local accountIdentifier = accountInfo.battleTag or accountInfo.accountName
        if not accountIdentifier then return end

        FriendGroups_SaveToAltCache(accountIdentifier, accountInfo.gameAccountInfo)
        
        -- [[ MULTIBOX FIX: Mirror of above ]]
        if C_BattleNet.GetFriendNumGameAccounts then
            local numAccounts = C_BattleNet.GetFriendNumGameAccounts(button.id)
            if numAccounts and numAccounts > 1 then
                for i = 1, numAccounts do
                    local gameAccountInfo = C_BattleNet.GetFriendGameAccountInfo(button.id, i)
                    if gameAccountInfo then
                        FriendGroups_SaveToAltCache(accountIdentifier, gameAccountInfo)
                    end
                end
            end
        end

        FriendGroups_CurrentHoverBNetID = accountIdentifier

        if accountInfo.gameAccountInfo and accountInfo.gameAccountInfo.characterName then
            local rawRealm = accountInfo.gameAccountInfo.realmName
            if not rawRealm or rawRealm == "" then rawRealm = accountInfo.gameAccountInfo.realmDisplayName end
            if rawRealm and rawRealm ~= "" then
                local cName = accountInfo.gameAccountInfo.characterName
                local rName = rawRealm:gsub("[%s%p]+", "")
                FriendGroups_CurrentHoverCharKey = cName .. "-" .. rName
                
                baselineData = {
                    charName = cName,
                    realm = rawRealm,
                    class = accountInfo.gameAccountInfo.className or "",
                    faction = accountInfo.gameAccountInfo.factionName or "",
                    level = tonumber(accountInfo.gameAccountInfo.characterLevel) or 1,
                    zone = accountInfo.gameAccountInfo.areaName or FGL["UNKNOWN_ZONE"],
                    timestamp = time()
                }
            end
        end

        FriendGroups_DrawAltTooltip(FriendsFrame, FriendGroups_CurrentHoverBNetID, FriendGroups_CurrentHoverCharKey, baselineData)
    end
end)

FriendsFrame:HookScript("OnHide", function()
    if FriendGroupsAltTooltip then FriendGroupsAltTooltip:Hide() end
    FriendGroups_CurrentHoverAnchor = nil
end)

local function FriendGroups_OnGuildRosterEnter(owner, memberInfo)
    -- [[ SECRET-SAFE HELPER ]]
    -- Returns the value only when it is present AND is not a 12.0 protected "secret value".
    -- It never reads the contents of a secret (issecretvalue is the only gate that is allowed).
    local function FG_PlainOrNil(v)
        if v == nil then return nil end
        if issecretvalue and issecretvalue(v) then return nil end
        return v
    end

    FriendGroups_CurrentHoverAnchor = owner

    local guid = memberInfo.guid
    local guidReadable = (guid ~= nil) and not (issecretvalue and issecretvalue(guid))
    local nameSecret = (issecretvalue and issecretvalue(memberInfo.name)) and true or false

    -- ========================================================================
    -- [[ SECRET PATH ]]
    -- The roster name is a protected secret in this context. We must NOT read it
    -- (no :find, no concat, no strsplit). Resolve identity via the GUID index that
    -- was harvested during normal (non-secret) hovers, and render purely from cache.
    -- ========================================================================
    if nameSecret then
        if type(ShiGuangDB.FriendGroupssecret_telemetry) ~= "table" then
            ShiGuangDB.FriendGroupssecret_telemetry = { hits = 0, resolved = 0, unresolved = 0, guid_secret = 0 }
        end
        local tel = ShiGuangDB.FriendGroupssecret_telemetry
        tel.hits = (tel.hits or 0) + 1
        if not guidReadable then tel.guid_secret = (tel.guid_secret or 0) + 1 end

        local resolved = nil
        if guidReadable and type(ShiGuangDB.FriendGroupsguid_index) == "table" then
            resolved = ShiGuangDB.FriendGroupsguid_index[guid]
        end

        if resolved then
            tel.resolved = (tel.resolved or 0) + 1
            FriendGroups_CurrentHoverBNetID = resolved.account
            FriendGroups_CurrentHoverCharKey = resolved.key
            -- Mirror the contact-list tooltip: show this account's note here too.
            FriendGroups_CurrentHoverNote = FriendGroups_GetNoteByAccount(resolved.account)

            local baselineData = {
                charName  = resolved.charName,
                realm     = resolved.realm,
                class     = resolved.class or "",
                faction   = resolved.faction or "",
                level     = tonumber(FG_PlainOrNil(memberInfo.level)) or resolved.level or 1,
                zone      = FG_PlainOrNil(memberInfo.zoneName) or FG_PlainOrNil(memberInfo.zone) or resolved.zone or FGL["UNKNOWN_ZONE"],
                guild     = FriendGroups_PlayerGuildName,
                timestamp = time()
            }
            FriendGroups_DrawAltTooltip(owner, resolved.account, resolved.key, baselineData)
        else
            tel.unresolved = (tel.unresolved or 0) + 1
            FriendGroups_CurrentHoverBNetID = nil
            FriendGroups_CurrentHoverCharKey = nil
            FriendGroups_DrawAltTooltip(owner, nil, nil, nil)
        end
        return
    end

    -- ========================================================================
    -- [[ NORMAL PATH ]] (original behaviour, unchanged, plus GUID-index harvest)
    -- ========================================================================
    local rawName = memberInfo.name
    if rawName == nil then return end

    local charKey = rawName
    local cName, rName = rawName, GetNormalizedRealmName()
    if not rawName:find("-") then 
        charKey = rawName .. "-" .. rName
    else 
        cName, rName = strsplit("-", rawName)
        rName = rName:gsub("[%s%p]+", "")
        charKey = cName .. "-" .. rName 
    end

    FriendGroups_CurrentHoverBNetID = FriendGroups_FindBNetIDByCharKey(charKey)
    FriendGroups_CurrentHoverCharKey = charKey
    -- Mirror the contact-list tooltip: show this account's note here too.
    FriendGroups_CurrentHoverNote = FriendGroups_GetNoteByAccount(FriendGroups_CurrentHoverBNetID)

    local className, classFilename = "", ""
    if memberInfo.classID then 
        className, classFilename = GetClassInfo(memberInfo.classID) 
    end

    -- [[ FACTION FIX: Hybrid Approach (Communities API -> Native GUID API) ]]
    local factionFallback = ""
    if type(memberInfo.faction) == "number" then
        if memberInfo.faction == 0 then 
            factionFallback = "Horde"
        elseif memberInfo.faction == 1 then 
            factionFallback = "Alliance" 
        end
    end
    
    -- Fallback to Native GUID check if Communities data is missing
    if factionFallback == "" and guidReadable then
        local _, _, _, englishRace = GetPlayerInfoByGUID(guid)
        if englishRace then
            if englishRace == "Human" or englishRace == "Dwarf" or englishRace == "NightElf" or englishRace == "Gnome" or englishRace == "Draenei" or englishRace == "Worgen" or englishRace == "LightforgedDraenei" or englishRace == "VoidElf" or englishRace == "DarkIronDwarf" or englishRace == "KulTiran" or englishRace == "Mechagnome" or englishRace == "EarthenAlliance" then
                factionFallback = "Alliance"
            elseif englishRace == "Orc" or englishRace == "Scourge" or englishRace == "Tauren" or englishRace == "Troll" or englishRace == "BloodElf" or englishRace == "Goblin" or englishRace == "Nightborne" or englishRace == "HighmountainTauren" or englishRace == "MagharOrc" or englishRace == "ZandalariTroll" or englishRace == "Vulpera" or englishRace == "EarthenHorde" then
                factionFallback = "Horde"
            end
            -- Note: Neutral races (Pandaren/Dracthyr) safely bypass this to avoid assumptions.
            -- If their faction cannot be derived here, your BNet/Mesh Alt Cache will securely resolve it.
        end
    end

    local baselineData = {
        charName = cName,
        realm = rName,
        class = classFilename or className or "",
        faction = factionFallback, 
        level = tonumber(memberInfo.level) or 1,
        zone = memberInfo.zoneName or memberInfo.zone or FGL["UNKNOWN_ZONE"],
        guild = FriendGroups_PlayerGuildName, -- [[ FIX: Supply local guild explicitly ]]
        timestamp = time()
    }

    -- [[ GUID-INDEX HARVEST ]]
    -- Store a plain snapshot keyed by the readable GUID so a future hover in a secret
    -- context can resolve this character without ever reading the protected name.
    -- Only stored when we resolved a BNet owner, since the alt tooltip is BNet-cache backed.
    if guidReadable and FriendGroups_CurrentHoverBNetID then
        if type(ShiGuangDB.FriendGroupsguid_index) ~= "table" then
            ShiGuangDB.FriendGroupsguid_index = {}
        end
        ShiGuangDB.FriendGroupsguid_index[guid] = {
            account   = FriendGroups_CurrentHoverBNetID,
            key       = charKey,
            charName  = cName,
            realm     = rName,
            class     = baselineData.class,
            faction   = factionFallback,
            level     = baselineData.level,
            zone      = baselineData.zone,
            timestamp = time()
        }
    end

    -- [[ BNET-ONLY GATE ]]
    -- The Known Alt panel is meaningful only for guild members who are also one of your
    -- Battle.net friends, since that is the only identity we track alts for. For a plain
    -- guildmate (no resolved BNet owner) suppress the panel instead of rendering a lone
    -- single-character window. Routing through DrawAltTooltip with nils hides it cleanly,
    -- mirroring the secret-path unresolved branch above.
    if not FriendGroups_CurrentHoverBNetID then
        FriendGroups_DrawAltTooltip(owner, nil, nil, nil)
        return
    end

    FriendGroups_DrawAltTooltip(owner, FriendGroups_CurrentHoverBNetID, charKey, baselineData)
end

function FriendGroups_InitCommunitiesHook()
    -- 1. Hook Standard Communities / Guild Roster Frame (Retail API)
    if CommunitiesFrame and not CommunitiesFrame.FG_HideHooked then
        CommunitiesFrame:HookScript("OnHide", function()
            if FriendGroupsAltTooltip then FriendGroupsAltTooltip:Hide() end
            FriendGroups_CurrentHoverAnchor = nil
        end)
        
        -- [[ NEW: Secure localized Tooltip Hook for Communities Roster (Replaces Global GameTooltip Hook) ]]
        if CommunitiesFrame.MemberList and CommunitiesFrame.MemberList.ScrollBox then
            CommunitiesFrame.MemberList.ScrollBox:RegisterCallback("OnInitializedFrame", function(_, frame)
                if not frame.FG_RosterTooltipHooked then
                    frame:HookScript("OnEnter", function(self)
                        if not FriendGroups_Loaded or ShiGuangDB.FriendGroupsshow_known_alts == false then return end
                        if type(self.GetMemberInfo) ~= "function" then return end
                        
                        local success, memberInfo = pcall(self.GetMemberInfo, self)
                        if success and memberInfo and memberInfo.name then
                            FriendGroups_OnGuildRosterEnter(self, memberInfo)
                        end
                    end)
                    
                    frame:HookScript("OnLeave", function(self)
                        if FriendGroupsAltTooltip then FriendGroupsAltTooltip:Hide() end
                        FriendGroups_CurrentHoverAnchor = nil
                    end)
                    
                    frame.FG_RosterTooltipHooked = true
                end
            end)
        end
        CommunitiesFrame.FG_HideHooked = true
    end

    -- 2. Hook Legacy/Standalone Guild UI (Fallback Support)
    if GuildRosterContainer and GuildRosterContainer.ScrollBox and not GuildRosterContainer.FG_Hooked then
        GuildRosterContainer.ScrollBox:RegisterCallback("OnInitializedFrame", function(_, frame)
            if not frame.FG_RosterTooltipHooked then
                frame:HookScript("OnEnter", function(self)
                    if not FriendGroups_Loaded or ShiGuangDB.FriendGroupsshow_known_alts == false then return end
                    if type(self.GetMemberInfo) ~= "function" then return end
                    
                    local success, memberInfo = pcall(self.GetMemberInfo, self)
                    if success and memberInfo and memberInfo.name then
                        FriendGroups_OnGuildRosterEnter(self, memberInfo)
                    end
                end)
                
                frame:HookScript("OnLeave", function(self)
                    if FriendGroupsAltTooltip then FriendGroupsAltTooltip:Hide() end
                    FriendGroups_CurrentHoverAnchor = nil
                end)
                
                frame.FG_RosterTooltipHooked = true
            end
        end)
        GuildRosterContainer.FG_Hooked = true
    end
end

-- ============================================================================
-- [[ DIAGNOSTIC & TELEMETRY ENGINE ]]
-- ============================================================================
SLASH_FRIENDGROUPSDEBUG1 = "/fg"
SlashCmdList["FRIENDGROUPSDEBUG"] = function(msg)
    local command = msg and msg:lower():match("^%s*(%w+)")
    
    if command == "debug" then
        -- 1. Measure Memory Churn (Strictly restricted to FriendGroups)
        UpdateAddOnMemoryUsage()
        local memUsageKB = GetAddOnMemoryUsage(addonName) or GetAddOnMemoryUsage("FriendGroups") or 0
        local memUsageMB = memUsageKB / 1024

        -- 2. Measure Database Bloat
        local privateCount = 0

        --if FriendGroups_SavedVars then
            if type(ShiGuangDB.FriendGroupsalt_cache) == "table" then
                for _, alts in pairs(ShiGuangDB.FriendGroupsalt_cache) do
                    privateCount = privateCount + #alts
                end
            end
        --end

        -- 3. Output Telemetry Safely
        DEFAULT_CHAT_FRAME:AddMessage(FGL["DEBUG_HEADER"] or "Debug Header Missing")
        DEFAULT_CHAT_FRAME:AddMessage(string.format(FGL["DEBUG_MEM_USAGE"] or "Mem: %.2f", memUsageMB))
        DEFAULT_CHAT_FRAME:AddMessage(string.format(FGL["DEBUG_DB_SIZE"] or "Database Profiles: %d (Private BNet Cache)", privateCount))

        -- [[ SECRET-VALUE TELEMETRY ]] Reports the secret-name fallback activity recorded at hover time.
        local guidIndexCount = 0
        if type(ShiGuangDB.FriendGroupsguid_index) == "table" then
            for _ in pairs(ShiGuangDB.FriendGroupsguid_index) do
                guidIndexCount = guidIndexCount + 1
            end
        end

        local tel = (type(ShiGuangDB.FriendGroupssecret_telemetry) == "table") and ShiGuangDB.FriendGroupssecret_telemetry or nil

        DEFAULT_CHAT_FRAME:AddMessage(FGL["DEBUG_SECRET_HEADER"])
        DEFAULT_CHAT_FRAME:AddMessage(string.format(FGL["DEBUG_GUID_INDEX"], guidIndexCount))
        DEFAULT_CHAT_FRAME:AddMessage(string.format(FGL["DEBUG_SECRET_HITS"], (tel and tel.hits) or 0))
        DEFAULT_CHAT_FRAME:AddMessage(string.format(FGL["DEBUG_SECRET_RESOLVED"], (tel and tel.resolved) or 0))
        DEFAULT_CHAT_FRAME:AddMessage(string.format(FGL["DEBUG_SECRET_UNRESOLVED"], (tel and tel.unresolved) or 0))
        DEFAULT_CHAT_FRAME:AddMessage(string.format(FGL["DEBUG_SECRET_GUID"], (tel and tel.guid_secret) or 0))
    elseif command == "export" then
        FriendGroups_ShowExport()
    elseif command == "import" then
        StaticPopup_Show("FRIENDGROUPS_IMPORT")
    else
        DEFAULT_CHAT_FRAME:AddMessage(FGL["DEBUG_HELP"] or "Type /fg debug")
    end
end

-- ============================================================================
-- [[ OPEN-LIST UPDATE COALESCER ]]
-- ============================================================================
-- Collapses bursts of roster events (BN_FRIEND_INFO_CHANGED, FRIENDLIST_UPDATE, native idle
-- ticks) routed here by the Dirty Roster Engine (and, in legacy mode, the
-- FriendsList_Update override) into a single deferred refresh.
-- Schedules at most one timer per window, and only while the Friends list is actually visible.
-- The Dirty Roster Engine still flags changes regardless, so a hidden list rebuilds correctly
-- via OnShow the next time it is opened. All existing guards (combat, visibility, dirty) still
-- apply when the timer fires, so behaviour is identical to today aside from a sub-frame delay.
-- [[ GROUPED PROVIDER RE-ASSERT (12.2.2 TAINT FIX COMPANION) ]]
-- In post-hook mode Blizzard's native FriendsList_Update installs its own flat data
-- provider into the ScrollBox. This runs immediately afterwards, inside the same
-- execution, and swaps the last grouped provider back before anything is rendered --
-- the flat list never becomes visible and the scroll position is retained. The
-- coalesced rebuild that follows refreshes the grouped data itself.
function FriendGroups_ReassertGroupedProvider()
    if not FriendGroups_Loaded then return end
    if not (FriendsListFrame and FriendsListFrame.ScrollBox) then return end
    if not FriendGroups_ActiveProvider then return end
    if FriendsListFrame.ScrollBox:GetDataProvider() == FriendGroups_ActiveProvider then return end
    FriendsListFrame.ScrollBox:SetDataProvider(FriendGroups_ActiveProvider, true)
end

local FriendGroups_UpdateThrottleTimer = nil
function FriendGroups_RequestListUpdate()
    if not (FriendsListFrame and FriendsListFrame:IsShown()) then return end
    if FriendGroups_UpdateThrottleTimer then return end
    FriendGroups_UpdateThrottleTimer = C_Timer.NewTimer(0.1, function()
        FriendGroups_UpdateThrottleTimer = nil
        FriendGroups_FriendsListUpdate(false)
    end)
end

-- ============================================================================
-- [[ COMBAT LOCKOUT QUEUE HANDLER ]]
-- ============================================================================
local FriendGroups_CombatQueueFrame = CreateFrame("Frame")
FriendGroups_CombatQueueFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
FriendGroups_CombatQueueFrame:SetScript("OnEvent", function(self, event)
    if event == "PLAYER_REGEN_ENABLED" then
        if FriendGroups_UpdateQueued then
            FriendGroups_UpdateQueued = false
            -- Combat has safely ended. Use the standard (non-forcing) update path so the
            -- visibility guard applies: if the Friends list is hidden this returns immediately
            -- (OnShow will rebuild it later); if it is shown it rebuilds once via the O(n) swap.
            FriendGroups_FriendsListUpdate(false)
        end
    end
end)