-- TOG Tools — Shared sortable-row component
-- Adapted from FastGuildInvite's GUI/RowList.lua. Used by every tab that
-- shows a list of data so the visual style stays consistent and the layout
-- elastically compacts when the user shrinks the window horizontally —
-- AceGUI Flow + SetWidth wraps to a new line when narrowed; raw frames
-- anchored LEFT/RIGHT to the parent stretch and shrink with it.
--
-- Visual contract:
--   * 16 px row height, alternating row banding.
--   * Right-to-left fixed-width columns; one auto-width column stretches
--     to fill whatever the fixed columns leave.
--   * Cells: SetWordWrap(false) + SetMaxLines(1) — narrowing the window
--     truncates text rather than wrapping it.
--   * Optional brand-colored, clickable header bar with click-to-sort.
--   * Virtual-scroll pool sized to the parent's height; custom scrollbar
--     on the right edge that auto-shows when content overflows.
--   * Mouse-wheel scrolls the parent.
--
-- Construction:
--   local rl = addon.RowList:New(parent, {
--       rowHeight = 16,
--       columns = {
--           { key = "title",  header = "Addon Name",  justify = "LEFT" },                          -- auto-width
--           { key = "mem",    header = "Memory",      width = 110, justify = "RIGHT",
--             format = function(e) return fmtMem(e.mem) end, sortDescDefault = true },
--           { key = "status", header = "Status",      width = 175, justify = "LEFT",
--             sortKey = "statusPrio",
--             format = function(e) return "|c"..e.statusColor..e.status.."|r" end },
--       },
--   })
--   rl:SetData(arrayOfEntries)        -- (re-)populate
--   rl:Refresh()                      -- after external mutation / resize

local _, addon = ...

local DEFAULT_ROW_HEIGHT = 16
local HEADER_HEIGHT      = 20
local LEFT_PAD           = 6
local COL_GAP            = 4
local SCROLLBAR_WIDTH    = 16
local SCROLLBAR_BTN_SIZE = 32
-- AutoFit columns measure the widest cell text and grow `col.width` to
-- `max(col.minWidth, measured + AUTOFIT_PADDING)`. The padding is
-- breathing room past the measured glyph extent so highlighted /
-- mouseover state doesn't visually crowd the next column. 8 px lands
-- about a half-character of slack on either side for GameFontHighlightSmall.
local AUTOFIT_PADDING    = 8
-- Reserved permanently on the row's right edge so rows don't shift horizontally
-- when the scrollbar shows / hides. Sized so the visible gap between the
-- rightmost column edge and the scrollbar's left edge lands at ~5 px:
--   gap = (SCROLLBAR_GUTTER - SCROLLBAR_WIDTH - 1) + COL_GAP
--       = (18 - 16 - 1) + 4 = 5
local SCROLLBAR_GUTTER   = SCROLLBAR_WIDTH + 2

local RowList = {}
RowList.__index = RowList
addon.RowList = RowList

-- ---------------------------------------------------------------------------
-- Constructor
-- ---------------------------------------------------------------------------
function RowList:New(parent, opts)
    opts = opts or {}
    local self = setmetatable({}, RowList)

    self.parent     = parent
    self.rowHeight  = opts.rowHeight or DEFAULT_ROW_HEIGHT
    self.columns    = opts.columns   or {}
    self.onRowClick = opts.onRowClick   -- optional fn(entry, sortedIndex, rl) on whole-row click

    self.data            = {}
    self.listOffset      = 0
    self.rows            = {}
    self.visibleRowCount = 0

    -- AutoFit-column bootstrap. _buildHeader / _buildRow both need a
    -- non-nil col.width for fixed-width columns; an autoFit column that
    -- hasn't been measured yet would crash that pass. Seed it with the
    -- declared minWidth so initial render works; the first SetData call
    -- will run _autoFitColumns and grow the width to fit real content.
    for _, col in ipairs(self.columns) do
        if col.autoFit and not col.width then
            col.width = col.minWidth or 0
        end
    end

    -- Header bar present only if at least one column declares a header.
    self.hasHeader = false
    for _, col in ipairs(self.columns) do
        if col.header then self.hasHeader = true; break end
    end
    self.headerHeight = self.hasHeader and HEADER_HEIGHT or 0

    if self.hasHeader then self:_buildHeader() end

    -- Initial pool size: enough to fill the parent at construction time.
    -- _recomputeVisibleRows grows the pool on demand if the parent gets taller.
    local initialCount = math.max(1, math.floor(((parent:GetHeight() or 0) - self.headerHeight) / self.rowHeight))
    for i = 1, initialCount do
        self.rows[i] = self:_buildRow(i)
    end

    self:_buildScrollbar()

    parent:HookScript("OnSizeChanged", function() self:Refresh() end)

    parent:EnableMouseWheel(true)
    parent:HookScript("OnMouseWheel", function(_, delta)
        if not self.data or #self.data == 0 then return end
        local sb = self.scrollbar
        if not sb then return end
        local _, maxOffset = sb:GetMinMaxValues()
        if maxOffset <= 0 then return end
        sb:SetValue(math.max(0, math.min(maxOffset, sb:GetValue() - delta)))
    end)

    return self
end

-- ---------------------------------------------------------------------------
-- Header bar
-- ---------------------------------------------------------------------------
local function brandWrap(text)
    return "|c" .. (addon.BrandColor or "ffFF8000") .. (text or "") .. "|r"
end

-- Three anchoring modes, selected by which of anchorRight / anchorLeft is set:
--   * Both set      → stretchy (auto-width column anchored on both sides)
--   * RIGHT only    → fixed-width column anchored from the parent's RIGHT edge
--   * LEFT only     → fixed-width column anchored from the parent's LEFT edge
-- The LEFT-only path is new in v0.5.1; it lets _buildHeader place fixed-
-- width columns that sit to the LEFT of a mid-array auto-width column
-- without colliding with the stretchy column's right anchor.
function RowList:_makeHeaderColumn(parent, col, anchorRight, anchorLeft)
    local btn = CreateFrame("Button", nil, parent)
    btn:EnableMouse(true)
    btn:SetHeight(HEADER_HEIGHT)
    if anchorLeft and anchorRight then
        btn:SetPoint("LEFT",  parent, "LEFT",  anchorLeft,  0)
        btn:SetPoint("RIGHT", parent, "RIGHT", anchorRight, 0)
    elseif anchorLeft then
        btn:SetWidth(col.width)
        btn:SetPoint("LEFT", parent, "LEFT", anchorLeft, 0)
    else
        btn:SetWidth(col.width)
        btn:SetPoint("RIGHT", parent, "RIGHT", anchorRight, 0)
    end

    local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    fs:SetAllPoints(btn)
    fs:SetJustifyH(col.justify or "LEFT")
    fs:SetWordWrap(false)
    fs:SetMaxLines(1)

    self._headerLabels[col.key] = { btn = btn, fs = fs, col = col }

    if col.headerTip or col.headerTipDesc then
        btn:SetScript("OnEnter", function(self_)
            -- Route through the global anchor helper so column header
            -- tooltips obey the same flip-budget rules as every other
            -- tooltip in the addon. UI.lua is guaranteed loaded by the
            -- time any RowList instance renders (it sits later in the
            -- TOC than RowList.lua but earlier than any tab module that
            -- actually creates a RowList).
            addon.Tooltip.Owner(self_)
            GameTooltip:SetText(col.headerTip or col.header or "", 1, 1, 1)
            if col.headerTipDesc then GameTooltip:AddLine(col.headerTipDesc, nil, nil, nil, true) end
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
    end

    if col.header and col.sortable ~= false then
        local rl  = self
        local key = col.sortKey or col.key
        local descDefault = col.sortDescDefault and true or false
        btn:SetScript("OnClick", function()
            if rl.sortKey == key then
                rl.sortDesc = not rl.sortDesc
            else
                rl.sortKey  = key
                rl.sortDesc = descDefault
            end
            rl._dataSorted = nil
            rl:_updateHeaderText()
            rl:Refresh()
        end)
    end

    return btn
end

function RowList:_updateHeaderText()
    -- No active-sort glyph — WoW's default font doesn't render unicode
    -- triangles cleanly and ASCII v / ^ look like typos. Click affordance
    -- is communicated by the column tooltip instead.
    if not self._headerLabels then return end
    for _, h in pairs(self._headerLabels) do
        h.fs:SetText(brandWrap(h.col.header or ""))
    end
end

function RowList:_buildHeader()
    self._headerLabels = {}

    local h = CreateFrame("Frame", nil, self.parent)
    h:SetHeight(HEADER_HEIGHT)
    h:SetPoint("TOPLEFT",  self.parent, "TOPLEFT",   0,                0)
    h:SetPoint("TOPRIGHT", self.parent, "TOPRIGHT", -SCROLLBAR_GUTTER, 0)

    local bg = h:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(h)
    bg:SetColorTexture(1, 1, 1, 0.08)

    local border = h:CreateTexture(nil, "OVERLAY")
    border:SetHeight(1)
    border:SetPoint("BOTTOMLEFT",  h, "BOTTOMLEFT",  0, 0)
    border:SetPoint("BOTTOMRIGHT", h, "BOTTOMRIGHT", 0, 0)
    border:SetColorTexture(1, 0.82, 0, 0.4)

    -- Find the auto-width column (only one allowed). If it sits mid-array,
    -- we split column placement into TWO passes — fixed-width cols to the
    -- RIGHT of auto get RIGHT-anchored (right-to-left), fixed-width cols
    -- to the LEFT of auto get LEFT-anchored (left-to-right), and the auto
    -- col stretches between them. Previously a single right-to-left pass
    -- treated the auto col as always-leftmost, which forced sub-tab
    -- consumers to put the stretchy column at array index 1; now array
    -- order = visual order and the auto col can land anywhere.
    local autoColIndex
    for ci, col in ipairs(self.columns) do
        if not col.width then autoColIndex = ci; break end
    end

    -- Pass 1 — RIGHT-anchored cols (everything right of auto, or all cols
    -- when no auto col exists).
    local rightOffset = COL_GAP
    local rightBound  = autoColIndex and (autoColIndex + 1) or 1
    for ci = #self.columns, rightBound, -1 do
        local col = self.columns[ci]
        self:_makeHeaderColumn(h, col, -rightOffset, nil)
        -- col.gapBefore: extra px of space to the LEFT of this column
        -- (in left-to-right reading order). Useful when the column to
        -- the left is right-justified and would otherwise visually
        -- collide with this column's left-justified text.
        rightOffset = rightOffset + col.width + COL_GAP + (col.gapBefore or 0)
    end
    local autoColRightOffset = rightOffset

    -- Pass 2 — LEFT-anchored cols (everything left of auto). Only runs
    -- when there IS an auto col; otherwise Pass 1 placed everything.
    local autoColLeftOffset = LEFT_PAD
    if autoColIndex then
        local leftOffset = LEFT_PAD
        for ci = 1, autoColIndex - 1 do
            local col = self.columns[ci]
            self:_makeHeaderColumn(h, col, nil, leftOffset)
            leftOffset = leftOffset + col.width + COL_GAP + (col.gapBefore or 0)
        end
        autoColLeftOffset = leftOffset
    end

    -- Pass 3 — the auto col itself, stretchy between the two passes.
    if autoColIndex then
        local col = self.columns[autoColIndex]
        self:_makeHeaderColumn(h, col, -autoColRightOffset, autoColLeftOffset)
    end

    self:_updateHeaderText()
    self.header = h
end

-- ---------------------------------------------------------------------------
-- Row pool
-- ---------------------------------------------------------------------------
function RowList:_buildRow(i)
    local row = CreateFrame("Frame", nil, self.parent)
    row:SetHeight(self.rowHeight)
    local yOff = -(self.headerHeight + (i - 1) * self.rowHeight)
    row:SetPoint("TOPLEFT",  self.parent, "TOPLEFT",   0,                yOff)
    row:SetPoint("TOPRIGHT", self.parent, "TOPRIGHT", -SCROLLBAR_GUTTER, yOff)

    if i % 2 == 0 then
        local bg = row:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints(row)
        bg:SetColorTexture(1, 1, 1, 0.04)
    end

    -- Interactive hyperlinks. Any cell text that contains `|Hitem:...|h`
    -- (or any other |H... markup — spell, achievement, quest, etc.)
    -- becomes hoverable and clickable: hover → GameTooltip with the
    -- linked subject; shift-click → insert the link into the open chat
    -- edit box; plain click → the standard Blizzard handler (e.g.
    -- pop a comparison tooltip). Requires `SetHyperlinksEnabled`, which
    -- exists on TBC+; older clients silently skip and the cell stays
    -- as static rendered-but-inert markup as before.
    if row.SetHyperlinksEnabled then
        row:SetHyperlinksEnabled(true)
        row:SetScript("OnHyperlinkEnter", function(self_, link)
            -- Player links (|Hplayer:NAME|h[NAME]|h) have no
            -- GameTooltip:SetHyperlink representation — the chat frame
            -- itself doesn't pop a tooltip on hover for them, just
            -- responds to the click. Skip the hover-tooltip path here
            -- so we don't error or render an empty tooltip.
            if type(link) == "string" and link:sub(1, 6) == "player" then
                return
            end
            -- Use the global anchor helper so item/spell/achievement
            -- link tooltips on data rows obey the same flip rules as
            -- the rest of the addon.
            addon.Tooltip.Owner(self_)
            GameTooltip:SetHyperlink(link)
            GameTooltip:Show()
        end)
        row:SetScript("OnHyperlinkLeave", function() GameTooltip:Hide() end)
        row:SetScript("OnHyperlinkClick", function(self_, link, text, button)
            -- Player links: split on button. Right-click calls
            -- FriendsFrame_ShowDropdown DIRECTLY rather than routing
            -- through SetItemRef — this is the pattern Blizzard's own
            -- CommunitiesInvitationFrame uses (see
            -- Blizzard_Communities/CommunitiesInvitationFrame.lua:103),
            -- because SetItemRef's right-click dispatch isn't reliable
            -- for non-chat-frame contexts on Retail. FriendsFrame_
            -- ShowDropdown lands at UnitPopup_OpenMenu("FRIEND", ...)
            -- which produces the standard chat-name menu (Whisper /
            -- Invite / Inspect / Ignore / Report / Copy Character
            -- Name / etc.) — the exact dropdown that appears when you
            -- click a name in chat. Left-click stays on SetItemRef
            -- since its left-click handler does the right thing
            -- (opens a whisper edit box on Retail; same on Classic).
            if type(link) == "string" and link:sub(1, 6) == "player" then
                -- Right-click → standard chat-name menu (whisper /
                -- invite / inspect / ignore / report / etc.), routed
                -- through FriendsFrame_ShowDropdown directly because
                -- SetItemRef's right-click dispatch doesn't reliably
                -- pop the menu for non-chat-frame contexts on Retail
                -- (see Blizzard_Communities/CommunitiesInvitationFrame.lua
                -- :103-109 for the same workaround in Blizzard's own
                -- code). The menu lands on FULLSCREEN_DIALOG strata
                -- (Blizzard_Menu/Menu.lua:2084) — MainWindow drops the
                -- AceGUI Frame from FULLSCREEN_DIALOG to DIALOG so the
                -- menu naturally floats above our window. Without
                -- that one-liner the AceGUI default
                -- (FULLSCREEN_DIALOG + frame level 100) wins z-order
                -- and the menu opens behind us. Left-click stays on
                -- SetItemRef since its left-click branch opens a
                -- whisper edit box on Retail and Classic alike.
                if button == "RightButton" and FriendsFrame_ShowDropdown then
                    local name = link:match("player:([^:]+)")
                    if name and name ~= "" then
                        -- The 5th arg is the chat frame the menu's Whisper
                        -- action sends through. On Retail, passing our row
                        -- frame works (leave it — it's the tested path). On
                        -- Classic, the Whisper button calls ChatFrame_SendTell
                        -- with that frame and reads its `editBox`; our RowList
                        -- row isn't a real ChatFrame (no editBox), so it throws
                        -- "attempt to index local 'editBox'" in
                        -- Blizzard's ChatFrame.lua. Pass the default chat frame
                        -- on Classic so Whisper opens its edit box correctly.
                        local gv = addon.gameVersion
                        if gv and gv.isRetail then
                            FriendsFrame_ShowDropdown(name, 1, nil, nil, self_)
                        else
                            FriendsFrame_ShowDropdown(name, 1, nil, nil, DEFAULT_CHAT_FRAME)
                        end
                        return
                    end
                end
                if SetItemRef then
                    SetItemRef(link, text, button, self_)
                    return
                end
            end
            -- HandleModifiedItemClick covers shift-click-to-chat,
            -- ctrl-click-dressup, etc. — matches the behaviour of
            -- links in chat / inbox / loot history without us having
            -- to reimplement modifier handling.
            if HandleModifiedItemClick and HandleModifiedItemClick(link) then
                return
            end
            -- Fallback for non-item links (spell / achievement / etc.):
            -- shift-click into the chat edit box if one is open.
            if IsModifiedClick and IsModifiedClick("CHATLINK") and ChatEdit_InsertLink then
                ChatEdit_InsertLink(link)
            end
        end)
    end

    local cells = {}

    -- Same auto-col detection and two-pass placement as _buildHeader.
    -- Build a small placement helper closure so the two passes share a
    -- single fixed-width-cell construction path.
    local autoColIndex
    for ci, col in ipairs(self.columns) do
        if not col.width then autoColIndex = ci; break end
    end

    local function placeFixedCell(col, anchorRight, anchorLeft)
        if col.checkbox then
            -- Interactive checkbox cell (FGI pattern). col.onToggle
            -- receives (entry, val, sortedIndex, rowList) when the
            -- user clicks. We also write entry[col.key] = val here
            -- so any subsequent _renderRows (parent resize, scroll,
            -- sort, external Refresh) repaints from the synced
            -- value rather than reverting to the pre-click state.
            local boxSize = col.boxSize or 16
            local cb = CreateFrame("CheckButton", nil, row, "UICheckButtonTemplate")
            cb:SetSize(boxSize, boxSize)
            -- Centre the box within the column slot so it visually
            -- matches the column-header text alignment for centre-
            -- justified columns.
            if anchorLeft then
                cb:SetPoint("LEFT", row, "LEFT",
                    anchorLeft + (col.width - boxSize) / 2, 0)
            else
                cb:SetPoint("RIGHT", row, "RIGHT",
                    -(anchorRight + (col.width - boxSize) / 2), 0)
            end
            cells[col.key] = cb
            if col.onToggle then
                local rl = self
                local rowIndex = i
                cb:SetScript("OnClick", function(self_)
                    local idx = (rl.listOffset or 0) + rowIndex
                    local entry = rl:_getSortedData()[idx]
                    if entry then
                        local val = self_:GetChecked() and true or false
                        entry[col.key] = val
                        col.onToggle(entry, val, idx, rl)
                    end
                end)
            end
        else
            local fs = row:CreateFontString(nil, "OVERLAY", col.font or "GameFontHighlightSmall")
            fs:SetWidth(col.width)
            fs:SetHeight(self.rowHeight)
            fs:SetJustifyH(col.justify or "LEFT")
            fs:SetWordWrap(false)
            fs:SetMaxLines(1)
            if anchorLeft then
                fs:SetPoint("LEFT", row, "LEFT", anchorLeft, 0)
            else
                fs:SetPoint("RIGHT", row, "RIGHT", -anchorRight, 0)
            end
            cells[col.key] = fs
        end
    end

    -- Pass 1 — RIGHT-anchored cells (right of auto, or all).
    local rightOffset = COL_GAP
    local rightBound  = autoColIndex and (autoColIndex + 1) or 1
    for ci = #self.columns, rightBound, -1 do
        local col = self.columns[ci]
        placeFixedCell(col, rightOffset, nil)
        rightOffset = rightOffset + col.width + COL_GAP + (col.gapBefore or 0)
    end
    local autoColRightOffset = rightOffset

    -- Pass 2 — LEFT-anchored cells (left of auto).
    local autoColLeftOffset = LEFT_PAD
    if autoColIndex then
        local leftOffset = LEFT_PAD
        for ci = 1, autoColIndex - 1 do
            local col = self.columns[ci]
            placeFixedCell(col, nil, leftOffset)
            leftOffset = leftOffset + col.width + COL_GAP + (col.gapBefore or 0)
        end
        autoColLeftOffset = leftOffset
    end

    -- Pass 3 — the auto cell stretches between left and right offsets.
    if autoColIndex then
        local col = self.columns[autoColIndex]
        local fs = row:CreateFontString(nil, "OVERLAY", col.font or "GameFontHighlightSmall")
        fs:SetHeight(self.rowHeight)
        fs:SetJustifyH(col.justify or "LEFT")
        fs:SetWordWrap(false)
        fs:SetMaxLines(1)
        fs:SetPoint("LEFT",  row, "LEFT",  autoColLeftOffset,   0)
        fs:SetPoint("RIGHT", row, "RIGHT", -autoColRightOffset, 0)
        cells[col.key] = fs
    end

    row.cells = cells

    -- Optional whole-row click handler. Fires opts.onRowClick(entry,
    -- sortedIndex, rl) when the user clicks anywhere on the row that
    -- isn't a child widget with its own click handler (checkboxes
    -- and action buttons swallow the click before it reaches the row).
    -- OnMouseDown rather than OnMouseUp matches the FGI pattern — the
    -- click feels more responsive.
    if self.onRowClick then
        row:EnableMouse(true)
        local rl = self
        row:SetScript("OnMouseDown", function()
            local idx = (rl.listOffset or 0) + i
            local entry = rl:_getSortedData()[idx]
            if entry then rl.onRowClick(entry, idx, rl) end
        end)
    end

    row:Hide()
    return row
end

-- ---------------------------------------------------------------------------
-- Scrollbar
-- ---------------------------------------------------------------------------
function RowList:_buildScrollbar()
    local sb = CreateFrame("Slider", nil, self.parent)
    sb:SetOrientation("VERTICAL")
    sb:SetPoint("TOPRIGHT",    self.parent, "TOPRIGHT",    -1, -(self.headerHeight + SCROLLBAR_BTN_SIZE))
    sb:SetPoint("BOTTOMRIGHT", self.parent, "BOTTOMRIGHT", -1,  SCROLLBAR_BTN_SIZE)
    sb:SetWidth(SCROLLBAR_WIDTH)
    sb:SetMinMaxValues(0, 0)
    sb:SetValueStep(1)
    sb:SetValue(0)

    local sbBg = sb:CreateTexture(nil, "BACKGROUND")
    sbBg:SetAllPoints(sb)
    sbBg:SetColorTexture(0, 0, 0, 0.4)

    local upBtn = CreateFrame("Button", nil, sb)
    upBtn:SetPoint("BOTTOM", sb, "TOP", 0, 0)
    upBtn:SetSize(SCROLLBAR_BTN_SIZE, SCROLLBAR_BTN_SIZE)
    upBtn:SetNormalTexture("Interface\\Buttons\\UI-ScrollBar-ScrollUpButton-Up")
    upBtn:SetPushedTexture("Interface\\Buttons\\UI-ScrollBar-ScrollUpButton-Down")
    upBtn:SetDisabledTexture("Interface\\Buttons\\UI-ScrollBar-ScrollUpButton-Disabled")
    upBtn:SetHighlightTexture("Interface\\Buttons\\UI-ScrollBar-ScrollUpButton-Highlight", "ADD")
    upBtn:SetScript("OnClick", function() sb:SetValue(sb:GetValue() - 1) end)

    local downBtn = CreateFrame("Button", nil, sb)
    downBtn:SetPoint("TOP", sb, "BOTTOM", 0, 0)
    downBtn:SetSize(SCROLLBAR_BTN_SIZE, SCROLLBAR_BTN_SIZE)
    downBtn:SetNormalTexture("Interface\\Buttons\\UI-ScrollBar-ScrollDownButton-Up")
    downBtn:SetPushedTexture("Interface\\Buttons\\UI-ScrollBar-ScrollDownButton-Down")
    downBtn:SetDisabledTexture("Interface\\Buttons\\UI-ScrollBar-ScrollDownButton-Disabled")
    downBtn:SetHighlightTexture("Interface\\Buttons\\UI-ScrollBar-ScrollDownButton-Highlight", "ADD")
    downBtn:SetScript("OnClick", function() sb:SetValue(sb:GetValue() + 1) end)

    local sbThumb = sb:CreateTexture(nil, "ARTWORK")
    sbThumb:SetSize(SCROLLBAR_WIDTH, 24)
    sbThumb:SetTexture("Interface\\Buttons\\UI-ScrollBar-Knob")
    sb:SetThumbTexture(sbThumb)

    sb:SetScript("OnValueChanged", function(_, val)
        local newOffset = math.floor(val + 0.5)
        if newOffset == self.listOffset then return end
        self.listOffset = newOffset
        self:_renderRows()
    end)
    sb:Hide()
    self.scrollbar = sb
end

-- ---------------------------------------------------------------------------
-- Sorting (caller-driven via header click)
-- ---------------------------------------------------------------------------
function RowList:_getSortedData()
    if not self.sortKey then return self.data end
    if self._dataSorted then return self._dataSorted end

    local sorted = {}
    for i, e in ipairs(self.data) do sorted[i] = e end

    local key, desc = self.sortKey, self.sortDesc
    table.sort(sorted, function(a, b)
        local av, bv = a[key], b[key]
        if av == bv then
            -- Tie-break by name when present, so the visual order is stable.
            local an, bn = a.name, b.name
            if an and bn and an ~= bn then return an < bn end
            return false
        end
        if av == nil then return not desc end
        if bv == nil then return desc end
        if type(av) == "number" and type(bv) == "number" then
            if desc then return av > bv else return av < bv end
        end
        local as, bs = tostring(av), tostring(bv)
        if desc then return as > bs else return as < bs end
    end)

    self._dataSorted = sorted
    return sorted
end

-- ---------------------------------------------------------------------------
-- Public API
-- ---------------------------------------------------------------------------
function RowList:SetData(data)
    self.data        = data or {}
    self.listOffset  = 0
    self._dataSorted = nil
    -- AutoFit pass — measure widest cell across new data for any column
    -- with `autoFit = true`, then reposition every column so the new
    -- widths actually take effect. Skipped when no column opts in.
    self:_autoFitColumns()
    self:_repositionColumns()
    self:Refresh()
end

-- For each column with `autoFit = true`, measure the widest text in the
-- current `self.data` (plus the header label) and grow `col.width` to
-- fit. Honours `col.minWidth` as a floor so columns don't disappear when
-- the dataset is empty / all-blank. A hidden fontstring per font face
-- caches between calls so we don't burn CreateFontString per resize.
function RowList:_autoFitColumns()
    if not self.columns or #self.columns == 0 then return end
    self._measureFS = self._measureFS or {}

    for _, col in ipairs(self.columns) do
        if col.autoFit then
            local fontName = col.font or "GameFontHighlightSmall"
            local fs = self._measureFS[fontName]
            if not fs then
                fs = UIParent:CreateFontString(nil, "ARTWORK", fontName)
                fs:Hide()
                self._measureFS[fontName] = fs
            end

            local maxW = 0
            if self.data then
                for _, entry in ipairs(self.data) do
                    local text
                    if col.format then
                        text = col.format(entry)
                    else
                        local v = entry[col.key]
                        text = (v == nil) and "" or tostring(v)
                    end
                    fs:SetText(text or "")
                    local w = fs:GetStringWidth() or 0
                    if w > maxW then maxW = w end
                end
            end

            -- Header label has to fit too, otherwise the column header
            -- truncates while the cells render fully — looks broken.
            fs:SetText(col.header or "")
            local headerW = fs:GetStringWidth() or 0
            if headerW > maxW then maxW = headerW end

            col.width = math.max(col.minWidth or 0, maxW + AUTOFIT_PADDING)
        end
    end
end

-- After column widths change, update the existing header buttons and
-- every row cell's anchor + width in place. Same two-pass placement
-- logic as _buildHeader / _buildRow so the auto-width column stretches
-- between the new offsets of its left and right neighbours.
function RowList:_repositionColumns()
    if not self._headerLabels then return end

    local autoColIndex
    for ci, col in ipairs(self.columns) do
        if not col.width then autoColIndex = ci; break end
    end

    -- Pass 1 — RIGHT-anchored offsets (right of auto, or all when no auto)
    local rightOffset = COL_GAP
    local rightOffsets = {}
    local rightBound = autoColIndex and (autoColIndex + 1) or 1
    for ci = #self.columns, rightBound, -1 do
        local col = self.columns[ci]
        rightOffsets[ci] = rightOffset
        rightOffset = rightOffset + col.width + COL_GAP + (col.gapBefore or 0)
    end
    local autoColRightOffset = rightOffset

    -- Pass 2 — LEFT-anchored offsets (left of auto)
    local leftOffsets = {}
    local autoColLeftOffset = LEFT_PAD
    if autoColIndex then
        local leftOffset = LEFT_PAD
        for ci = 1, autoColIndex - 1 do
            local col = self.columns[ci]
            leftOffsets[ci] = leftOffset
            leftOffset = leftOffset + col.width + COL_GAP + (col.gapBefore or 0)
        end
        autoColLeftOffset = leftOffset
    end

    -- Apply to header labels
    for ci, col in ipairs(self.columns) do
        local label = self._headerLabels[col.key]
        if label and label.btn then
            local btn = label.btn
            btn:ClearAllPoints()
            if ci == autoColIndex then
                btn:SetPoint("LEFT",  self.header, "LEFT",  autoColLeftOffset,   0)
                btn:SetPoint("RIGHT", self.header, "RIGHT", -autoColRightOffset, 0)
            elseif leftOffsets[ci] then
                btn:SetWidth(col.width)
                btn:SetPoint("LEFT", self.header, "LEFT", leftOffsets[ci], 0)
            elseif rightOffsets[ci] then
                btn:SetWidth(col.width)
                btn:SetPoint("RIGHT", self.header, "RIGHT", -rightOffsets[ci], 0)
            end
        end
    end

    -- Apply to all row cells (including hidden ones in the pool). Checkbox
    -- cells need special treatment: the CheckButton's size is fixed at
    -- boxSize and it's centred within the column slot — calling SetWidth
    -- on a CheckButton would stretch the box, not slot it. Mirror the
    -- centring logic from _buildRow for those.
    for _, row in ipairs(self.rows or {}) do
        if row.cells then
            for ci, col in ipairs(self.columns) do
                local cell = row.cells[col.key]
                if cell then
                    cell:ClearAllPoints()
                    if ci == autoColIndex then
                        cell:SetPoint("LEFT",  row, "LEFT",  autoColLeftOffset,   0)
                        cell:SetPoint("RIGHT", row, "RIGHT", -autoColRightOffset, 0)
                    elseif col.checkbox then
                        local boxSize = col.boxSize or 16
                        if leftOffsets[ci] then
                            cell:SetPoint("LEFT", row, "LEFT",
                                leftOffsets[ci] + (col.width - boxSize) / 2, 0)
                        elseif rightOffsets[ci] then
                            cell:SetPoint("RIGHT", row, "RIGHT",
                                -(rightOffsets[ci] + (col.width - boxSize) / 2), 0)
                        end
                    elseif leftOffsets[ci] then
                        if cell.SetWidth then cell:SetWidth(col.width) end
                        cell:SetPoint("LEFT", row, "LEFT", leftOffsets[ci], 0)
                    elseif rightOffsets[ci] then
                        if cell.SetWidth then cell:SetWidth(col.width) end
                        cell:SetPoint("RIGHT", row, "RIGHT", -rightOffsets[ci], 0)
                    end
                end
            end
        end
    end
end

-- Set the active sort column without going through a header click. Call
-- before SetData() to have the initial render appear pre-sorted.
function RowList:SetSort(key, desc)
    self.sortKey     = key
    self.sortDesc    = desc and true or false
    self._dataSorted = nil
    self:_updateHeaderText()
end

-- Detach all child frames from the parent. Call from the host widget's
-- OnRelease so the row pool, header bar, and scrollbar don't bleed into
-- the next addon that reuses the AceGUI widget. Frames stay in memory
-- (Lua table refs are held by self.rows etc.) but are orphaned to UIParent
-- so they no longer render inside the parent.
function RowList:Detach()
    self._detached = true
    local function orphan(f)
        if not f then return end
        f:Hide()
        f:ClearAllPoints()
        f:SetParent(UIParent)
    end
    for _, r in ipairs(self.rows) do orphan(r) end
    orphan(self.header)
    orphan(self.scrollbar)
end

function RowList:_recomputeVisibleRows()
    local h = (self.parent:GetHeight() or 0) - self.headerHeight
    local need = math.max(0, math.floor(h / self.rowHeight))
    if need > #self.rows then
        for i = #self.rows + 1, need do
            self.rows[i] = self:_buildRow(i)
        end
    end
    self.visibleRowCount = need
end

function RowList:_renderRows()
    local data = self:_getSortedData()
    for i = 1, #self.rows do
        local row = self.rows[i]
        if i <= self.visibleRowCount then
            local entry = data[(self.listOffset or 0) + i]
            if entry then
                for _, col in ipairs(self.columns) do
                    local cell = row.cells[col.key]
                    if cell then
                        if col.checkbox then
                            cell:SetChecked(entry[col.key] and true or false)
                        else
                            local text
                            if col.format then
                                text = col.format(entry)
                            else
                                local v = entry[col.key]
                                text = (v == nil) and "" or tostring(v)
                            end
                            cell:SetText(text or "")
                        end
                    end
                end
                row:Show()
            else
                row:Hide()
            end
        else
            row:Hide()
        end
    end
end

function RowList:Refresh()
    -- AceGUI pools widgets across the whole UI; HookScript handlers can't
    -- be unhooked, so the parent's OnSizeChanged hook lives on after this
    -- RowList is replaced. Bail out so a recycled host doesn't trigger a
    -- pool grow that would re-parent rows into the next addon's widget.
    if self._detached then return end
    self:_recomputeVisibleRows()

    local maxOffset = math.max(0, #self.data - self.visibleRowCount)
    if self.listOffset > maxOffset then self.listOffset = maxOffset end

    local sb = self.scrollbar
    if sb then
        sb:SetMinMaxValues(0, maxOffset)
        sb:SetValue(self.listOffset)
        if maxOffset > 0 then sb:Show() else sb:Hide() end
    end

    self:_renderRows()
end
