--[[
	Cerebro - Standings Frame
	
	PURPOSE:
	Displays EP/GP/PR standings with sorting, filtering, and RBAC-aware visibility.
	
	RESPONSIBILITIES:
	1. Schema-Driven Rendering - Column definitions with type-aware sorting
	2. Nil-Safety - Graceful degradation when dependencies/data unavailable
	3. Deterministic Refresh - Idempotent redraw, no duplicated rows/headers
	4. RBAC Visibility - Officer sees Pending players, members do not
	5. Performance - Debounced filter, refresh budget guard
	
	CONTRACTS:
	- Draw(container): Initial render with header + data rows
	- Refresh(container): Redraw current view with updated data
	- Redraw(container): Force full rebuild (clears and recreates)
	- GetExtraColumns(): Extension point for future officer-only columns
	
	DESIGN DECISIONS:
	- Schema-based columns for maintainability and extensibility
	- Stable sorting (primary: selected column, secondary: name)
	- Refresh budget guard (0.2s minimum between redraws)
	- Nil-safe data access with defaults (EP/GP/PR = 0, class = "Unknown")
]]--

local AddonName, PrivateTable = ...
local Cerebro = LibStub("AceAddon-3.0"):GetAddon(AddonName)
local AceGUI = LibStub("AceGUI-3.0")
local StandingsFrame = Cerebro:NewModule("StandingsFrame", "AceEvent-3.0")

-- Context menu frame (used by EasyMenu / UIDropDown fallback)
local CONTEXT_MENU_FRAME = CreateFrame("Frame", AddonName.."StandingsContextMenu", UIParent, "UIDropDownMenuTemplate")

-- State
StandingsFrame.sortKey = "pr"
StandingsFrame.sortAsc = false -- descending by default (High PR first)
StandingsFrame.filterText = ""
StandingsFrame.scrollContainer = nil
StandingsFrame.lastRedrawTime = 0
StandingsFrame.REDRAW_BUDGET_SECONDS = 0.2
StandingsFrame._pendingMenuAction = nil  -- Pending action from context menu popups
StandingsFrame.selected = {}           -- { [fullName] = true } for officer selection checkboxes
StandingsFrame._rowCheckboxes = {}     -- Cache: { [fullName] = { check = CheckButton, bg = Texture } }

-- Column Schema
-- Extension point: Officers can add columns via GetExtraColumns()
local BASE_COLUMNS = {
	{ id = "rank", label = "#", width = 0.06, sortKey = "pr", numeric = true },
	{ id = "name", label = "Player", width = 0.22, sortKey = "name", numeric = false },
	{ id = "guildRank", label = "Guild Rank", width = 0.12, sortKey = "guildRankIndex", numeric = true },
	{ id = "pr", label = "PR", width = 0.10, sortKey = "pr", numeric = true },
	{ id = "ep", label = "EP", width = 0.09, sortKey = "ep", numeric = true },
	{ id = "gp", label = "GP", width = 0.09, sortKey = "gp", numeric = true },
	{ id = "status", label = "Status", width = 0.11, sortKey = "status", numeric = false },
	{ id = "attendance", label = "Attend", width = 0.17, sortKey = "attendancePct", numeric = true },
}

-- Officer-only "Due" column (prepended when officer)
local SELECT_COLUMN = { id = "select", label = "Due", width = 0.04, sortKey = nil, numeric = false }

function StandingsFrame:OnInitialize()
	-- Purge confirmation popup (type-to-confirm for purge, simple confirm for block)
	StaticPopupDialogs["CEREBRO_CONFIRM_REMOVE_PLAYER"] = {
		text = "Confirm action on this player?",
		button1 = "Confirm",
		button2 = "Cancel",
		hasEditBox = true,
		editBoxWidth = 160,
		OnShow = function(dialog)
			local action = StandingsFrame._pendingMenuAction
			-- Modern WoW uses dialog.Buttons[1] instead of dialog.button1
			local confirmBtn = (dialog.Buttons and dialog.Buttons[1]) or dialog.button1
			if action and action.action == "block" then
				dialog.Text:SetText("Are you sure you want to |cffff0000BLOCK|r " .. (action.player or "this player") .. "?\n\nThis permanently removes them and blocks re-sync.\nUse /cb unblock to reverse.")
				dialog.EditBox:Hide()
				if confirmBtn then confirmBtn:Enable() end
			else
				dialog.Text:SetText("Are you sure you want to |cffff8800PURGE|r " .. (action and action.player or "this player") .. "?\n\nThis |cffff4444permanently deletes|r all EP/GP/attendance data.\nType |cffff8800PURGE|r to confirm:")
				dialog.EditBox:SetText("")
				dialog.EditBox:Show()
				dialog.EditBox:SetFocus()
				if confirmBtn then confirmBtn:Disable() end
			end
		end,
		EditBoxOnTextChanged = function(editBox)
			local parent = editBox:GetParent()
			local action = StandingsFrame._pendingMenuAction
			local confirmBtn = (parent.Buttons and parent.Buttons[1]) or parent.button1
			if action and action.action ~= "block" and confirmBtn then
				if editBox:GetText():upper() == "PURGE" then
					confirmBtn:Enable()
				else
					confirmBtn:Disable()
				end
			end
		end,
		OnAccept = function()
			local action = StandingsFrame._pendingMenuAction
			StandingsFrame._pendingMenuAction = nil
			if not action then return end
			local fullName = action.player

			if action.action == "block" then
				Cerebro:BlockPlayer(fullName)
			else
				Cerebro:PurgePlayer(fullName)
			end
		end,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 4,
	}

	-- Load persisted selection from DB (map format: { [name] = true })
	Cerebro.db.global.customEP = Cerebro.db.global.customEP or {}
	local saved = Cerebro.db.global.customEP.selectedPlayers or {}
	self.selected = {}
	-- Handle both legacy array format and current map format
	if saved[1] then
		-- Legacy array format: { "Name1", "Name2" }
		for _, nm in ipairs(saved) do self.selected[nm] = true end
	else
		-- Map format: { ["Name1"] = true }
		for nm, val in pairs(saved) do if val then self.selected[nm] = true end end
	end
	self._rowCheckboxes = {}
end

--[[=============================================
	Helper Methods
===============================================]]

function StandingsFrame:IsOfficerSafe()
	-- Use UI helper if available, fall back to Cerebro
	if Cerebro.modules and Cerebro.modules.UI and Cerebro.modules.UI.IsOfficerSafe then
		return Cerebro.modules.UI:IsOfficerSafe()
	end
	return Cerebro and Cerebro.IsOfficer and Cerebro:IsOfficer() or false
end

--- Returns the RGB color for a player's addon compliance state (officer-only).
-- Non-officers always receive the standard guild/non-guild binary color.
-- Fix 9: States mapped to colors:
--   synced   = green      (in sync, current version)
--   outdated = yellow     (in sync, old version)
--   inactive = warm gray  (was seen before, now quiet >90s)
--   no_addon = RED        (online in guild, never heartbeated = no addon installed)
--   unknown  = dim gray   (offline or not in guild; can't determine)
function StandingsFrame:GetAddonStateColor(fullName, isInGuild)
	if not self:IsOfficerSafe() then
		-- Non-officers: original binary color
		return isInGuild and {r=0.0, g=1.0, b=0.0} or {r=0.6, g=0.6, b=0.6}
	end

	local v2 = Cerebro:GetModule("CommunicationV2", true)
	if not v2 then
		return isInGuild and {r=0.0, g=1.0, b=0.0} or {r=0.6, g=0.6, b=0.6}
	end

	local state = v2:GetAddonState(fullName)
	if state == "synced" then
		return {r=0.0,  g=1.0,  b=0.0}  -- Green
	elseif state == "outdated" then
		return {r=1.0,  g=0.85, b=0.0}  -- Yellow
	elseif state == "inactive" then
		return {r=0.55, g=0.55, b=0.55} -- Warm gray
	elseif state == "no_addon" then
		return {r=0.85, g=0.15, b=0.15} -- Red: online in guild, no addon detected
	else -- "unknown"
		return {r=0.35, g=0.35, b=0.35} -- Dim gray: offline / not in guild
	end
end


--- Returns tooltip text for a player's addon state.
function StandingsFrame:GetAddonStateTooltip(fullName)
	if not self:IsOfficerSafe() then return nil end
	local v2 = Cerebro:GetModule("CommunicationV2", true)
	if not v2 then return nil end

	local state = v2:GetAddonState(fullName)
	local s = Cerebro.db.global.syncStateV2 or {}
	local addonStatus = s.addonStatus or {}
	local info = addonStatus[fullName]

	local stateLabels = {
		synced   = "|cff00ff00Synced|r",
		outdated = "|cfffe8700Outdated version|r",
		inactive = "|cff8c8c8cInactive (addon seen before)|r",
		no_addon = "|cffff3333No addon detected|r",
		unknown  = "|cff666666Unknown (offline/not in guild)|r",
	}
	local label = stateLabels[state] or state

	if info and info.lastKnownVersion then
		label = label .. " v" .. info.lastKnownVersion
	end
	if info and info.lastSeenAt then
		local age = time() - info.lastSeenAt
		label = label .. string.format(" (last seen %ds ago)", age)
	end
	return label
end

--- Build and show the sync health tooltip on a frame.
function StandingsFrame:ShowSyncHealthTooltip(anchorFrame)
	if not self:IsOfficerSafe() then return end
	local v2 = Cerebro:GetModule("CommunicationV2", true)
	if not v2 then return end

	local s = Cerebro.db.global.syncStateV2 or {}
	local peerHashes = s.peerHashes or {}

	local myRosterHash = v2:ComputeRosterHash()
	local myConfigHash = v2:ComputeConfigHash()

	-- Hash convergence: only count active (online/recently-seen) peers
	-- Offline/unknown peers are excluded so the top rows reflect current reality
	-- Use GetAddonState() which handles name formats correctly (same as Addon Compliance)
	local now = time()
	local rMatch, rDiffer, cMatch, cDiffer = 0, 0, 0, 0
	for name, info in pairs(peerHashes) do
		-- peerHashes keys are short names (AceComm same-realm); normalize to full Name-Realm
		-- before calling GetAddonState which uses addonStatus (full name keys)
		local fullName = Cerebro:GetFullPlayerName(name) or name
		local state = v2:GetAddonState(fullName)
		if state == "synced" or state == "outdated" then
			if info.rosterHash == myRosterHash then rMatch = rMatch + 1 else rDiffer = rDiffer + 1 end
			if info.configHash == myConfigHash then cMatch = cMatch + 1 else cDiffer = cDiffer + 1 end
		end
	end
	-- Include self
	rMatch = rMatch + 1
	cMatch = cMatch + 1

	-- Addon compliance: scoped to visible guild roster members
	-- (same visibleRanks filter as DiscoverFullGuildRoster + standings).
	-- Uses GetAddonState() for classification (handles name normalization,
	-- version comparison, and inactive thresholds internally).
	local synced, outdated, inactive, offline = 0, 0, 0, 0
	local visRanks = (Cerebro.db.global.config and Cerebro.db.global.config.sync
		and Cerebro.db.global.config.sync.visibleRanks) or {}
	local purgedPlayers  = Cerebro.db.global.purgedPlayers  or {}
	local blockedPlayers = Cerebro.db.global.blockedPlayers or {}
	-- Use GetFullPlayerName (the TitleCase normalizer from Utilities.lua)
	-- to match the same format used for guild roster names at line 258.
	-- GetPlayerFullName() uses UnitName which can produce a different format.
	local myName = Cerebro:GetFullPlayerName(UnitName("player"))

	-- Self is always synced & current (we're running the addon right now).
	-- Count outside the loop to avoid relying on name-equality between
	-- GetPlayerFullName() and GetFullPlayerName(gName) which may differ.
	synced = synced + 1

	-- Iterate visible guild roster and classify each member (excluding self)
	for i = 1, GetNumGuildMembers() do
		local gName, _, rankIndex = GetGuildRosterInfo(i)
		if gName and rankIndex ~= nil and visRanks[rankIndex] then
			local full = Cerebro:GetFullPlayerName(gName)
			-- Skip self (already counted before loop) and removed players
			if full ~= myName and not purgedPlayers[full] and not blockedPlayers[full] then
				-- Use GetAddonState for consistent classification
				-- (same logic as name colors; handles name normalization)
				local state = v2:GetAddonState(full)
				if state == "synced" then
					synced = synced + 1
				elseif state == "outdated" then
					outdated = outdated + 1
				elseif state == "inactive" then
					inactive = inactive + 1
				else -- "unknown" or "no_addon"
					offline = offline + 1
				end
			end
		end
	end

	-- Removed players
	local purgedNames, blockedNames = {}, {}
	for name in pairs(purgedPlayers)  do table.insert(purgedNames,  name) end
	for name in pairs(blockedPlayers) do table.insert(blockedNames, name) end

	-- Last HB age
	local lastHBAge = s.lastHeartbeatSent and (now - s.lastHeartbeatSent) or nil

	local total = synced + outdated + inactive + offline
	GameTooltip:SetOwner(anchorFrame, "ANCHOR_BOTTOMLEFT")
	GameTooltip:ClearLines()
	GameTooltip:AddDoubleLine("Sync Health",
		string.format("%d / %d peers", rMatch, rMatch + rDiffer),
		1, 0.8, 0, 1, 1, 1)
	GameTooltip:AddLine(" ")
	GameTooltip:AddDoubleLine("Roster Hash",
		string.format("|cff00ff00%d match|r  |cffff4444%d differ|r", rMatch, rDiffer),
		1, 1, 1, 1, 1, 1)
	GameTooltip:AddDoubleLine("Config Hash",
		string.format("|cff00ff00%d match|r  |cffff4444%d differ|r", cMatch, cDiffer),
		1, 1, 1, 1, 1, 1)
	GameTooltip:AddLine(" ")
	GameTooltip:AddLine(string.format("Addon Compliance (%d members)", total), 1, 0.8, 0)
	GameTooltip:AddDoubleLine("  Synced & current",        tostring(synced),   1,1,1, 0,1,0)
	GameTooltip:AddDoubleLine("  Outdated version",        tostring(outdated),  1,1,1, 1,0.85,0)
	GameTooltip:AddDoubleLine("  Inactive (seen before)",  tostring(inactive),  1,1,1, 0.45,0.45,0.45)
	GameTooltip:AddDoubleLine("  Offline",                 tostring(offline),   1,1,1, 0.45,0.50,0.62)
	GameTooltip:AddLine(" ")
	GameTooltip:AddDoubleLine("Epoch",
		tostring(Cerebro.db.global.resetEpoch or 0), 1,1,1, 1,1,1)
	if lastHBAge then
		GameTooltip:AddDoubleLine("Last HB",
			string.format("%ds ago", lastHBAge), 1,1,1, 1,1,1)
	end
	GameTooltip:Show()
end

--- Show tooltip listing only removed (purged/blocked) players.
-- Bound to the 'N removed' label hover; click opens the management popup.
function StandingsFrame:ShowRemovedPlayersTooltip(anchorFrame)
	if not self:IsOfficerSafe() then return end

	local purged  = Cerebro.db.global.purgedPlayers  or {}
	local blocked = Cerebro.db.global.blockedPlayers or {}

	GameTooltip:SetOwner(anchorFrame, "ANCHOR_BOTTOMLEFT")
	GameTooltip:ClearLines()
	GameTooltip:AddLine("Removed Players", 1, 0.8, 0)
	GameTooltip:AddLine("Click to manage (Restore / Unblock)", 0.7, 0.7, 0.7)
	GameTooltip:AddLine(" ")
	for name in pairs(purged) do
		local short = Cerebro:GetShortPlayerName(name) or name
		GameTooltip:AddLine("|cffff8800Purged:|r  " .. short)
	end
	for name in pairs(blocked) do
		local short = Cerebro:GetShortPlayerName(name) or name
		GameTooltip:AddLine("|cffff4444Blocked:|r " .. short)
	end
	GameTooltip:Show()
end

--- Open the Removed Players popup (officer-only).
-- Lists purged and blocked players with Restore/Unblock buttons.
-- Fixes: scroll container for long lists, duplicate guard, fixed frame height.
function StandingsFrame:ShowRemovedPlayersPopup()
	if not self:IsOfficerSafe() then return end

	-- Duplicate guard: close any existing popup before opening a new one
	if self._removedPlayersPopup then
		pcall(function() self._removedPlayersPopup:Release() end)
		self._removedPlayersPopup = nil
	end

	local purged  = Cerebro.db.global.purgedPlayers  or {}
	local blocked = Cerebro.db.global.blockedPlayers or {}

	if not next(purged) and not next(blocked) then
		Cerebro:Print("No purged or blocked players.")
		return
	end

	-- Fixed height: keeps the AceGUI close button always visible regardless of entry count.
	-- Items beyond the visible area scroll via the ScrollFrame child container.
	local popupFrame = AceGUI:Create("Frame")
	popupFrame:SetTitle("Removed Players")
	popupFrame:SetWidth(420)
	popupFrame:SetHeight(350)
	popupFrame:SetLayout("Fill")
	popupFrame:SetCallback("OnClose", function(w)
		self._removedPlayersPopup = nil
		w:Release()
	end)
	self._removedPlayersPopup = popupFrame
	_G["CerebroRemovedPlayersPopup"] = popupFrame.frame
	tinsert(UISpecialFrames, "CerebroRemovedPlayersPopup")

	-- Scroll container: allows any number of entries without overflowing the frame
	local scroll = AceGUI:Create("ScrollFrame")
	scroll:SetLayout("Flow")
	scroll:SetFullWidth(true)
	popupFrame:AddChild(scroll)

	local function addRow(name, kind)
		local labelText = string.format("|cff%s%s|r  %s",
			kind == "purged" and "ff8800" or "ff4444",
			kind == "purged" and "Purged" or "Blocked",
			Cerebro:GetShortPlayerName(name) or name)

		local rowLabel = AceGUI:Create("Label")
		rowLabel:SetText(labelText)
		rowLabel:SetRelativeWidth(0.65)
		scroll:AddChild(rowLabel)

		local actionLabel = kind == "purged" and "Restore" or "Unblock"
		local btn = AceGUI:Create("Button")
		btn:SetText(actionLabel)
		btn:SetRelativeWidth(0.30)
		btn:SetCallback("OnClick", function()
			if not self:IsOfficerSafe() then return end
			if kind == "purged" then
				-- Restore: remove from purge list and recreate Core record.
				-- Look up class from guild roster so the record isn't classless.
				Cerebro.db.global.purgedPlayers[name] = nil
				local baseGP = (Cerebro.db.global.config.epgp
					and Cerebro.db.global.config.epgp.baseGP) or 200
				local restoredClass = nil
				pcall(function()
					for i = 1, GetNumGuildMembers() do
						local gn, _, _, _, _, _, _, _, _, _, cf = GetGuildRosterInfo(i)
						if gn and Cerebro:GetFullPlayerName(gn) == name then
							restoredClass = cf; break
						end
					end
				end)
				Cerebro.db.global.players[name] = {
					ep=0, gp=baseGP, pr=0,
					status="Core",
					class = restoredClass,
					lastUpdate = GetServerTime and GetServerTime() or time(),
				}
				if Cerebro.Ledger then
					Cerebro.Ledger:AddAuditLog("RESTORE_PLAYER",
						"Restored purged player " .. name)
				end
				Cerebro:Print("Restored " .. (Cerebro:GetShortPlayerName(name) or name))
			else
				-- Unblock: remove from blocked list
				Cerebro.db.global.blockedPlayers[name] = nil
				if Cerebro.Ledger then
					Cerebro.Ledger:AddAuditLog("UNBLOCK_PLAYER",
						"Unblocked player " .. name)
				end
				Cerebro:Print("Unblocked " .. (Cerebro:GetShortPlayerName(name) or name))
			end
			if Cerebro.modules.CommunicationV2 then
				Cerebro.modules.CommunicationV2:NotifyStateChange("removed-player-restored")
			end
			Cerebro:RecalculateAllPR()
			Cerebro:RefreshAllUI()
			-- Rebuild popup with updated data (popupFrame still open)
			self._removedPlayersPopup = nil
			popupFrame:Release()
			self:ShowRemovedPlayersPopup()
		end)
		scroll:AddChild(btn)
	end

	for name in pairs(purged)  do addRow(name, "purged")  end
	for name in pairs(blocked) do addRow(name, "blocked") end
end


function StandingsFrame:GetAllPlayersSafe()
	-- Nil-safe wrapper for GetAllPlayers
	local success, players = pcall(function()
		return Cerebro:GetAllPlayers()
	end)
	
	if not success or not players then
		Cerebro:Debug("StandingsFrame: GetAllPlayers failed, returning empty list")
		return {}
	end
	
	return players
end

--[[=============================================
	Selection Management (Officer-Only)
===============================================]]

-- Persist current selection to DB as map format for direct key lookup.
-- Session.lua reads this via Cerebro.db.global.customEP.selectedPlayers[name].
function StandingsFrame:SaveSelection()
	Cerebro.db.global.customEP = Cerebro.db.global.customEP or {}
	local stored = {}
	for name in pairs(self.selected) do
		stored[name] = true
	end
	Cerebro.db.global.customEP.selectedPlayers = stored
	Cerebro.db.global.customEP.lastUpdate = time()
end

-- Clear all selections
function StandingsFrame:ClearSelection()
	self.selected = {}
	for _, entry in pairs(self._rowCheckboxes or {}) do
		if entry.check and entry.check.SetChecked then entry.check:SetChecked(false) end
		if entry.bg and entry.bg.Hide then entry.bg:Hide() end
	end
	self:SaveSelection()
end

-- Set selection from a list of names
function StandingsFrame:SetSelection(list)
	self.selected = {}
	for _, name in ipairs(list or {}) do
		self.selected[name] = true
		local entry = self._rowCheckboxes[name]
		if entry and entry.check and entry.check.SetChecked then entry.check:SetChecked(true) end
		if entry and entry.bg and entry.bg.Show then entry.bg:Show() end
	end
	self:SaveSelection()
end

-- Get list of selected player names
function StandingsFrame:GetSelectedPlayers()
	local result = {}
	for name in pairs(self.selected) do
		table.insert(result, name)
	end
	return result
end

-- Update the Select All checkbox to match current visible row state
function StandingsFrame:UpdateSelectAllState()
	local cb = self._selectAllBox
	if not cb or not cb.SetValue then return end
	local total, sel = 0, 0
	if self._visibleNames and #self._visibleNames > 0 then
		for _, nm in ipairs(self._visibleNames) do
			total = total + 1
			if self.selected[nm] then sel = sel + 1 end
		end
	else
		for name in pairs(self._rowCheckboxes or {}) do
			total = total + 1
			if self.selected[name] then sel = sel + 1 end
		end
	end
	pcall(function() cb:SetValue(total > 0 and sel == total) end)
end

--[[=============================================
	Right-Click Context Menu
===============================================]]

-- Show context menu for a player row (right-click)
function StandingsFrame:ShowPlayerContextMenu(playerFullName, anchorFrame)
	local isOfficer = self:IsOfficerSafe()
	local menu = {}
	table.insert(menu, { text = string.format("|cff00ccff%s|r", Cerebro:GetShortPlayerName(playerFullName)), isTitle = true, notCheckable = true })

	-- Officer EP/GP adjustment actions (opens dialog with amount + reason)
	if isOfficer then
		local function queueAdjust(currency, sign, defaultReason)
			self:ShowAdjustPointsDialog({ player = playerFullName, currency = currency, sign = sign, defaultReason = defaultReason })
		end

		table.insert(menu, { text = "|cff00ff00Add EP...|r", notCheckable = true, func = function() queueAdjust("EP", 1, "Officer Adjust") end })
		table.insert(menu, { text = "|cffff0000Subtract EP...|r", notCheckable = true, func = function() queueAdjust("EP", -1, "Officer Adjust") end })
		table.insert(menu, { text = "|cff00ff00Add GP...|r", notCheckable = true, func = function() queueAdjust("GP", 1, "Manual") end })
		table.insert(menu, { text = "|cffff0000Subtract GP...|r", notCheckable = true, func = function() queueAdjust("GP", -1, "Manual") end })

		table.insert(menu, { text = "", notCheckable = true, isSeparator = true })

		-- Admit to Core
		table.insert(menu, { text = "Admit to Core", notCheckable = true, func = function()
			local data = Cerebro:GetPlayerData(playerFullName)
			if data.status == "Core" then
				Cerebro:Print(Cerebro:ColorPlayerName(playerFullName) .. " is already Core.")
				return
			end
			data.status = "Core"
			data.coreAdmittedAt = time()
			data.lastUpdate = time()
			-- Attempt to fill class from guild roster if missing
			if not data.class then
				for i = 1, GetNumGuildMembers() do
					local name, _, _, _, _, _, _, _, _, _, classFileName = GetGuildRosterInfo(i)
					if name and Cerebro:GetFullPlayerName(name) == playerFullName then
						data.class = classFileName
						break
					end
				end
			end
			Cerebro:Print(string.format("Admitted %s to Core.", Cerebro:ColorPlayerName(playerFullName)))
			if Cerebro.Ledger then Cerebro.Ledger:AddAuditLog("ADMIT_PLAYER", "Promoted " .. playerFullName .. " to Core status.") end
			-- Broadcast via V2
			local v2 = Cerebro:GetModule("CommunicationV2", true)
			if v2 and v2.BroadcastPlayerUpdate then
				v2:BroadcastPlayerUpdate(playerFullName, {
					status = "Core",
					class = data.class,
					coreAdmittedAt = data.coreAdmittedAt,
					lastUpdate = data.lastUpdate
				})
			end
			Cerebro:RefreshAllUI()
		end })

		table.insert(menu, { text = "", notCheckable = true, isSeparator = true })

		-- Purge Player (reset EP/GP) — locked during active sessions
		local sessionActive = Cerebro.modules.Session and Cerebro.modules.Session:IsActive()
		if sessionActive then
			table.insert(menu, { text = "|cff666666Purge Player (locked during session)|r", notCheckable = true, disabled = true })
		else
			table.insert(menu, { text = "|cffff8800Purge Player (reset)|r", notCheckable = true, func = function()
				self._pendingMenuAction = { player = playerFullName, action = "purge" }
				StaticPopup_Show("CEREBRO_CONFIRM_REMOVE_PLAYER")
			end })
		end

		-- Block Player (permanent sync block)
		table.insert(menu, { text = "|cffff0000Block Player (permanent)|r", notCheckable = true, func = function()
			self._pendingMenuAction = { player = playerFullName, action = "block" }
			StaticPopup_Show("CEREBRO_CONFIRM_REMOVE_PLAYER")
		end })
	end

	table.insert(menu, { text = "Close", notCheckable = true, func = function() end })

	-- Build EasyMenu-compatible table
	local easyMenu = {}
	for _, item in ipairs(menu) do
		table.insert(easyMenu, {
			text = item.text,
			func = item.func,
			disabled = item.disabled,
			notCheckable = item.notCheckable,
			isTitle = item.isTitle,
			isSeparator = item.isSeparator,
		})
	end

	-- Show menu via EasyMenu (preferred) or UIDropDown fallback
	local anchor = anchorFrame and (anchorFrame.frame or anchorFrame) or "cursor"
	if type(EasyMenu) == "function" then
		EasyMenu(easyMenu, CONTEXT_MENU_FRAME, anchor, 0, 0, "MENU")
	else
		CloseDropDownMenus()
		local function init(_, level)
			for _, entry in ipairs(easyMenu) do
				local info = UIDropDownMenu_CreateInfo()
				if entry.isSeparator then
					info.isSeparator = true
					UIDropDownMenu_AddButton(info, level)
				else
					info.text = entry.text or ""
					info.func = entry.func
					info.disabled = entry.disabled
					info.notCheckable = entry.notCheckable
					info.isTitle = entry.isTitle
					UIDropDownMenu_AddButton(info, level)
				end
			end
		end
		UIDropDownMenu_Initialize(CONTEXT_MENU_FRAME, init, "MENU")
		ToggleDropDownMenu(1, nil, CONTEXT_MENU_FRAME, anchor, 0, 0, nil, nil, "MENU")
	end
end

-- Officer dialog: amount + reason input for EP/GP adjustments
function StandingsFrame:ShowAdjustPointsDialog(action)
	if not self:IsOfficerSafe() then
		Cerebro:Print("Officer permissions required.")
		return
	end

	local verb = (action.sign or 1) == 1 and "Add" or "Subtract"
	local currency = action.currency or "EP"
	local playerShort = Cerebro:GetShortPlayerName(action.player or "")

	local frame = AceGUI:Create("Frame")
	frame:SetTitle(string.format("%s %s to \"%s\"", verb, currency, playerShort))
	frame:SetWidth(420)
	frame:SetHeight(170)
	frame:SetLayout("Flow")
	frame:SetCallback("OnClose", function(widget) widget:Release() end)

	-- Allow ESC to close
	_G["CerebroAdjustPointsPopup"] = frame.frame
	tinsert(UISpecialFrames, "CerebroAdjustPointsPopup")

	-- Amount input
	local amtLabel = AceGUI:Create("Label")
	amtLabel:SetText("Amount")
	amtLabel:SetFullWidth(false)
	frame:AddChild(amtLabel)

	local amtBox = AceGUI:Create("EditBox")
	amtBox:SetWidth(120)
	amtBox:SetText("")
	frame:AddChild(amtBox)

	-- Reason input
	local reasonLabel = AceGUI:Create("Label")
	reasonLabel:SetText("Reason (will be logged)")
	reasonLabel:SetFullWidth(false)
	frame:AddChild(reasonLabel)

	local reasonBox = AceGUI:Create("EditBox")
	reasonBox:SetWidth(260)
	reasonBox:SetText(action.defaultReason or "")
	frame:AddChild(reasonBox)

	-- Spacer
	local spacer = AceGUI:Create("Label")
	spacer:SetText(" ")
	spacer:SetFullWidth(true)
	frame:AddChild(spacer)

	-- Buttons
	local btnGroup = AceGUI:Create("SimpleGroup")
	btnGroup:SetLayout("Flow")
	btnGroup:SetFullWidth(true)
	frame:AddChild(btnGroup)

	local function doSubmit()
		local amount = tonumber(amtBox:GetText())
		if not amount then
			Cerebro:Print("|cffff0000Invalid amount entered.|r")
			pcall(function() if amtBox.editbox then amtBox.editbox:SetFocus() end end)
			return
		end

		local fullName = action.player
		local epgp = Cerebro:GetModule("EPGP")
		local reason = reasonBox:GetText() or action.defaultReason or "Officer Adjust"

		if not epgp then
			Cerebro:Print("EPGP module not loaded; cannot apply adjustment.")
			frame:Release()
			return
		end

		local value = amount * (action.sign or 1)
		if currency == "EP" then
			epgp:AwardEP(fullName, value, "OFFICER_ADJUST", reason)
			Cerebro:Print(string.format("%s %d EP %s %s.", (value >= 0) and "Added" or "Subtracted", math.abs(value), (value >= 0) and "to" or "from", playerShort))
		else
			epgp:ChargeGP(fullName, value, "OFFICER_ADJUST", reason)
			Cerebro:Print(string.format("%s %d GP %s %s.", (value >= 0) and "Added" or "Subtracted", math.abs(value), (value >= 0) and "to" or "from", playerShort))
		end

		if Cerebro.Ledger then
			pcall(function()
				Cerebro.Ledger:AddAuditLog("MANUAL_ADJUST", string.format("%s %d %s to %s (%s)", (action.sign == 1) and "ADD" or "SUB", amount, currency, fullName, reason))
			end)
		end

		Cerebro:RefreshAllUI()
		frame:Release()
	end

	local okBtn = AceGUI:Create("Button")
	okBtn:SetText("Submit")
	okBtn:SetWidth(120)
	okBtn:SetCallback("OnClick", doSubmit)
	btnGroup:AddChild(okBtn)

	local cancelBtn = AceGUI:Create("Button")
	cancelBtn:SetText("Cancel")
	cancelBtn:SetWidth(120)
	cancelBtn:SetCallback("OnClick", function() frame:Release() end)
	btnGroup:AddChild(cancelBtn)

	-- Enter key flow: Amount → Reason → Submit
	amtBox:SetCallback("OnEnterPressed", function()
		pcall(function() if reasonBox.editbox then reasonBox.editbox:SetFocus() end end)
	end)
	reasonBox:SetCallback("OnEnterPressed", function() doSubmit() end)

	-- Auto-focus amount field
	pcall(function()
		if amtBox.editbox then
			Cerebro:ScheduleTimer(function()
				pcall(function() amtBox.editbox:SetFocus() end)
			end, 0.01)
		end
	end)
end

--[[=============================================
	Attendance Calculation
===============================================]]

function StandingsFrame:CalculateAttendanceDisplay(playerData)
	-- Returns (displayText, colorTable) for attendance column
	-- Uses totalBossKills (synced from officers) as the denominator
	
	local bossKillsPresent = playerData.bossKillsPresent or 0
	
	-- If no coreAdmittedAt, check status to determine if player is established or new
	if not playerData.coreAdmittedAt then
		local status = playerData.status or "Core"
		if status ~= "Core" and status ~= "Officer" and status ~= "Veteran Member" and status ~= "Survivor" then
			return "— (NEW)", {r=0.7, g=0.7, b=0.7}  -- Gray for new
		end
	end
	
	-- Use eligibleBossKills (pre-computed by officer, synced to all clients)
	local eligibleBosses = playerData.eligibleBossKills or 0
	
	-- No eligible bosses = new season or no data yet
	if eligibleBosses == 0 then
		return "— (NEW)", {r=0.7, g=0.7, b=0.7}  -- Gray for new
	end
	
	-- Calculate percentage (cap at 100% for display sanity)
	local pct = math.min(100, math.floor((bossKillsPresent / eligibleBosses) * 100))
	local displayText = string.format("%d/%d (%d%%)", bossKillsPresent, eligibleBosses, pct)
	
	-- Color coding
	local color
	if pct >= 95 then
		color = {r=0.0, g=1.0, b=0.0}  -- Green
	elseif pct >= 80 then
		color = {r=1.0, g=1.0, b=0.0}  -- Yellow
	else
		color = {r=1.0, g=0.0, b=0.0}  -- Red
	end
	
	return displayText, color
end

function StandingsFrame:GetExtraColumns()
	-- Extension point for officer-only columns (not implemented yet)
	-- Future: Add "Actions" column with +EP/-GP buttons for officers
	return {}
end

function StandingsFrame:GetColumns()
	local columns = {}
	-- Prepend selection column for officers
	if self:IsOfficerSafe() then
		tinsert(columns, SELECT_COLUMN)
	end
	for _, col in ipairs(BASE_COLUMNS) do tinsert(columns, col) end
	if self:IsOfficerSafe() then
		for _, col in ipairs(self:GetExtraColumns()) do tinsert(columns, col) end
	end
	return columns
end

function StandingsFrame:CanRedraw()
	-- Refresh budget guard: prevent refresh storms
	local now = time()
	local elapsed = now - self.lastRedrawTime
	
	if elapsed < self.REDRAW_BUDGET_SECONDS then
		Cerebro:Debug("StandingsFrame: Refresh throttled (budget guard)")
		return false
	end
	
	return true
end

--- Fix 8: Compute sync health summary from peer hash state (DRY helper).
-- Replaces three copies of the sync % calculation in Draw(), Refresh(),
-- and ShowSyncHealthTooltip().
-- Returns: syncedPeers, totalPeers, syncPct
function StandingsFrame:ComputeSyncHealth()
	local s = Cerebro.db.global.syncStateV2 or {}
	local peerHashes = s.peerHashes or {}
	local v2 = Cerebro:GetModule("CommunicationV2", true)
	local myRosterHash = v2 and v2:ComputeRosterHash() or nil
	local myConfigHash = v2 and v2:ComputeConfigHash() or nil
	local syncedPeers, totalPeers = 0, 0
	for name, info in pairs(peerHashes) do
		-- peerHashes keys are short names (AceComm same-realm); normalize to full Name-Realm
		-- before calling GetAddonState which uses addonStatus (full name keys)
		local fullName = v2 and (Cerebro:GetFullPlayerName(name) or name) or name
		local state = v2 and v2:GetAddonState(fullName) or "unknown"
		if state == "synced" or state == "outdated" then
			totalPeers = totalPeers + 1
			if info.rosterHash == myRosterHash and info.configHash == myConfigHash then
				syncedPeers = syncedPeers + 1
			end
		end
	end
	-- Include self: local player is never in peerHashes but is always synced
	syncedPeers = syncedPeers + 1
	totalPeers  = totalPeers  + 1
	local pct = totalPeers > 0 and math.floor((syncedPeers / totalPeers) * 100) or 100
	return syncedPeers, totalPeers, pct
end

--[[=============================================
	Rendering Engine
===============================================]]

function StandingsFrame:Draw(container)
	container:ReleaseChildren()
	
	-- Filter/Search Bar
	local filterGroup = AceGUI:Create("SimpleGroup")
	filterGroup:SetFullWidth(true)
	filterGroup:SetLayout("Flow")
	container:AddChild(filterGroup)
	
	local searchBox = AceGUI:Create("EditBox")
	searchBox:SetLabel("Search Player")
	searchBox:SetWidth(200)
	searchBox:SetText(self.filterText)
	searchBox:DisableButton(true)
	searchBox:SetCallback("OnTextChanged", function(widget, event, text)
		self.filterText = text:lower()
		-- Debounced refresh (basic timer approach)
		if self.filterTimer then
			Cerebro:CancelTimer(self.filterTimer)
		end
		self.filterTimer = Cerebro:ScheduleTimer(function()
			self:Refresh(container)
			self.filterTimer = nil
		end, 0.3)
	end)
	filterGroup:AddChild(searchBox)
	
	-- Sync Button (RBAC-aware)
	local syncBtn = AceGUI:Create("Button")
	syncBtn:SetText("Force Sync")
	syncBtn:SetWidth(120)
	syncBtn:SetCallback("OnClick", function()
		if self:IsOfficerSafe() then
			-- Full sync reset: clear delta state and force immediate full sync (same as /cb syncreset)
			local s = Cerebro.db.global.syncState
			if s then
				s.pendingSyncs = {}
				s.dirtyPlayers = {}
				s.lastFullSyncAt = 0
				s.dirtyRetainUntil = nil
			end
			
			Cerebro:RepairDatabase()
			
			if Cerebro.modules.CommunicationV2 then
				Cerebro:RecalculateAllPR()
				local sent = Cerebro.modules.CommunicationV2:SyncToRaid()
				if sent then
					Cerebro:Print("|cff00ff00Sync state reset.|r Full sync sent to all clients.")
				else
					Cerebro:Print("|cffff8800Sync deferred until combat ends.|r")
				end
			end
			
			-- Audit log
			if Cerebro.Ledger then
				Cerebro.Ledger:AddAuditLog("SYNC_RESET", "Force Sync button pressed (UI)")
			end
		else
			if Cerebro.modules.CommunicationV2 then
				Cerebro.modules.CommunicationV2:RequestSync()
			end
			Cerebro:Print("Sync requested from officers...")
		end
		-- Update status bar immediately
		if Cerebro.modules.UI then
			Cerebro.modules.UI:UpdateStatusText()
		end
		self:Refresh(container)
		
		-- Schedule a synccheck after 10s to verify convergence
		local v2 = Cerebro:GetModule("CommunicationV2", true)
		if v2 and v2.SendHeartbeat then
			Cerebro:ScheduleTimer(function()
				-- Clear stale peerHashes for fresh check
				local sv2 = Cerebro.db.global.syncStateV2 or {}
				sv2.peerHashes = {}
				Cerebro.db.global.syncStateV2 = sv2
				
				v2:SendHeartbeat()
				Cerebro:Print("|cff00ff00[Sync Check]|r Verifying peer convergence (8s)...")
				
				Cerebro:ScheduleTimer(function()
					local sv2State = Cerebro.db.global.syncStateV2 or {}
					local myRosterHash = v2:ComputeRosterHash()
					local myConfigHash = v2:ComputeConfigHash()
					local matched, mismatched = 0, 0
					local mismatchNames = {}
					
					for name, peer in pairs(sv2State.peerHashes or {}) do
						if peer.lastSeen and (time() - peer.lastSeen) < 10 then
							local rOk = peer.rosterHash == myRosterHash
							local cOk = peer.configHash == myConfigHash
							if rOk and cOk then
								matched = matched + 1
							else
								mismatched = mismatched + 1
								local shortName = Cerebro:GetShortPlayerName(name) or name
								local detail = ""
								if not rOk then detail = detail .. "roster" end
								if not cOk then
									if detail ~= "" then detail = detail .. "+" end
									detail = detail .. "config"
								end
								table.insert(mismatchNames, string.format("%s(%s)", shortName, detail))
							end
						end
					end
					
					Cerebro:Print(string.format("|cff00ff00[Sync Check]|r Peers: |cff00ff00%d matched|r, |cffff4444%d mismatched|r", matched, mismatched))
					if mismatched > 0 then
						Cerebro:Print("  Mismatched: " .. table.concat(mismatchNames, ", "))
					elseif matched > 0 then
						Cerebro:Print("  |cff00ff00All peers converged!|r")
					end
					
					-- Update status bar with fresh state
					if Cerebro.modules.UI then
						Cerebro.modules.UI:UpdateStatusText()
					end
				end, 8)
			end, 10)
		end
	end)
	filterGroup:AddChild(syncBtn)

	-- Select All checkbox (officer-only, next to sync button)
	if self:IsOfficerSafe() then
		local selectAllBox = AceGUI:Create("CheckBox")
		selectAllBox:SetLabel("Select All")
		selectAllBox:SetWidth(110)
		selectAllBox:SetValue(false)
		selectAllBox:SetCallback("OnValueChanged", function(_, _, value)
			-- Toggle all visible rows
			if value then
				for _, nm in ipairs(self._visibleNames or {}) do
					self.selected[nm] = true
					local entry = self._rowCheckboxes[nm]
					if entry and entry.check then pcall(function() entry.check:SetChecked(true) end) end
					if entry and entry.bg then pcall(function() entry.bg:Show() end) end
				end
			else
				for _, nm in ipairs(self._visibleNames or {}) do
					self.selected[nm] = nil
					local entry = self._rowCheckboxes[nm]
					if entry and entry.check then pcall(function() entry.check:SetChecked(false) end) end
					if entry and entry.bg then pcall(function() entry.bg:Hide() end) end
				end
			end
			self:SaveSelection()
		end)
		filterGroup:AddChild(selectAllBox)
		self._selectAllBox = selectAllBox
	end

	-- Sync Health Bar (officer-only) — two separate hover zones:
	--   1. Sync percentage label: hover = hash/compliance stats, no click
	--   2. Removed count label:   hover = removed players list, click = popup
	if self:IsOfficerSafe() then
		-- Fix 8: Use DRY ComputeSyncHealth() helper instead of inline duplication
		local syncedPeers, totalPeers, syncPct = self:ComputeSyncHealth()

		-- Label 1: sync percentage (hover = sync health tooltip)
		-- Green text when 100% synced, white otherwise
		local healthLabel = AceGUI:Create("InteractiveLabel")
		local syncLabelText
		if syncPct == 100 then
			syncLabelText = string.format("|cff00ff00Sync: 100%% (%d/%d)|r", syncedPeers, totalPeers)
		else
			syncLabelText = string.format("Sync: %d%% (%d/%d)", syncPct, syncedPeers, totalPeers)
		end
		healthLabel:SetText(syncLabelText)
		healthLabel:SetWidth(175)
		healthLabel:SetCallback("OnEnter", function(widget)
			self:ShowSyncHealthTooltip(widget.frame)
		end)
		healthLabel:SetCallback("OnLeave", function() GameTooltip:Hide() end)
		filterGroup:AddChild(healthLabel)
		self._syncHealthLabel = healthLabel

		-- Label 2: removed count (hover = removed list tooltip, click = popup)
		local removedCount = 0
		for _ in pairs(Cerebro.db.global.purgedPlayers  or {}) do removedCount = removedCount + 1 end
		for _ in pairs(Cerebro.db.global.blockedPlayers or {}) do removedCount = removedCount + 1 end
		if removedCount > 0 then
			local removedLabel = AceGUI:Create("InteractiveLabel")
			removedLabel:SetText("|cffff8800| " .. removedCount .. " removed|r")
			removedLabel:SetWidth(90)
			removedLabel:SetCallback("OnEnter", function(widget)
				self:ShowRemovedPlayersTooltip(widget.frame)
			end)
			removedLabel:SetCallback("OnLeave", function() GameTooltip:Hide() end)
			removedLabel:SetCallback("OnClick", function()
				self:ShowRemovedPlayersPopup()
			end)
			filterGroup:AddChild(removedLabel)
			self._removedPlayersLabel = removedLabel
		end
	end

	-- Scroll Frame
	local scroll = AceGUI:Create("ScrollFrame")
	scroll:SetFullWidth(true)
	scroll:SetFullHeight(true)
	scroll:SetLayout("Flow")
	container:AddChild(scroll)
	
	self.scrollContainer = scroll
	self:DrawTable(scroll)
end

function StandingsFrame:Refresh(container)
	if not self:CanRedraw() then
		return
	end

	-- Update sync health label and removed label with live data on every refresh.
	-- Draw() creates these labels once; Refresh() must update the text explicitly
	-- because the filter row is NOT rebuilt on every redraw.
	if self._syncHealthLabel and self:IsOfficerSafe() then
		-- Fix 8: Use ComputeSyncHealth() DRY helper instead of inline duplication
		local syncedPeers, totalPeers, syncPct = self:ComputeSyncHealth()
		pcall(function()
			local syncLabelText
			if syncPct == 100 then
				syncLabelText = string.format("|cff00ff00Sync: 100%% (%d/%d)|r", syncedPeers, totalPeers)
			else
				syncLabelText = string.format("Sync: %d%% (%d/%d)", syncPct, syncedPeers, totalPeers)
			end
			self._syncHealthLabel:SetText(syncLabelText)
		end)
		if self._removedPlayersLabel then
			local removedCount = 0
			for _ in pairs(Cerebro.db.global.purgedPlayers  or {}) do removedCount = removedCount + 1 end
			for _ in pairs(Cerebro.db.global.blockedPlayers or {}) do removedCount = removedCount + 1 end
			pcall(function()
				if removedCount == 0 then
					-- Hide but don't destroy: clear text so the widget takes no visual space.
					self._removedPlayersLabel:SetText("")
				else
					self._removedPlayersLabel:SetText("|cffff8800| " .. removedCount .. " removed|r")
				end
			end)
		end
	end

	if self.scrollContainer and self.scrollContainer.frame and self.scrollContainer.frame:IsShown() then
		-- Only refresh if the widget is actually visible/attached
		self:Redraw(self.scrollContainer)
	elseif container then
		self:Draw(container)
	end
end

function StandingsFrame:Redraw(container)
	-- Retire stale native checkbox frames before AceGUI recycles its widgets
	self:RetireCheckboxFrames()
	-- Deterministic, idempotent redraw: clear all children and rebuild
	container:ReleaseChildren()
	self:DrawTable(container)
	self.lastRedrawTime = time()
end

-- Retire all native CheckButton frames and row decorations (textures, scripts)
-- to prevent them from leaking into recycled AceGUI frames.
function StandingsFrame:RetireCheckboxFrames()
	if not self._checkboxPool then
		local pool = CreateFrame("Frame", AddonName.."StandingsChkPool", UIParent)
		pool:Hide()
		self._checkboxPool = pool
	end
	local pool = self._checkboxPool
	-- Retire checkbox frames
	for _, entry in pairs(self._rowCheckboxes or {}) do
		if entry.check then
			pcall(function()
				entry.check:SetScript("OnClick", nil)
				entry.check:Hide()
				entry.check:ClearAllPoints()
				entry.check:SetParent(pool)
			end)
		end
	end
	self._rowCheckboxes = {}
	-- Retire row decorations (select/hover textures and mouse scripts)
	for _, rowFrame in ipairs(self._decoratedRows or {}) do
		pcall(function()
			if rowFrame._selectBg then rowFrame._selectBg:Hide() end
			if rowFrame._hoverBg then rowFrame._hoverBg:Hide() end
			rowFrame:SetScript("OnEnter", nil)
			rowFrame:SetScript("OnLeave", nil)
			rowFrame:SetScript("OnMouseUp", nil)
			rowFrame:EnableMouse(false)
		end)
	end
	self._decoratedRows = {}
end

function StandingsFrame:Clear()
	self:RetireCheckboxFrames()
	self.scrollContainer = nil
end


function StandingsFrame:DrawTable(container)
	local columns = self:GetColumns()
	
	-- Header
	local headerGroup = AceGUI:Create("SimpleGroup")
	headerGroup:SetFullWidth(true)
	headerGroup:SetLayout("Flow")
	container:AddChild(headerGroup)
	
	-- Check officer status for Pending player visibility
	local isOfficer = self:IsOfficerSafe()
	local myStatus = nil
	local myName = UnitName("player")
	
	local success, playerData = pcall(function()
		return Cerebro:GetPlayerData(myName)
	end)
	
	if success and playerData then
		myStatus = playerData.status
	end
	
	-- Show "Awaiting Approval" screen for pending non-officers
	if myStatus == "Pending" and not isOfficer then
		self:DrawAwaitingApproval(container)
		return
	end

	-- Render column headers
	for _, col in ipairs(columns) do
		local btn = AceGUI:Create("Button")
		
		local sortIndicator = ""
		if self.sortKey == col.sortKey then
			sortIndicator = self.sortAsc 
				and " |TInterface\\Buttons\\UI-ScrollBar-ScrollUpButton-Up:16:16|t" 
				or " |TInterface\\Buttons\\UI-ScrollBar-ScrollDownButton-Up:16:16|t"
		end
		
		btn:SetText(col.label .. sortIndicator)
		btn:SetRelativeWidth(col.width)
		btn:SetCallback("OnClick", function()
			if not col.sortKey then return end  -- Non-sortable columns (e.g. Due)
			if self.sortKey == col.sortKey then
				self.sortAsc = not self.sortAsc
			else
				self.sortKey = col.sortKey
				self.sortAsc = (col.numeric == false) -- String cols default asc, numeric default desc
			end
			self:Refresh(nil)
		end)
		headerGroup:AddChild(btn)
	end

	-- Data rows
	local players = self:GetAllPlayersSafe()
	local filtered = {}
	
	-- Filter players
	for _, p in ipairs(players) do
		local name = p.name or ""
		local status = p.status or "Core"
		
		-- RBAC: Exclude Pending players for non-officers
		if not (status == "Pending" and not isOfficer) then
			-- Apply filter text
			if self.filterText == "" or name:lower():find(self.filterText, 1, true) then
				table.insert(filtered, p)
			end
		end
	end
	
	-- Pre-compute derived sort values that aren't stored on player data
	for _, p in ipairs(filtered) do
		-- Guild rank name (fetched via API, not stored on data)
		if not p.guildRank then
			local fetchOk, rank = pcall(function() return Cerebro:GetPlayerGuildRank(p.name) end)
			p.guildRank = (fetchOk and rank) or nil  -- nil = not in guild
		end
		if p.guildRankIndex == nil then
			local idxOk, idx = pcall(function() return Cerebro:GetPlayerGuildRankIndex(p.name) end)
			p.guildRankIndex = (idxOk and idx) or nil  -- nil = not in guild
		end
		-- Display: rank name with index, e.g. "Officer(R1)", or dash for non-guild
		if p.guildRank and p.guildRankIndex then
			p.guildRankDisplay = string.format("%s(R%d)", p.guildRank, p.guildRankIndex)
		else
			p.guildRankDisplay = p.guildRank or "--"
		end
		-- Attendance percentage (computed, not stored)
		if not p.attendancePct then
			local present = p.bossKillsPresent or 0
			local eligible = p.eligibleBossKills or 0
			p.attendancePct = eligible > 0 and math.floor((present / eligible) * 100) or -1
		end
	end

	-- Stable sort (primary: selected column, secondary: name)
	table.sort(filtered, function(a, b)
		local valA = a[self.sortKey]
		local valB = b[self.sortKey]
		
		-- Nil-safe defaults
		if valA == nil then valA = 0 end
		if valB == nil then valB = 0 end
		
		-- Find column type
		local isNumeric = false
		for _, col in ipairs(columns) do
			if col.sortKey == self.sortKey then
				isNumeric = col.numeric
				break
			end
		end
		
		if not isNumeric then
			-- String sort
			valA = tostring(valA):lower()
			valB = tostring(valB):lower()
		end
		
		-- Primary sort
		if valA ~= valB then
			if self.sortAsc then
				return valA < valB
			else
				return valA > valB
			end
		end
		
		-- Secondary sort: name ascending (for stability)
		local nameA = (a.name or ""):lower()
		local nameB = (b.name or ""):lower()
		return nameA < nameB
	end)

	-- Track visible names for Select All checkbox
	local visibleNames = {}
	self._rowCheckboxes = {}
	self._decoratedRows = {}

	-- Render rows
	for i, data in ipairs(filtered) do
		local rowGroup = AceGUI:Create("SimpleGroup")
		rowGroup:SetFullWidth(true)
		rowGroup:SetLayout("Flow")
		container:AddChild(rowGroup)

		-- Nil-safe data extraction with defaults
		local name = data.name or "Unknown"
		local class = data.class or "Unknown"
		local classColor = Cerebro:GetClassColor(class)
		local pr = data.pr or 0
		local ep = data.ep or 0
		local gp = data.gp or 0
		local status = data.status or "Core"

		-- Render each column
		for _, col in ipairs(columns) do
			if col.id == "select" then
				-- Officer-only checkbox column (minimal wrapper to avoid extra row height)
				local chkLabel = AceGUI:Create("Label")
				chkLabel:SetText("")
				chkLabel:SetRelativeWidth(col.width)
				rowGroup:AddChild(chkLabel)

				local chk = CreateFrame("CheckButton", nil, chkLabel.frame, "UICheckButtonTemplate")
				chk:SetSize(16, 16)
				chk:SetPoint("CENTER", 0, 0)
				chk:SetHitRectInsets(0, 0, 0, 0)  -- Clamp hit rect to actual checkbox size
				chk:SetChecked(self.selected[name] or false)
				chk:SetScript("OnClick", function(cb)
					local checked = cb:GetChecked()
					if checked then
						self.selected[name] = true
					else
						self.selected[name] = nil
					end
					-- Update gold highlight
					local entry = self._rowCheckboxes[name]
					if entry and entry.bg then
						if checked then entry.bg:Show() else entry.bg:Hide() end
					end
					self:SaveSelection()
					self:UpdateSelectAllState()
				end)

				-- Cache for Select All / ClearSelection
				self._rowCheckboxes[name] = self._rowCheckboxes[name] or {}
				self._rowCheckboxes[name].check = chk
			else
				local label = AceGUI:Create("Label")
				local text = ""
				local color = nil
				
				if col.id == "rank" then
					text = tostring(i)
				elseif col.id == "name" then
					text = name  -- Use full Name-Realm for auditing
					color = classColor
				elseif col.id == "guildRank" then
					text = data.guildRankDisplay or "--"
					-- Officer view: color encodes addon compliance state (5 states)
					-- Raider view: original binary green=in-guild / gray=not-in-guild
					local isInGuild = (data.guildRank ~= nil)
					color = self:GetAddonStateColor(name, isInGuild)
				elseif col.id == "pr" then
					text = string.format("%.4f", pr)
					color = {r=1, g=0.8, b=0} -- Gold/Orange for PR
				elseif col.id == "ep" then
					text = string.format("%d", ep)
				elseif col.id == "gp" then
					text = string.format("%d", gp == 0 and 1 or gp) -- Display floor of 1
				elseif col.id == "status" then
					if status == "Pending" then
						text = "|cffff0000Pending|r"
					else
						text = status
					end
				elseif col.id == "attendance" then
					-- Calculate fair attendance using coreAdmittedAt
					local attendText, attendColor = self:CalculateAttendanceDisplay(data)
					text = attendText
					color = attendColor
				end
				
				
				-- Add padding to left-aligned columns only (rank is centered, doesn't need it)
				if col.id == "rank" then
					label:SetText(text or "")
				else
					label:SetText("  " .. (text or ""))  -- Add padding to match button header offset
				end
				label:SetRelativeWidth(col.width)
				-- Center-align rank column, left-align all others
				if col.id == "rank" then
					label:SetJustifyH("CENTER")
				else
					label:SetJustifyH("LEFT")
				end
				if color then
					label:SetColor(color.r, color.g, color.b)
				end
				rowGroup:AddChild(label)
			end
		end
		
		-- Gold selection highlight background
		local rowFrame = rowGroup.frame
		local fullName = Cerebro:GetFullPlayerName(name)
		if rowFrame then
			-- Selection highlight (gold, behind hover)
			if not rowFrame._selectBg then
				local selectBg = rowFrame:CreateTexture(nil, "BACKGROUND", nil, 0)
				selectBg:SetAllPoints()
				selectBg:SetColorTexture(1, 1, 0, 0.12)
				selectBg:Hide()
				rowFrame._selectBg = selectBg
			end
			if self.selected[name] then
				rowFrame._selectBg:Show()
			else
				rowFrame._selectBg:Hide()
			end
			-- Store bg ref in checkbox cache
			self._rowCheckboxes[name] = self._rowCheckboxes[name] or {}
			self._rowCheckboxes[name].bg = rowFrame._selectBg

			rowFrame:EnableMouse(true)
			
			-- Hover highlight (above selection bg)
			if not rowFrame._hoverBg then
				local hoverBg = rowFrame:CreateTexture(nil, "BACKGROUND", nil, 1)
				hoverBg:SetAllPoints()
				hoverBg:SetColorTexture(1, 1, 1, 0.08)
				hoverBg:Hide()
				rowFrame._hoverBg = hoverBg
			end
			
			rowFrame:SetScript("OnEnter", function(f)
				if f._hoverBg then f._hoverBg:Show() end
			end)
			rowFrame:SetScript("OnLeave", function(f)
				if f._hoverBg then f._hoverBg:Hide() end
			end)
			
			-- Right-click opens context menu
			rowFrame:SetScript("OnMouseUp", function(f, button)
				if button == "RightButton" then
					self:ShowPlayerContextMenu(fullName, f)
				end
			end)

			-- Track for cleanup on redraw
			tinsert(self._decoratedRows, rowFrame)
		end

		-- Track visible names for Select All
		tinsert(visibleNames, name)
	end
	
	-- No results message
	if #filtered == 0 then
		local label = AceGUI:Create("Label")
		label:SetText("No players found matching filter.")
		label:SetFullWidth(true)
		container:AddChild(label)
	else
		-- Add spacer at the bottom to prevent last row cutoff
		local spacer = AceGUI:Create("SimpleGroup")
		spacer:SetLayout("Fill")
		spacer:SetFullWidth(true)
		spacer:SetHeight(5)
		container:AddChild(spacer)
	end

	-- Store visible names and update Select All state
	self._visibleNames = visibleNames
	self:UpdateSelectAllState()
end

function StandingsFrame:DrawAwaitingApproval(container)
	local f = AceGUI:Create("Label")
	f:SetText("\n\n\n\n|cffff0000Awaiting Officer Approval...|r\n\nYou are currently in 'Pending' status.\nYour EP/GP data is visible in 'My Progress',\nbut you will not appear in the Standings until an officer\nadmits you or starts a raid session.")
	f:SetFontObject(GameFontNormalHuge)
	f:SetJustifyH("CENTER")
	f:SetFullWidth(true)
	container:AddChild(f)
end
