local addonName, RF = ...
local T

-- Sort state
local sortKey = "name"
local sortDir = RF.SORT_ASC
local searchText = ""
local onlineFilter = nil -- set from charDB on init
local classFilter = nil  -- nil = all
local rankFilter = nil   -- nil = all
local tagFilter = nil    -- nil = all
local currentViewKey = "default"

-- Display data after filter/sort
local displayData = {}
local savedScrollOffset = 0

-------------------------------------------------------------------------------
-- Column View Helpers
-------------------------------------------------------------------------------
local function GetCurrentView()
    for _, view in ipairs(RF.COLUMN_VIEWS) do
        if view.key == currentViewKey then
            return view
        end
    end
    return RF.COLUMN_VIEWS[1]
end

local function GetOrderedColumns(rawCols, viewKey)
    local savedOrder = RF.charDB and RF.charDB.columnOrder
    if not savedOrder or not savedOrder[viewKey] then
        return rawCols
    end

    local lookup = {}
    for _, col in ipairs(rawCols) do
        lookup[col.key] = col
    end

    local ordered = {}
    local used = {}
    for _, key in ipairs(savedOrder[viewKey]) do
        if lookup[key] then
            tinsert(ordered, lookup[key])
            used[key] = true
        end
    end
    -- Append any new columns not in saved order
    for _, col in ipairs(rawCols) do
        if not used[col.key] then
            tinsert(ordered, col)
        end
    end
    return ordered
end

local function SaveColumnOrder(viewKey, keys)
    if not RF.charDB then return end
    if not RF.charDB.columnOrder then
        RF.charDB.columnOrder = {}
    end
    RF.charDB.columnOrder[viewKey] = keys
end

local function SaveColumnWidth(viewKey, colKey, width)
    if not RF.charDB then return end
    if not RF.charDB.columnWidths then
        RF.charDB.columnWidths = {}
    end
    if not RF.charDB.columnWidths[viewKey] then
        RF.charDB.columnWidths[viewKey] = {}
    end
    RF.charDB.columnWidths[viewKey][colKey] = width
end

-------------------------------------------------------------------------------
-- Per-view column visibility (mirrors columnWidths/columnOrder, which are
-- already keyed by view). Previously visibleColumns was one flat table
-- shared across all views, so hiding a column on one view (e.g.
-- "Professions") silently hid it on every other view too. Also migrates
-- any pre-existing flat visibleColumns table into the "default" view bucket
-- so existing users' customizations aren't lost.
-------------------------------------------------------------------------------
function RF:GetViewVisibility(viewKey)
    if not RF.charDB then return RF.DEFAULT_VISIBLE_COLUMNS end
    local vc = RF.charDB.visibleColumns
    if not vc then
        vc = {}
        RF.charDB.visibleColumns = vc
    else
        local looksFlat = false
        for _, v in pairs(vc) do
            if type(v) == "boolean" then looksFlat = true; break end
        end
        if looksFlat then
            RF.charDB.visibleColumns = { default = vc }
            vc = RF.charDB.visibleColumns
        end
    end
    if not vc[viewKey] then
        vc[viewKey] = {}
    end
    return vc[viewKey]
end

local function GetCurrentColumns()
    local view = GetCurrentView()
    local rawCols = GetOrderedColumns(view.columns, view.key)
    local vis = RF:GetViewVisibility(view.key)

    local filtered = {}
    local x = 16  -- start after status dot
    local gap = 4

    local savedWidths = RF.charDB and RF.charDB.columnWidths and RF.charDB.columnWidths[view.key]

    for _, col in ipairs(rawCols) do
        -- Hide "Last Online" when filtering to online-only members
        local hidden = vis[col.key] == false
        if not hidden and col.key == "lastOnline" and onlineFilter == RF.ONLINE_FILTER.ONLINE then
            hidden = true
        end
        if not hidden then
            local w = (savedWidths and savedWidths[col.key]) or col.w
            tinsert(filtered, {
                key = col.key,
                label = col.label,
                w = w,
                x = x,
            })
            x = x + w + gap
        end
    end

    return filtered
end

-------------------------------------------------------------------------------
-- Filter & Sort
-------------------------------------------------------------------------------
local function MatchesSearch(entry, text)
    if not text or text == "" then return true end
    text = text:lower()
    if (entry.name and entry.name:lower():find(text, 1, true))
        or (entry.note and entry.note:lower():find(text, 1, true))
        or (entry.zone and entry.zone:lower():find(text, 1, true))
        or (entry.officerNote and entry.officerNote:lower():find(text, 1, true))
        or (entry.rankName and entry.rankName:lower():find(text, 1, true))
        or (entry.classDisplayName and entry.classDisplayName:lower():find(text, 1, true))
        or (entry.extendedNote and entry.extendedNote ~= "" and entry.extendedNote:lower():find(text, 1, true))
        or (entry.profession1Name and entry.profession1Name ~= "" and entry.profession1Name:lower():find(text, 1, true))
        or (entry.profession2Name and entry.profession2Name ~= "" and entry.profession2Name:lower():find(text, 1, true)) then
        return true
    end
    -- Search tags
    local guildDB = RF:GetGuildDB()
    if guildDB and guildDB.tags and guildDB.tags[entry.name] then
        for _, tag in ipairs(guildDB.tags[entry.name]) do
            if tag.label:lower():find(text, 1, true) then
                return true
            end
        end
    end
    return false
end

local function PassesFilter(entry)
    if onlineFilter == RF.ONLINE_FILTER.ONLINE and not entry.isOnline then
        return false
    elseif onlineFilter == RF.ONLINE_FILTER.OFFLINE and entry.isOnline then
        return false
    end
    if classFilter and entry.classFile ~= classFilter then
        return false
    end
    if rankFilter and entry.rankIndex ~= rankFilter then
        return false
    end
    if tagFilter then
        local guildDB = RF:GetGuildDB()
        local tags = guildDB and guildDB.tags and guildDB.tags[entry.name]
        if not tags then return false end
        local found = false
        for _, t in ipairs(tags) do
            if t.label == tagFilter then found = true; break end
        end
        if not found then return false end
    end
    return true
end

local SORT_FUNCS = {
    name = function(a, b)
        return a.name:lower() < b.name:lower()
    end,
    rank = function(a, b)
        if a.rankIndex == b.rankIndex then
            return a.name:lower() < b.name:lower()
        end
        return a.rankIndex < b.rankIndex
    end,
    level = function(a, b)
        if a.level == b.level then
            return a.name:lower() < b.name:lower()
        end
        return a.level < b.level
    end,
    class = function(a, b)
        if a.classFile == b.classFile then
            return a.name:lower() < b.name:lower()
        end
        return (a.classDisplayName or "") < (b.classDisplayName or "")
    end,
    zone = function(a, b)
        if a.zone == b.zone then
            return a.name:lower() < b.name:lower()
        end
        return a.zone < b.zone
    end,
    note = function(a, b)
        return (a.note or "") < (b.note or "")
    end,
    officerNote = function(a, b)
        return (a.officerNote or "") < (b.officerNote or "")
    end,
    lastOnline = function(a, b)
        if a.lastOnlineSeconds == b.lastOnlineSeconds then
            return a.name:lower() < b.name:lower()
        end
        return a.lastOnlineSeconds < b.lastOnlineSeconds
    end,
    status = function(a, b)
        local order = { ONLINE = 1, AWAY = 2, DND = 3, MOBILE = 4, OFFLINE = 5 }
        local oa = order[a.status] or 5
        local ob = order[b.status] or 5
        if oa == ob then
            return a.name:lower() < b.name:lower()
        end
        return oa < ob
    end,
    joinDate = function(a, b)
        local aj = a.joinDateRaw or 0
        local bj = b.joinDateRaw or 0
        if aj == bj then return a.name:lower() < b.name:lower() end
        return aj < bj
    end,
    mplus = function(a, b)
        local am = a.mplusRating or 0
        local bm = b.mplusRating or 0
        if am == bm then return a.name:lower() < b.name:lower() end
        return am < bm
    end,
    profession1 = function(a, b)
        local ap = (a.profession1Name or ""):lower()
        local bp = (b.profession1Name or ""):lower()
        if ap == bp then return a.name:lower() < b.name:lower() end
        return ap < bp
    end,
    profession2 = function(a, b)
        local ap = (a.profession2Name or ""):lower()
        local bp = (b.profession2Name or ""):lower()
        if ap == bp then return a.name:lower() < b.name:lower() end
        return ap < bp
    end,
    altMain = function(a, b)
        local aa = (a.altMain or ""):lower()
        local ba = (b.altMain or ""):lower()
        if aa == ba then return a.name:lower() < b.name:lower() end
        return aa < ba
    end,
    rosterNote = function(a, b)
        local an = (a.extendedNote or ""):lower()
        local bn = (b.extendedNote or ""):lower()
        if an == bn then return a.name:lower() < b.name:lower() end
        return an < bn
    end,
    teams = function(a, b)
        local function getTeamNames(name)
            local teams = RF:GetMemberTeams(name)
            if #teams == 0 then return "" end
            return teams[1].teamName:lower()
        end
        local at = getTeamNames(a.name)
        local bt = getTeamNames(b.name)
        if at == bt then return a.name:lower() < b.name:lower() end
        return at < bt
    end,
    roles = function(a, b)
        local guildDB = RF:GetGuildDB()
        local function getFirstTag(name)
            if guildDB and guildDB.tags and guildDB.tags[name] and #guildDB.tags[name] > 0 then
                return guildDB.tags[name][1].label:lower()
            end
            return ""
        end
        local at = getFirstTag(a.name)
        local bt = getFirstTag(b.name)
        if at == bt then return a.name:lower() < b.name:lower() end
        return at < bt
    end,
}

local function FilterAndSort()
    displayData = {}
    for _, entry in ipairs(RF.rosterData) do
        if MatchesSearch(entry, searchText) and PassesFilter(entry) then
            tinsert(displayData, entry)
        end
    end

    local sortFunc = SORT_FUNCS[sortKey]
    if sortFunc then
        table.sort(displayData, function(a, b)
            -- Favorites always first
            if a.isFavorite ~= b.isFavorite then
                return a.isFavorite == true
            end
            if sortDir == RF.SORT_DESC then
                return sortFunc(b, a)
            end
            return sortFunc(a, b)
        end)
    end

    return displayData
end

-------------------------------------------------------------------------------
-- Field Renderers: Each key maps to a function(fontString, data, T)
-------------------------------------------------------------------------------
local FIELD_RENDERERS = {
    name = function(fs, data)
        local prefix = data.isFavorite and "|TInterface\\COMMON\\ReputationStar:12:12:0:0:32:32:0:16:0:16|t " or ""
        fs:SetText(prefix .. RF:GetClassColoredName(data.name, data.classFile))
        fs:SetJustifyH("LEFT")
    end,
    rank = function(fs, data)
        fs:SetText(data.rankName)
        local rankColor = RF.RANK_COLORS[data.rankIndex]
        if rankColor then
            fs:SetTextColor(rankColor.r, rankColor.g, rankColor.b)
        else
            fs:SetTextColor(unpack(RF.Theme.textSecondary))
        end
        fs:SetJustifyH("LEFT")
    end,
    level = function(fs, data)
        fs:SetText(data.level)
        fs:SetTextColor(unpack(RF.Theme.textSecondary))
        fs:SetJustifyH("CENTER")
    end,
    class = function(fs, data)
        local cc = RF:GetClassColor(data.classFile)
        fs:SetText(data.classDisplayName or "")
        fs:SetTextColor(cc.r, cc.g, cc.b)
        fs:SetJustifyH("LEFT")
    end,
    zone = function(fs, data)
        fs:SetText(data.zone)
        fs:SetTextColor(unpack(RF.Theme.textSecondary))
        fs:SetJustifyH("LEFT")
    end,
    note = function(fs, data)
        local text = data.note or ""
        if RF:ExtractURL(text) then
            fs:SetText("|cFF5BA8FF" .. text .. "|r")
        else
            fs:SetText(text)
            fs:SetTextColor(unpack(RF.Theme.textSecondary))
        end
        fs:SetJustifyH("LEFT")
    end,
    officerNote = function(fs, data)
        local text = data.officerNote or ""
        if RF:ExtractURL(text) then
            fs:SetText("|cFF5BA8FF" .. text .. "|r")
        else
            fs:SetText(text)
            fs:SetTextColor(unpack(RF.Theme.textSecondary))
        end
        fs:SetJustifyH("LEFT")
    end,
    lastOnline = function(fs, data)
        fs:SetText(data.lastOnlineText or "")
        if data.isOnline then
            fs:SetTextColor(unpack(RF.Theme.online))
        else
            fs:SetTextColor(unpack(RF.Theme.textMuted))
        end
        fs:SetJustifyH("RIGHT")
    end,
    mplus = function(fs, data)
        local rating = data.mplusRating or 0
        if rating > 0 then
            fs:SetText(tostring(rating))
            if data.mplusRatingColor then
                local mc = data.mplusRatingColor
                fs:SetTextColor(mc.r, mc.g, mc.b)
            else
                fs:SetTextColor(unpack(RF.Theme.textSecondary))
            end
        else
            fs:SetText("-")
            fs:SetTextColor(unpack(RF.Theme.textMuted))
        end
        fs:SetJustifyH("CENTER")
    end,
    status = function(fs, data)
        local statusInfo = RF.STATUS[data.status]
        if statusInfo then
            fs:SetText(statusInfo.text)
            local c = statusInfo.color
            fs:SetTextColor(c.r, c.g, c.b)
        else
            fs:SetText("")
        end
        fs:SetJustifyH("CENTER")
    end,
    profession1 = function(fs, data)
        local name = data.profession1Name or ""
        if name ~= "" then
            fs:SetText(name)
            fs:SetTextColor(unpack(RF.Theme.textSecondary))
        else
            fs:SetText("-")
            fs:SetTextColor(unpack(RF.Theme.textMuted))
        end
        fs:SetJustifyH("LEFT")
    end,
    profession2 = function(fs, data)
        local name = data.profession2Name or ""
        if name ~= "" then
            fs:SetText(name)
            fs:SetTextColor(unpack(RF.Theme.textSecondary))
        else
            fs:SetText("-")
            fs:SetTextColor(unpack(RF.Theme.textMuted))
        end
        fs:SetJustifyH("LEFT")
    end,
    joinDate = function(fs, data)
        local text = data.joinDate or ""
        fs:SetText(text)
        fs:SetTextColor(unpack(RF.Theme.textMuted))
        fs:SetJustifyH("RIGHT")
    end,
    altMain = function(fs, data)
        local text = data.altMain or ""
        if text ~= "" then
            fs:SetText(text)
            fs:SetTextColor(0.4, 0.7, 1.0)
        else
            fs:SetText("")
        end
        fs:SetJustifyH("LEFT")
    end,
    rosterNote = function(fs, data)
        local text = data.extendedNote or ""
        if text ~= "" then
            -- Single-line preview, truncate
            local preview = text:gsub("\n", " ")
            if #preview > 30 then preview = preview:sub(1, 27) .. "..." end
            fs:SetText(preview)
            fs:SetTextColor(0.6, 0.8, 0.6)
        else
            fs:SetText("-")
            fs:SetTextColor(unpack(RF.Theme.textMuted))
        end
        fs:SetJustifyH("LEFT")
    end,
    teams = function(fs, data)
        local teams = RF:GetMemberTeams(data.name)
        if #teams > 0 then
            local names = {}
            for _, t in ipairs(teams) do
                tinsert(names, t.teamName)
            end
            local text = table.concat(names, ", ")
            if #text > 30 then text = text:sub(1, 27) .. "..." end
            fs:SetText(text)
            fs:SetTextColor(unpack(RF.Theme.accent))
        else
            fs:SetText("-")
            fs:SetTextColor(unpack(RF.Theme.textMuted))
        end
        fs:SetJustifyH("LEFT")
    end,
    roles = function(fs, data)
        fs:SetText("")
        local row = fs:GetParent()
        if not row._tagPills then row._tagPills = {} end
        for _, pill in ipairs(row._tagPills) do pill:Hide() end

        local guildDB = RF:GetGuildDB()
        local tags = guildDB and guildDB.tags and guildDB.tags[data.name]
        if not tags or #tags == 0 then
            fs:SetText("-")
            fs:SetTextColor(unpack(RF.Theme.textMuted))
            fs:SetJustifyH("LEFT")
            return
        end

        local anchor = fs
        for ti = 1, math.min(#tags, 3) do
            local tag = tags[ti]
            local pill = row._tagPills[ti]
            if not pill then
                pill = CreateFrame("Frame", nil, row, "BackdropTemplate")
                pill:SetHeight(14)
                pill:SetBackdrop({
                    bgFile = "Interface\\Buttons\\WHITE8x8",
                    edgeFile = "Interface\\Buttons\\WHITE8x8",
                    edgeSize = 1,
                })
                pill.label = pill:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                pill.label:SetFont(pill.label:GetFont(), 9, "")
                pill.label:SetPoint("CENTER", 0, 0)
                row._tagPills[ti] = pill
            end
            pill.label:SetText(tag.label)
            local textWidth = pill.label:GetStringWidth()
            pill:SetWidth(math.max(textWidth + 8, 20))
            local r = tonumber(tag.color:sub(1, 2), 16) / 255
            local g = tonumber(tag.color:sub(3, 4), 16) / 255
            local b = tonumber(tag.color:sub(5, 6), 16) / 255
            pill:SetBackdropColor(r, g, b, 0.3)
            pill:SetBackdropBorderColor(r, g, b, 0.6)
            pill.label:SetTextColor(r, g, b)
            pill:ClearAllPoints()
            if ti == 1 then
                pill:SetPoint("LEFT", fs, "LEFT", 0, 0)
            else
                pill:SetPoint("LEFT", row._tagPills[ti - 1], "RIGHT", 2, 0)
            end
            pill:Show()
        end
    end,
}

-------------------------------------------------------------------------------
-- Row Factory (data-driven columns)
-------------------------------------------------------------------------------
local function GetRowHeight()
    local fontSize = RF.charDB and RF.charDB.rosterFontSize or 10
    return fontSize + 12
end
local MAX_FIELDS = 15  -- pre-allocate this many FontStrings per row

local function CreateRosterRow(parent, index)
    local T = RF.Theme
    local row = CreateFrame("Button", nil, parent)
    row:SetHeight(GetRowHeight())
    row:RegisterForClicks("LeftButtonUp", "RightButtonUp")

    row.bg = row:CreateTexture(nil, "BACKGROUND")
    row.bg:SetAllPoints()

    -- Status dot (always present, leftmost)
    row.statusDot = row:CreateTexture(nil, "OVERLAY")
    row.statusDot:SetSize(8, 8)
    row.statusDot:SetPoint("LEFT", 4, 0)
    row.statusDot:SetTexture("Interface\\Buttons\\WHITE8x8")

    -- Pre-allocate generic FontStrings
    row.fields = {}
    for i = 1, MAX_FIELDS do
        local fs = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        local rosterSize = RF.charDB and RF.charDB.rosterFontSize or RF.FONT_SIZES.small
        fs:SetFont(fs:GetFont(), rosterSize, "")
        fs:SetWordWrap(false)
        fs:SetNonSpaceWrap(false)
        fs:Hide()
        row.fields[i] = fs
    end

    return row
end

local function LayoutRowFields(row)
    local columns = GetCurrentColumns()
    -- Position each field according to current view columns
    for i = 1, MAX_FIELDS do
        local fs = row.fields[i]
        local col = columns[i]
        if col then
            fs:ClearAllPoints()
            fs:SetPoint("LEFT", col.x, 0)
            fs:SetWidth(col.w)
            fs:Show()
        else
            fs:Hide()
        end
    end
end

local function UpdateRosterRow(row, data, index, isSelected)
    local T = RF.Theme

    -- Status dot
    local statusInfo = RF.STATUS[data.status]
    if statusInfo then
        local c = statusInfo.color
        row.statusDot:SetVertexColor(c.r, c.g, c.b, 1)
    end

    -- Layout fields for current view
    LayoutRowFields(row)

    -- Render each column field using FIELD_RENDERERS
    local columns = GetCurrentColumns()
    for i, col in ipairs(columns) do
        local fs = row.fields[i]
        if fs then
            local renderer = FIELD_RENDERERS[col.key]
            if renderer then
                renderer(fs, data)
            else
                fs:SetText("")
            end
        end
    end

end

-------------------------------------------------------------------------------
-- Context Menu
-------------------------------------------------------------------------------
local function ShowRosterContextMenu(row, data)
    if not data then return end
    local name = data.name
    local fullName = data.fullName

    local items = {
        { text = RF:GetClassColoredName(name, data.classFile), func = function() end, disabled = true },
        { separator = true },
        { text = data.isFavorite and "Remove Favorite" or "Add Favorite", func = function()
            local newState = not data.isFavorite
            RF:SetFavorite(name, newState)
            -- Update in-memory roster entry so FilterAndSort sees the change
            data.isFavorite = newState or nil
            if RF.rosterByName and RF.rosterByName[name] then
                RF.rosterByName[name].isFavorite = newState or nil
            end
            RF:RefreshRosterTab()
        end },
        { text = "Whisper", func = function()
            ChatFrame_OpenChat("/w " .. name .. " ")
        end },
        { text = "Invite to Group", func = function()
            C_PartyInfo.InviteUnit(fullName or name)
        end },
        { text = "Copy Name", func = function()
            RF:ShowCopyPopup(name)
        end },
        { text = "Inspect", func = function()
            if data.isOnline then
                NotifyInspect(name)
            else
                RF:Print(name .. " must be online and nearby to inspect.")
            end
        end },
        { separator = true },
    }

    local canEditPublic = (C_GuildInfo and C_GuildInfo.CanEditPublicNote and C_GuildInfo.CanEditPublicNote())
        or (CanEditPublicNote and CanEditPublicNote())
    local canEditOfficer = (C_GuildInfo and C_GuildInfo.CanViewOfficerNote and C_GuildInfo.CanViewOfficerNote())
        or (CanEditOfficerNote and CanEditOfficerNote())
    local canPromote = (CanGuildPromote and CanGuildPromote()) or IsGuildLeader and IsGuildLeader()
    local canDemote = (CanGuildDemote and CanGuildDemote()) or IsGuildLeader and IsGuildLeader()
    local canRemove = (CanGuildRemove and CanGuildRemove()) or IsGuildLeader and IsGuildLeader()

    if canEditPublic then
        tinsert(items, { text = "Set Note...", func = function()
            RF:ShowNoteEditor(data, "public")
        end })
    end
    if canEditOfficer then
        tinsert(items, { text = "Set Officer Note...", func = function()
            RF:ShowNoteEditor(data, "officer")
        end })
    end

    tinsert(items, { text = "Set RosterNote...", func = function()
        RF:ShowRosterNoteEditor(data)
    end })

    if canPromote then
        tinsert(items, { text = "Promote", func = function()
            RF:ShowMacroCopy("/gpromote " .. fullName)
        end })
    end
    if canDemote then
        tinsert(items, { text = "Demote", func = function()
            RF:ShowMacroCopy("/gdemote " .. fullName)
        end })
    end
    if canRemove then
        tinsert(items, {
            text = "Remove from Guild",
            color = { r = 0.9, g = 0.25, b = 0.25 },
            func = function()
                RF:ShowConfirmDialog("Remove Member",
                    "Generate removal macro for " .. name .. "?",
                    function() RF:ShowMacroCopy("/gkick " .. fullName) end)
            end
        })
    end

    tinsert(items, { separator = true })

    tinsert(items, { text = "Link as Alt...", func = function()
        RF:ShowAltLinkDialog(name)
    end })
    tinsert(items, { text = "Set as Main", func = function()
        if RF.SetMain then RF:SetMain(name) end
        RF:RefreshRosterTab()
    end })

    -- Role tags
    tinsert(items, { separator = true })
    tinsert(items, { text = "Role", isTitle = true })
    local guildDB = RF:GetGuildDB()
    local memberTags = guildDB and guildDB.tags and guildDB.tags[name] or {}
    local tagPresets = { "Tank", "Healer", "DPS", "Trial", "Bench", "Officer Alt", "Social", "PvP", "Raider" }
    for _, tagName in ipairs(tagPresets) do
        local hasTag = false
        for i, t in ipairs(memberTags) do
            if t.label == tagName then
                hasTag = true
                break
            end
        end
        local prefix = hasTag and "|cFF22C55E+ |r" or "  "
        tinsert(items, {
            text = prefix .. tagName,
            func = function()
                if hasTag then
                    RF:RemoveTag(name, tagName)
                else
                    RF:AddTag(name, tagName)
                end
                RF:RefreshRosterTab()
            end,
        })
    end

    tinsert(items, { separator = true })
    tinsert(items, {
        text = "Add to Ban List",
        color = { r = 0.9, g = 0.25, b = 0.25 },
        func = function()
            RF:ShowConfirmDialog("Ban Member",
                "Add " .. name .. " to the ban list?",
                function() if RF.AddBan then RF:AddBan(name) end end)
        end
    })

    RF:ShowContextMenu(row, items)
end

-------------------------------------------------------------------------------
-- Copy Popup
-------------------------------------------------------------------------------
function RF:ShowCopyPopup(text)
    local multiline = text:find("\n") ~= nil

    if multiline then
        if not RF.copyFrameMulti then
            local f = RF:CreateStyledFrame("RosterForgeCopyPopupMulti", UIParent, 500, 350, {
                title = "Copy Text",
                closable = true,
                strata = "DIALOG",
            })
            f:SetPoint("CENTER")
            f:Hide()

            local sf = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
            sf:SetPoint("TOPLEFT", f.titleBar, "BOTTOMLEFT", 8, -8)
            sf:SetPoint("BOTTOMRIGHT", -28, 8)

            local eb = CreateFrame("EditBox", nil, sf)
            eb:SetMultiLine(true)
            eb:SetFontObject("ChatFontNormal")
            eb:SetAutoFocus(false)
            eb:SetWidth(sf:GetWidth() or 460)
            eb:SetScript("OnEscapePressed", function() f:Hide() end)
            sf:SetScrollChild(eb)
            f.scrollFrame = sf
            f.editBox = eb

            sf:SetScript("OnSizeChanged", function(self, w)
                eb:SetWidth(w)
            end)

            RF.copyFrameMulti = f
        end

        RF.copyFrameMulti.editBox:SetText(text)
        RF.copyFrameMulti:Show()
        RF.copyFrameMulti.editBox:SetFocus()
        RF.copyFrameMulti.editBox:HighlightText()
    else
        if not RF.copyFrame then
            local f = RF:CreateStyledFrame("RosterForgeCopyPopup", UIParent, 300, 80, {
                title = "Copy Text",
                closable = true,
                strata = "DIALOG",
            })
            f:SetPoint("CENTER")
            f:Hide()

            local eb = CreateFrame("EditBox", nil, f, "BackdropTemplate")
            eb:SetPoint("TOPLEFT", f.titleBar, "BOTTOMLEFT", 8, -8)
            eb:SetPoint("BOTTOMRIGHT", -8, 8)
            eb:SetFontObject("ChatFontNormal")
            eb:SetAutoFocus(false)
            eb:SetScript("OnEscapePressed", function() f:Hide() end)
            f.editBox = eb

            RF.copyFrame = f
        end

        RF.copyFrame.editBox:SetText(text)
        RF.copyFrame:Show()
        RF.copyFrame.editBox:SetFocus()
        RF.copyFrame.editBox:HighlightText()
    end
end

-------------------------------------------------------------------------------
-- Macro Copy Popup
-------------------------------------------------------------------------------
function RF:ShowMacroCopy(macroText, message)
    RF:ShowCopyPopup(macroText)
    RF:Print(message or ("Copy this macro and run it: " .. macroText))
end

-------------------------------------------------------------------------------
-- Note Editor
-------------------------------------------------------------------------------
-- C_GuildInfo.SetNote is fully protected in WoW 12.0 — no addon or /run can
-- call it.  Redirect the user to Blizzard's built-in guild panel instead.
function RF:ShowNoteEditor(data, noteType)
    local noteLabel = noteType == "officer" and "officer note" or "public note"
    RF:Print(format("To edit %s's %s, right-click them in the Guild Panel (J key).",
        data.name, noteLabel))
    -- Try to open the Blizzard guild / communities frame
    if CommunitiesFrame then
        if not CommunitiesFrame:IsShown() then
            ToggleCommunitiesFrame()
        end
    elseif ToggleCommunitiesFrame then
        ToggleCommunitiesFrame()
    elseif ToggleGuildFrame then
        ToggleGuildFrame()
    end
end

-------------------------------------------------------------------------------
-- RosterNote Editor Dialog
-------------------------------------------------------------------------------
function RF:ShowRosterNoteEditor(data)
    local name = type(data) == "table" and data.name or data
    if not name then return end

    if not RF.rosterNoteFrame then
        local T = RF.Theme
        local fontPath = ChatFontNormal:GetFont()
        local f = RF:CreateStyledFrame("RosterForgeRosterNote", UIParent, 380, 220, {
            title = "Set RosterNote",
            closable = true,
            strata = "DIALOG",
        })
        f:SetPoint("CENTER")
        f:Hide()

        local nameLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        nameLabel:SetFont(fontPath, RF.FONT_SIZES.normal, "")
        nameLabel:SetPoint("TOPLEFT", f.titleBar, "BOTTOMLEFT", 12, -8)
        nameLabel:SetTextColor(unpack(T.textPrimary))
        f.nameLabel = nameLabel

        local sf = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
        sf:SetPoint("TOPLEFT", nameLabel, "BOTTOMLEFT", 0, -6)
        sf:SetPoint("BOTTOMRIGHT", -28, 40)

        local eb = CreateFrame("EditBox", nil, sf)
        eb:SetWidth(sf:GetWidth() or 330)
        eb:SetFontObject("ChatFontNormal")
        eb:SetFont(fontPath, RF.FONT_SIZES.normal, "")
        eb:SetMultiLine(true)
        eb:SetAutoFocus(false)
        eb:SetTextColor(unpack(T.textPrimary))
        eb:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
        sf:SetScrollChild(eb)
        sf:SetScript("OnSizeChanged", function(self, w) eb:SetWidth(w) end)
        f.editBox = eb

        local saveBtn = RF:CreateButton(f, "Save", 80, 26, "accent")
        saveBtn:SetPoint("BOTTOMRIGHT", -12, 8)
        saveBtn:SetScript("OnClick", function()
            if f.targetName then
                RF:SetExtendedNote(f.targetName, eb:GetText())
                RF:Print("RosterNote saved for " .. f.targetName)
                f:Hide()
                RF:RefreshRosterTab()
            end
        end)

        local deleteBtn = RF:CreateButton(f, "Delete", 80, 26, "danger")
        deleteBtn:SetPoint("RIGHT", saveBtn, "LEFT", -6, 0)
        deleteBtn:SetScript("OnClick", function()
            if f.targetName then
                RF:ShowConfirmDialog("Delete RosterNote",
                    "Delete RosterNote for " .. f.targetName .. "?",
                    function()
                        RF:DeleteExtendedNote(f.targetName)
                        f:Hide()
                        RF:RefreshRosterTab()
                    end)
            end
        end)

        RF.rosterNoteFrame = f
    end

    local f = RF.rosterNoteFrame
    f.targetName = name
    local member = RF:GetMemberByName(name)
    if member then
        f.nameLabel:SetText(RF:GetClassColoredName(name, member.classFile))
    else
        f.nameLabel:SetText(name)
    end
    f.editBox:SetText(RF:GetExtendedNote(name) or "")
    f:Show()
    f.editBox:SetFocus()
end

-------------------------------------------------------------------------------
-- Tag Helpers (used by context menu and SyncEngine)
-------------------------------------------------------------------------------
function RF:AddTag(name, label, color)
    local guildDB = RF:GetGuildDB()
    if not guildDB then return false end
    if not guildDB.tags then guildDB.tags = {} end
    if not guildDB.tags[name] then guildDB.tags[name] = {} end

    -- Check if already has this tag
    for _, t in ipairs(guildDB.tags[name]) do
        if t.label == label then return false end
    end

    if #guildDB.tags[name] >= 3 then
        RF:Print("Maximum 3 tags per member.")
        return false
    end

    if not color then
        color = RF.TAG_COLORS[1].hex
        for _, tc in ipairs(RF.TAG_COLORS) do
            if tc.label:lower() == "blue" then color = tc.hex; break end
        end
    end

    tinsert(guildDB.tags[name], { label = label, color = color })

    -- Update timestamp for sync
    if not guildDB.tagTimestamps then guildDB.tagTimestamps = {} end
    guildDB.tagTimestamps[name .. ":" .. label] = time()

    return true
end

function RF:RemoveTag(name, label)
    local guildDB = RF:GetGuildDB()
    if not guildDB then return false end
    if not guildDB.tags or not guildDB.tags[name] then return false end

    for i = #guildDB.tags[name], 1, -1 do
        if guildDB.tags[name][i].label == label then
            tremove(guildDB.tags[name], i)
            -- Update timestamp for sync
            if not guildDB.tagTimestamps then guildDB.tagTimestamps = {} end
            guildDB.tagTimestamps[name .. ":" .. label] = time()
            return true
        end
    end
    return false
end

-------------------------------------------------------------------------------
-- Alt Link Dialog
-------------------------------------------------------------------------------
function RF:ShowAltLinkDialog(altName)
    if not RF.altLinkFrame then
        local f = RF:CreateStyledFrame("RosterForgeAltLink", UIParent, 300, 100, {
            title = "Link Alt to Main",
            closable = true,
            strata = "DIALOG",
        })
        f:SetPoint("CENTER")
        f:Hide()

        local label = RF:CreateLabel(f, "Enter main character name:", RF.FONT_SIZES.small, "textSecondary")
        label:SetPoint("TOPLEFT", f.titleBar, "BOTTOMLEFT", 8, -6)

        local eb = CreateFrame("EditBox", nil, f, "BackdropTemplate")
        eb:SetPoint("TOPLEFT", label, "BOTTOMLEFT", 0, -4)
        eb:SetPoint("RIGHT", -68, 0)
        eb:SetHeight(24)
        eb:SetFontObject("ChatFontNormal")
        eb:SetAutoFocus(false)
        eb:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
        })
        eb:SetBackdropColor(0.1, 0.1, 0.12, 1)
        eb:SetBackdropBorderColor(0.25, 0.25, 0.28, 1)
        f.editBox = eb

        local linkBtn = RF:CreateButton(f, "Link", 55, 24, "accent")
        linkBtn:SetPoint("LEFT", eb, "RIGHT", 4, 0)
        f.linkBtn = linkBtn

        eb:SetScript("OnEscapePressed", function() f:Hide() end)
        eb:SetScript("OnEnterPressed", function() f.linkBtn:Click() end)

        RF.altLinkFrame = f
    end

    local f = RF.altLinkFrame
    f.editBox:SetText("")
    f.linkBtn:SetScript("OnClick", function()
        local mainName = f.editBox:GetText():trim()
        if mainName ~= "" and RF.LinkAlt then
            RF:LinkAlt(altName, mainName)
            RF:Print(altName .. " linked as alt of " .. mainName)
            RF:RefreshRosterTab()
        end
        f:Hide()
    end)

    f:Show()
    f.editBox:SetFocus()
end

-------------------------------------------------------------------------------
-- Column Header Factory (data-driven)
-------------------------------------------------------------------------------
local function CreateColumnHeaders(parent, columns)
    local T = RF.Theme
    local headerHeight = GetRowHeight() + 2
    local headerFrame = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    headerFrame:SetHeight(headerHeight)
    headerFrame:SetPoint("TOPLEFT", 0, 0)
    headerFrame:SetPoint("TOPRIGHT", 0, 0)
    headerFrame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
    })
    headerFrame:SetBackdropColor(unpack(T.bgSecondary))

    headerFrame.buttons = {}
    headerFrame.dragging = false

    for _, col in ipairs(columns) do
        local btn = CreateFrame("Button", nil, headerFrame)
        btn:SetPoint("LEFT", col.x, 0)
        btn:SetSize(col.w, headerHeight)
        btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
        btn:RegisterForDrag("LeftButton")

        local rosterSize = RF.charDB and RF.charDB.rosterFontSize or RF.FONT_SIZES.small
        local text = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        text:SetFont(text:GetFont(), rosterSize, "")
        text:SetPoint("LEFT", 0, 0)
        text:SetTextColor(unpack(T.textSecondary))
        text:SetText(col.label)
        btn.label = text

        local arrow = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        arrow:SetFont(arrow:GetFont(), math.max(rosterSize - 2, 7), "")
        arrow:SetPoint("LEFT", text, "RIGHT", 2, 0)
        arrow:SetTextColor(unpack(T.accent))
        arrow:Hide()
        btn.arrow = arrow

        -- Resize handle on right edge
        local resizer = CreateFrame("Frame", nil, btn)
        resizer:SetSize(6, headerHeight)
        resizer:SetPoint("RIGHT", btn, "RIGHT", 2, 0)
        resizer:EnableMouse(true)

        local resizeLine = resizer:CreateTexture(nil, "OVERLAY")
        resizeLine:SetSize(2, headerHeight)
        resizeLine:SetPoint("CENTER")
        resizeLine:SetColorTexture(T.accent[1], T.accent[2], T.accent[3], 0)

        resizer:SetScript("OnEnter", function(self)
            resizeLine:SetColorTexture(T.accent[1], T.accent[2], T.accent[3], 0.8)
        end)
        resizer:SetScript("OnLeave", function(self)
            if not self.isResizing then
                resizeLine:SetColorTexture(T.accent[1], T.accent[2], T.accent[3], 0)
            end
        end)
        resizer:SetScript("OnMouseDown", function(self, mouseBtn)
            if mouseBtn == "LeftButton" then
                self.isResizing = true
                self.startX = GetCursorPosition() / UIParent:GetEffectiveScale()
                self.startW = col.w
                resizeLine:SetColorTexture(T.accent[1], T.accent[2], T.accent[3], 1)
                self:SetScript("OnUpdate", function(handle)
                    local cx = GetCursorPosition() / UIParent:GetEffectiveScale()
                    local delta = cx - handle.startX
                    local newW = math.max(20, math.floor(handle.startW + delta))
                    col.w = newW
                    btn:SetWidth(newW)
                end)
            end
        end)
        resizer:SetScript("OnMouseUp", function(self)
            if self.isResizing then
                self.isResizing = false
                self:SetScript("OnUpdate", nil)
                resizeLine:SetColorTexture(T.accent[1], T.accent[2], T.accent[3], 0)
                SaveColumnWidth(currentViewKey, col.key, col.w)
                RF:RebuildRosterColumns(parent)
            end
        end)

        btn:SetScript("OnClick", function(self, button)
            if button == "RightButton" then
                local menuItems = {}
                tinsert(menuItems, { text = "Columns", isTitle = true })

                -- Scoped to the active view's own column list and visibility
                -- table -- previously this listed every column from every
                -- view (including ones not present here) and wrote to a
                -- single visibility table shared by all views.
                local view = GetCurrentView()
                local vis = RF:GetViewVisibility(view.key)
                for _, colDef in ipairs(view.columns) do
                    local isVisible = vis[colDef.key] ~= false
                    local prefix = isVisible and "|TInterface\\RAIDFRAME\\ReadyCheck-Ready:14|t " or "      "
                    local disabled = colDef.key == "name"
                    tinsert(menuItems, {
                        text = prefix .. colDef.label,
                        disabled = disabled,
                        func = function()
                            if colDef.key ~= "name" then
                                vis[colDef.key] = not isVisible
                                RF:FireCallbacks("COLUMNS_CHANGED")
                            end
                        end,
                    })
                end

                tinsert(menuItems, { separator = true })
                tinsert(menuItems, { text = "Reset Column Order", func = function()
                    if RF.charDB and RF.charDB.columnOrder then
                        RF.charDB.columnOrder[currentViewKey] = nil
                    end
                    RF:RebuildRosterColumns(parent)
                end })
                tinsert(menuItems, { text = "Reset Column Widths", func = function()
                    if RF.charDB and RF.charDB.columnWidths then
                        RF.charDB.columnWidths[currentViewKey] = nil
                    end
                    RF:RebuildRosterColumns(parent)
                end })
                RF:ShowContextMenu(self, menuItems)
                return
            end
            if sortKey == col.key then
                sortDir = (sortDir == RF.SORT_ASC) and RF.SORT_DESC or RF.SORT_ASC
            else
                sortKey = col.key
                sortDir = RF.SORT_ASC
            end
            RF:RefreshRosterTab()
        end)

        btn:SetScript("OnEnter", function()
            if not headerFrame.dragging then
                text:SetTextColor(unpack(T.textHighlight))
            end
            local tip = RF.COLUMN_TOOLTIPS and RF.COLUMN_TOOLTIPS[col.key]
            if tip then
                GameTooltip:SetOwner(btn, "ANCHOR_BOTTOM")
                GameTooltip:AddLine(col.label, 1, 1, 1)
                GameTooltip:AddLine(tip, 0.7, 0.7, 0.7, true)
                if col.key == "status" then
                    GameTooltip:AddLine(" ")
                    GameTooltip:AddDoubleLine("|cFF4ADE80●|r Online", "|cFFFFCC00●|r Away")
                    GameTooltip:AddDoubleLine("|cFFE64040●|r Busy", "|cFF808080●|r Offline")
                    GameTooltip:AddLine("|cFF66B3FF●|r Mobile", 0.4, 0.7, 1.0)
                end
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine("Click to sort | Right-click for options", 0.5, 0.5, 0.5)
                GameTooltip:Show()
            end
        end)
        btn:SetScript("OnLeave", function()
            if not headerFrame.dragging then
                if sortKey == col.key then
                    text:SetTextColor(unpack(T.accent))
                else
                    text:SetTextColor(unpack(T.textSecondary))
                end
            end
            GameTooltip:Hide()
        end)

        -- Drag start
        btn:SetScript("OnDragStart", function(self)
            headerFrame.dragging = true
            headerFrame.dragSourceKey = col.key

            -- Dim the source button
            self.label:SetTextColor(unpack(T.textMuted))

            -- Lazily create drag indicator
            if not headerFrame.dragIndicator then
                local di = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
                di:SetFrameStrata("TOOLTIP")
                di:SetSize(80, 22)
                di:SetBackdrop({
                    bgFile = "Interface\\Buttons\\WHITE8x8",
                    edgeFile = "Interface\\Buttons\\WHITE8x8",
                    edgeSize = 1,
                })
                di:SetBackdropColor(0.12, 0.12, 0.16, 0.95)
                di:SetBackdropBorderColor(unpack(T.accent))
                di.label = di:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                di.label:SetFont(di.label:GetFont(), RF.FONT_SIZES.small, "")
                di.label:SetPoint("CENTER", 0, 0)
                di.label:SetTextColor(unpack(T.accent))
                headerFrame.dragIndicator = di
            end

            -- Lazily create drop line
            if not headerFrame.dropLine then
                local dl = CreateFrame("Frame", nil, headerFrame, "BackdropTemplate")
                dl:SetFrameStrata("DIALOG")
                dl:SetWidth(2)
                dl:SetBackdrop({
                    bgFile = "Interface\\Buttons\\WHITE8x8",
                })
                dl:SetBackdropColor(unpack(T.accent))
                headerFrame.dropLine = dl
            end

            local di = headerFrame.dragIndicator
            di.label:SetText(col.label)
            local textW = di.label:GetStringWidth()
            di:SetWidth(math.max(textW + 16, 40))
            di:Show()

            headerFrame.dropLine:SetHeight(headerFrame:GetHeight())
            headerFrame.dropLine:Show()
            headerFrame.dropIndex = nil

            -- OnUpdate: follow cursor and compute drop position
            headerFrame:SetScript("OnUpdate", function()
                local cx, cy = GetCursorPosition()
                local scale = UIParent:GetEffectiveScale()
                cx, cy = cx / scale, cy / scale
                di:ClearAllPoints()
                di:SetPoint("CENTER", UIParent, "BOTTOMLEFT", cx, cy + 18)

                -- Determine drop index by comparing cursor X to header button edges
                local hScale = headerFrame:GetEffectiveScale()
                local cursorLocal = (cx * scale / hScale) - headerFrame:GetLeft()
                local bestIdx = 1
                local bestDist = math.huge
                for i, b in ipairs(headerFrame.buttons) do
                    local leftEdge = b:GetLeft() - headerFrame:GetLeft()
                    local dist = math.abs(cursorLocal - leftEdge)
                    if dist < bestDist then
                        bestDist = dist
                        bestIdx = i
                    end
                    -- Also check right edge of last button
                    if i == #headerFrame.buttons then
                        local rightEdge = b:GetRight() - headerFrame:GetLeft()
                        dist = math.abs(cursorLocal - rightEdge)
                        if dist < bestDist then
                            bestDist = dist
                            bestIdx = i + 1
                        end
                    end
                end

                headerFrame.dropIndex = bestIdx

                -- Position the drop line
                local dl = headerFrame.dropLine
                dl:ClearAllPoints()
                if bestIdx <= #headerFrame.buttons then
                    dl:SetPoint("TOPLEFT", headerFrame.buttons[bestIdx], "TOPLEFT", -1, 0)
                else
                    local lastBtn = headerFrame.buttons[#headerFrame.buttons]
                    dl:SetPoint("TOPLEFT", lastBtn, "TOPRIGHT", 0, 0)
                end
            end)
        end)

        -- Drag stop
        btn:SetScript("OnDragStop", function(self)
            -- Clear visuals
            headerFrame.dragging = false
            headerFrame:SetScript("OnUpdate", nil)
            if headerFrame.dragIndicator then headerFrame.dragIndicator:Hide() end
            if headerFrame.dropLine then headerFrame.dropLine:Hide() end

            -- Reset label color
            if sortKey == col.key then
                self.label:SetTextColor(unpack(T.accent))
            else
                self.label:SetTextColor(unpack(T.textSecondary))
            end

            local sourceKey = headerFrame.dragSourceKey
            local dropIdx = headerFrame.dropIndex
            if not sourceKey or not dropIdx then return end

            -- Get visible column keys
            local visCols = GetCurrentColumns()
            local sourceIdx = nil
            for i, vc in ipairs(visCols) do
                if vc.key == sourceKey then
                    sourceIdx = i
                    break
                end
            end
            if not sourceIdx then return end

            -- No-op check: dropping in same position or adjacent (no actual move)
            if dropIdx == sourceIdx or dropIdx == sourceIdx + 1 then return end

            -- Build new visible key order
            local visKeys = {}
            for _, vc in ipairs(visCols) do
                tinsert(visKeys, vc.key)
            end

            -- Remove source
            tremove(visKeys, sourceIdx)
            -- Adjust drop index after removal
            if dropIdx > sourceIdx then
                dropIdx = dropIdx - 1
            end
            -- Insert at new position
            tinsert(visKeys, dropIdx, sourceKey)

            -- Merge hidden columns back into full order
            local view = GetCurrentView()
            local orderedAll = GetOrderedColumns(view.columns, view.key)
            local vis = RF:GetViewVisibility(view.key)

            -- Build map: for each hidden col, track which visible col it follow
            local fullKeys = {}
            local visSet = {}
            for _, k in ipairs(visKeys) do visSet[k] = true end

            -- Collect hidden columns grouped by their preceding visible column
            local hiddenAfter = {} -- key -> { hidden keys in order }
            local hiddenBefore = {} -- hidden keys before first visible
            local lastVisKey = nil
            for _, c in ipairs(orderedAll) do
                local isHidden = (vis[c.key] == false) or (not visSet[c.key] and vis[c.key] == false)
                if vis[c.key] == false then
                    if lastVisKey then
                        if not hiddenAfter[lastVisKey] then hiddenAfter[lastVisKey] = {} end
                        tinsert(hiddenAfter[lastVisKey], c.key)
                    else
                        tinsert(hiddenBefore, c.key)
                    end
                else
                    lastVisKey = c.key
                end
            end

            -- Rebuild full order: hidden-before, then for each visible key append it + its trailing hidden
            for _, hk in ipairs(hiddenBefore) do
                tinsert(fullKeys, hk)
            end
            for _, vk in ipairs(visKeys) do
                tinsert(fullKeys, vk)
                if hiddenAfter[vk] then
                    for _, hk in ipairs(hiddenAfter[vk]) do
                        tinsert(fullKeys, hk)
                    end
                end
            end

            SaveColumnOrder(currentViewKey, fullKeys)
            RF:RebuildRosterColumns(parent)
        end)

        btn.colKey = col.key
        tinsert(headerFrame.buttons, btn)
    end

    function headerFrame:UpdateSortIndicators()
        for _, btn in ipairs(headerFrame.buttons) do
            if btn.colKey == sortKey then
                btn.label:SetTextColor(unpack(T.accent))
                btn.arrow:SetText(sortDir == RF.SORT_ASC and "^" or "v")
                btn.arrow:Show()
            else
                btn.label:SetTextColor(unpack(T.textSecondary))
                btn.arrow:Hide()
            end
        end
    end

    return headerFrame
end

-------------------------------------------------------------------------------
-- Init Roster Tab
-------------------------------------------------------------------------------
function RF:InitRosterTab(parent)
    T = RF.Theme

    onlineFilter = RF.charDB and RF.charDB.defaultOnlineFilter or RF.ONLINE_FILTER.ONLINE

    ---------------------------------------------------------------------------
    -- Filter Bar
    ---------------------------------------------------------------------------
    local filterBar = CreateFrame("Frame", nil, parent)
    filterBar:SetHeight(34)
    filterBar:SetPoint("TOPLEFT", 8, -4)
    filterBar:SetPoint("TOPRIGHT", -8, -4)

    -- Search
    local searchBar = RF:CreateSearchBar(filterBar, {
        width = 200,
        placeholder = "Search roster...",
        onTextChanged = function(text)
            searchText = text
            RF:RefreshRosterTab()
        end,
    })
    searchBar:SetPoint("LEFT", 0, 0)
    parent.searchBar = searchBar

    -- Online filter pills
    local pillAll = RF:CreatePillButton(filterBar, "All")
    pillAll:SetPoint("LEFT", searchBar, "RIGHT", 10, 0)

    local pillOnline = RF:CreatePillButton(filterBar, "Online")
    pillOnline:SetPoint("LEFT", pillAll, "RIGHT", 4, 0)

    local pillOffline = RF:CreatePillButton(filterBar, "Offline")
    pillOffline:SetPoint("LEFT", pillOnline, "RIGHT", 4, 0)

    local pills = { pillAll, pillOnline, pillOffline }
    local pillValues = { RF.ONLINE_FILTER.ALL, RF.ONLINE_FILTER.ONLINE, RF.ONLINE_FILTER.OFFLINE }

    local function UpdatePills()
        for i, pill in ipairs(pills) do
            pill:SetActive(onlineFilter == pillValues[i])
        end
    end

    for i, pill in ipairs(pills) do
        pill:SetScript("OnClick", function()
            local prevFilter = onlineFilter
            onlineFilter = pillValues[i]
            UpdatePills()
            -- Rebuild columns when switching to/from Online filter (hides/shows Last Online)
            local needsRebuild = (prevFilter == RF.ONLINE_FILTER.ONLINE) ~= (onlineFilter == RF.ONLINE_FILTER.ONLINE)
            if needsRebuild then
                RF:RebuildRosterColumns(parent)
            else
                RF:RefreshRosterTab()
            end
        end)
    end
    UpdatePills()

    -- Class filter dropdown
    local classItems = { { label = "All Classes", value = nil } }
    local classNames = {
        "WARRIOR", "PALADIN", "HUNTER", "ROGUE", "PRIEST", "DEATHKNIGHT",
        "SHAMAN", "MAGE", "WARLOCK", "MONK", "DRUID", "DEMONHUNTER", "EVOKER",
    }
    for _, cf in ipairs(classNames) do
        local displayName = cf:sub(1,1) .. cf:sub(2):lower()
        tinsert(classItems, { label = displayName, value = cf })
    end

    local classDropdown = RF:CreateDropdown(filterBar, {
        width = 110,
        defaultText = "All Classes",
        items = classItems,
        onChanged = function(value)
            classFilter = value
            RF:RefreshRosterTab()
        end,
    })
    classDropdown:SetPoint("LEFT", pillOffline, "RIGHT", 10, 0)

    -- Role (tag) filter dropdown
    local roleItems = { { label = "All Roles", value = nil } }
    local rolePresets = { "Tank", "Healer", "DPS", "Trial", "Bench", "Officer Alt", "Social", "PvP", "Raider" }
    for _, rp in ipairs(rolePresets) do
        tinsert(roleItems, { label = rp, value = rp })
    end

    local roleDropdown = RF:CreateDropdown(filterBar, {
        width = 100,
        defaultText = "All Roles",
        items = roleItems,
        onChanged = function(value)
            tagFilter = value
            RF:RefreshRosterTab()
        end,
    })
    roleDropdown:SetPoint("LEFT", classDropdown, "RIGHT", 10, 0)

    -- Column view dropdown
    local viewItems = {}
    for _, view in ipairs(RF.COLUMN_VIEWS) do
        tinsert(viewItems, { label = view.label, value = view.key })
    end

    local viewDropdown = RF:CreateDropdown(filterBar, {
        width = 120,
        defaultText = "Default",
        items = viewItems,
        onChanged = function(value)
            currentViewKey = value
            RF:RebuildRosterColumns(parent)
        end,
    })
    viewDropdown:SetPoint("LEFT", roleDropdown, "RIGHT", 10, 0)

    -- Export button
    local exportBtn = RF:CreateButton(filterBar, "Export", 60, 24)
    exportBtn:SetPoint("RIGHT", -75, 0)
    exportBtn:SetScript("OnClick", function()
        local entries = displayData and #displayData > 0 and displayData or RF.rosterData
        if entries and #entries > 0 then
            RF:ExportData(entries, "csv")
        else
            RF:Print("No roster data to export.")
        end
    end)

    -- Refresh button
    local refreshBtn = RF:CreateButton(filterBar, "Refresh", 65, 24)
    refreshBtn:SetPoint("RIGHT", 0, 0)
    refreshBtn:SetScript("OnClick", function()
        if IsInGuild() then
            C_GuildInfo.GuildRoster()
            RF:Print("Roster refresh requested.")
        end
    end)

    ---------------------------------------------------------------------------
    -- Session Diff Banner
    ---------------------------------------------------------------------------
    local diffBanner = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    diffBanner:SetHeight(24)
    diffBanner:SetPoint("TOPLEFT", filterBar, "BOTTOMLEFT", 0, -2)
    diffBanner:SetPoint("TOPRIGHT", filterBar, "BOTTOMRIGHT", 0, -2)
    diffBanner:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    diffBanner:SetBackdropColor(0.23, 0.51, 0.96, 0.15)
    diffBanner:SetBackdropBorderColor(0.23, 0.51, 0.96, 0.4)
    diffBanner:Hide()

    local diffText = diffBanner:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    diffText:SetFont(diffText:GetFont(), RF.FONT_SIZES.small, "")
    diffText:SetPoint("LEFT", 8, 0)
    diffText:SetTextColor(unpack(T.textPrimary))

    local diffClose = CreateFrame("Button", nil, diffBanner)
    diffClose:SetSize(16, 16)
    diffClose:SetPoint("RIGHT", -4, 0)
    local diffCloseText = diffClose:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    diffCloseText:SetFont(diffCloseText:GetFont(), 12, "")
    diffCloseText:SetAllPoints()
    diffCloseText:SetText("x")
    diffCloseText:SetTextColor(unpack(T.textMuted))
    diffClose:SetScript("OnClick", function()
        diffBanner:Hide()
        parent.diffBannerShown = false
        -- Re-anchor headers below filter bar
        parent.headers:ClearAllPoints()
        parent.headers:SetPoint("TOPLEFT", filterBar, "BOTTOMLEFT", 0, -4)
        parent.headers:SetPoint("TOPRIGHT", filterBar, "BOTTOMRIGHT", 0, -4)
    end)

    parent.diffBanner = diffBanner
    parent.diffText = diffText
    parent.diffBannerShown = false
    parent.sessionDiffChecked = false

    ---------------------------------------------------------------------------
    -- Inline Note Editor
    ---------------------------------------------------------------------------
    local inlineNoteEditor = CreateFrame("EditBox", nil, parent, "BackdropTemplate")
    inlineNoteEditor:SetHeight(GetRowHeight())
    inlineNoteEditor:SetFrameStrata("DIALOG")
    inlineNoteEditor:SetAutoFocus(false)
    inlineNoteEditor:SetMaxLetters(31)
    inlineNoteEditor:SetFontObject("ChatFontNormal")
    inlineNoteEditor:SetFont(inlineNoteEditor:GetFont(), RF.FONT_SIZES.small, "")
    inlineNoteEditor:SetTextColor(unpack(T.textPrimary))
    inlineNoteEditor:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    inlineNoteEditor:SetBackdropColor(0.1, 0.1, 0.14, 1)
    inlineNoteEditor:SetBackdropBorderColor(unpack(T.accent))
    inlineNoteEditor:Hide()
    parent.inlineNoteEditor = inlineNoteEditor

    inlineNoteEditor:SetScript("OnEscapePressed", function(self)
        self:Hide()
        self:ClearFocus()
    end)
    inlineNoteEditor:SetScript("OnEnterPressed", function(self)
        self:Hide()
        self:ClearFocus()
        RF:Print("To edit notes, right-click the member in the Guild Panel (J key).")
    end)
    inlineNoteEditor:SetScript("OnEditFocusLost", function(self)
        self:Hide()
    end)

    ---------------------------------------------------------------------------
    -- Column Headers (data-driven)
    ---------------------------------------------------------------------------
    local headers = CreateColumnHeaders(parent, GetCurrentColumns())
    headers:SetPoint("TOPLEFT", filterBar, "BOTTOMLEFT", 0, -4)
    headers:SetPoint("TOPRIGHT", filterBar, "BOTTOMRIGHT", 0, -4)

    ---------------------------------------------------------------------------
    -- Scroll List
    ---------------------------------------------------------------------------
    local scrollList = RF:CreateScrollList(parent, {
        rowHeight = GetRowHeight(),
        createRow = CreateRosterRow,
        updateRow = UpdateRosterRow,
        onRowClick = function(row, data, button)
            if not data then return end
            if button == "RightButton" then
                ShowRosterContextMenu(row, data)
            else
                -- Check if click is in a note or officerNote column
                local columns = GetCurrentColumns()
                local cursorX = GetCursorPosition()
                local scale = row:GetEffectiveScale()
                cursorX = cursorX / scale
                local rowLeft = row:GetLeft()
                local relX = cursorX - rowLeft

                for _, col in ipairs(columns) do
                    if (col.key == "note" or col.key == "officerNote") and relX >= col.x and relX <= (col.x + col.w) then
                        local text = col.key == "note" and data.note or data.officerNote
                        -- If the field contains a URL, show copy popup
                        local url = RF:ExtractURL(text or "")
                        if url then
                            RF:ShowCopyPopup(url)
                            return
                        end
                        -- Inline note editing is disabled in WoW 12.0
                        -- (C_GuildInfo.SetNote is fully protected)
                        -- Redirect to Blizzard guild panel
                        if col.key == "note" or col.key == "officerNote" then
                            RF:Print("To edit notes, right-click the member in the Guild Panel (J key).")
                            return
                        end
                    end
                end
                parent.scrollList:SetSelected(row.dataIndex)
                if RF.ShowDetailPanel then
                    RF:ShowDetailPanel(data)
                end
            end
        end,
        onRowEnter = function(row, data)
            GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
            GameTooltip:AddLine(RF:GetClassColoredName(data.name, data.classFile))
            GameTooltip:AddLine(data.rankName .. " (Rank " .. data.rankIndex .. ")", 0.7, 0.7, 0.7)
            if data.zone ~= "" then
                GameTooltip:AddLine("Zone: " .. data.zone, 0.7, 0.7, 0.7)
            end
            if data.note ~= "" then
                GameTooltip:AddLine("Note: " .. data.note, 0.7, 0.7, 0.7)
            end
            if data.profession1Name and data.profession1Name ~= "" then
                GameTooltip:AddLine("Prof: " .. data.profession1Name, 0.6, 0.8, 0.6)
            end
            if data.profession2Name and data.profession2Name ~= "" then
                GameTooltip:AddLine("Prof: " .. data.profession2Name, 0.6, 0.8, 0.6)
            end
            if data.altMain then
                GameTooltip:AddLine(data.altMain, 0.4, 0.7, 1.0)
            end
            if data.extendedNote then
                GameTooltip:AddLine("RosterNote: " .. data.extendedNote, 0.6, 0.8, 0.6, true)
            end
            GameTooltip:Show()
        end,
        onRowLeave = function()
            GameTooltip:Hide()
        end,
    })
    scrollList:SetPoint("TOPLEFT", headers, "BOTTOMLEFT", 0, -1)
    scrollList:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -4, 4)

    parent.scrollList = scrollList
    parent.headers = headers
    parent.filterBar = filterBar

    ---------------------------------------------------------------------------
    -- Rebuild Columns (called when view changes)
    ---------------------------------------------------------------------------
    function RF:RebuildRosterColumns(tab)
        -- Hide and remove old headers
        if tab.headers then
            tab.headers:Hide()
            tab.headers:SetParent(nil)
        end

        -- Create new headers with current view columns
        local newHeaders = CreateColumnHeaders(tab, GetCurrentColumns())
        newHeaders:SetPoint("TOPLEFT", tab.filterBar, "BOTTOMLEFT", 0, -4)
        newHeaders:SetPoint("TOPRIGHT", tab.filterBar, "BOTTOMRIGHT", 0, -4)
        tab.headers = newHeaders

        -- Re-anchor scroll list below new headers
        tab.scrollList:ClearAllPoints()
        tab.scrollList:SetPoint("TOPLEFT", newHeaders, "BOTTOMLEFT", 0, -1)
        tab.scrollList:SetPoint("BOTTOMRIGHT", tab, "BOTTOMRIGHT", -4, 4)

        RF:RefreshRosterTab()
    end

    ---------------------------------------------------------------------------
    -- Refresh Function
    ---------------------------------------------------------------------------
    function RF:RefreshRosterTab()
        FilterAndSort()
        if parent.scrollList then
            parent.scrollList:SetFilteredData(displayData)
        end
        if parent.headers then
            parent.headers:UpdateSortIndicators()
        end
        RF:UpdateStatusBar()
    end

    -- Hook roster refresh callback
    RF:RegisterCallback("ROSTER_REFRESHED", function()
        if RF.mainFrame and RF.mainFrame:IsShown() then
            RF:RefreshRosterTab()
        end

        -- Session diff banner (show once per session on first refresh)
        if not parent.sessionDiffChecked then
            parent.sessionDiffChecked = true
            local diff = RF:ComputeSessionDiff()
            if diff then
                local parts = {}
                if #diff.joined > 0 then
                    tinsert(parts, "|cFF22C55E+" .. #diff.joined .. " joined|r")
                end
                if #diff.left > 0 then
                    tinsert(parts, "|cFFEF4444-" .. #diff.left .. " left|r")
                end
                if #parts > 0 then
                    parent.diffText:SetText("Since last session: " .. table.concat(parts, ", "))
                    parent.diffBanner:Show()
                    parent.diffBannerShown = true
                    -- Re-anchor headers below banner
                    parent.headers:ClearAllPoints()
                    parent.headers:SetPoint("TOPLEFT", parent.diffBanner, "BOTTOMLEFT", 0, -2)
                    parent.headers:SetPoint("TOPRIGHT", parent.diffBanner, "BOTTOMRIGHT", 0, -2)
                end
            end
        end
    end)

    -- Save/restore scroll position when switching tabs
    RF:RegisterCallback("SWITCH_TAB", function(_, key)
        if key == "Roster" then
            RF:RefreshRosterTab()
            -- Restore scroll position
            if parent.scrollList and savedScrollOffset > 0 then
                parent.scrollList:SetScrollOffset(savedScrollOffset)
            end
        else
            -- Save scroll position when leaving Roster
            if parent.scrollList then
                savedScrollOffset = parent.scrollList:GetScrollOffset()
            end
        end
    end)

    -- Rebuild columns when visibility settings change
    RF:RegisterCallback("COLUMNS_CHANGED", function()
        RF:RebuildRosterColumns(parent)
    end)

    -- Initial refresh
    RF:RefreshRosterTab()
end
