-- 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
-- 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.data            = {}
    self.listOffset      = 0
    self.rows            = {}
    self.visibleRowCount = 0

    -- 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

function RowList:_makeHeaderColumn(parent, col, anchorRight, autoLeftPad)
    local btn = CreateFrame("Button", nil, parent)
    btn:EnableMouse(true)
    btn:SetHeight(HEADER_HEIGHT)
    if autoLeftPad then
        btn:SetPoint("LEFT",  parent, "LEFT",  autoLeftPad,  0)
        btn:SetPoint("RIGHT", parent, "RIGHT", anchorRight, 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_)
            if addon.Tooltip and addon.Tooltip.Owner then
                addon.Tooltip.Owner(self_)
            else
                GameTooltip:SetOwner(self_, "ANCHOR_TOPLEFT")
            end
            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)

    local rightOffset = COL_GAP
    local autoColIndex

    for ci = #self.columns, 1, -1 do
        local col = self.columns[ci]
        if col.width then
            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)
        else
            autoColIndex = ci
        end
    end

    if autoColIndex then
        local col = self.columns[autoColIndex]
        self:_makeHeaderColumn(h, col, -rightOffset, LEFT_PAD)
    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

    local cells = {}
    local rightOffset = COL_GAP
    local autoColIndex

    for ci = #self.columns, 1, -1 do
        local col = self.columns[ci]
        if col.width then
            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)
            fs:SetPoint("RIGHT", row, "RIGHT", -rightOffset, 0)
            cells[col.key] = fs
            rightOffset = rightOffset + col.width + COL_GAP + (col.gapBefore or 0)
        else
            autoColIndex = ci
        end
    end

    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",  LEFT_PAD,     0)
        fs:SetPoint("RIGHT", row, "RIGHT", -rightOffset, 0)
        cells[col.key] = fs
    end

    row.cells = cells
    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
    self:Refresh()
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
                        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
                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
