-- =============================================================================
-- ETB v12.61.0 — UI/MainFrame/ItemGrid.lua
-- =============================================================================
-- Fourth MainFrame submodule. Owns the item-grid data pipeline: loading
-- pages, paginating, async name resolution, rendering rows, and updating
-- collection statistics.
--
-- This is the largest functional cluster in MainFrame: 10 methods spanning
-- ~390 lines of pagination, cache management, async item-info loading, and
-- row rendering. Heavy logic, not UI construction (no CreateXxx methods
-- here — those are in the upcoming Construction cluster).
--
-- Public API (all take owner = MainFrame singleton as first arg):
--   ItemGrid:RefreshItems(owner)
--   ItemGrid:LoadPageData(owner, pageNum)
--   ItemGrid:PreloadFirstPageItemInfo(owner)
--   ItemGrid:RequestItemInfoAsync(owner)
--   ItemGrid:ClearAllItemRows(owner)
--   ItemGrid:UpdateDisplayWithRefresh(owner)
--   ItemGrid:UpdateDisplay(owner)
--   ItemGrid:UpdatePendingItemsForCurrentPage(owner)
--   ItemGrid:UpdateChangedRows(owner)
--   ItemGrid:UpdateStats(owner)
--
-- Owner contract — fields read/written:
--   owner.frame                       (with .pageText, .itemCountText, .prevBtn,
--                                      .nextBtn, .collectionStats, .statsOverall,
--                                      .statsSlot, .statsShowing)
--   owner.items                       (current page's item list)
--   owner._filteredItems              (full filtered+sorted list across all pages)
--   owner.itemRows                    (reusable row pool)
--   owner.currentPage, .itemsPerPage  (pagination state)
--   owner.totalItemCount              (length of _filteredItems)
--   owner.lastPageLoaded              (last page LoadPageData processed)
--
-- Owner methods called (delegators in main file):
--   owner:ClearAllItemRows()              (this cluster, self-call OK)
--   owner:UpdateDisplay()                 (this cluster, self-call OK)
--   owner:UpdateStats()                   (this cluster, self-call OK)
--   owner:LoadPageData(pageNum)           (this cluster, self-call OK)
--   owner:PreloadFirstPageItemInfo()      (this cluster, self-call OK)
--   owner:RequestItemInfoAsync()          (this cluster, self-call OK)
--   owner:UpdateCurrentViewingLabel(name) (LivePreview cluster)
--
-- Globals/Blizzard:
--   GetItemInfo, GetItemInfoInstant, wipe, collectgarbage
--   ETB.Filtering, ETB.BlizzardData, ETB.ArmorData, ETB.WeaponData,
--   ETB.ItemRow, ETB.ModelPreview, ETB.CATEGORY_INFO
-- =============================================================================

local ADDON_NAME, NS = ...
local ETB = NS.ETB

NS.ETB.MainFrame = NS.ETB.MainFrame or ETB.MainFrame or {}
local ItemGrid = {}
NS.ETB.MainFrame.ItemGrid = ItemGrid

-- =============================================================================
-- REFRESH — full reload after category/filter change
-- =============================================================================

local function sliceCurrentPage(owner)
    local per = owner.itemsPerPage
    local startIdx = (owner.currentPage - 1) * per + 1
    local endIdx = startIdx + per - 1

    owner.items = {}
    for i = startIdx, math.min(endIdx, #(owner._filteredItems or {})) do
        if owner._filteredItems[i] then
            local item = owner._filteredItems[i]
            if item.itemID and ETB.BlizzardData and ETB.BlizzardData.itemCache then
                local cached = ETB.BlizzardData.itemCache:Get(item.itemID)
                if cached then item = cached end
            end
            owner.items[#owner.items + 1] = item
        end
    end
    owner.lastPageLoaded = owner.currentPage
end

function ItemGrid:_RefreshItemsSyncLegacy(owner)
    if not owner.frame then return end

    -- Hide hover preview when changing categories/refreshing
    if ETB.ModelPreview then
        ETB.ModelPreview:Hide()
    end

    -- Free old data before allocating new
    if owner._filteredItems then
        wipe(owner._filteredItems)
        owner._filteredItems = nil
    end
    owner.items = {}
    owner.lastPageLoaded = 0

    -- Clear all ItemRow data
    owner:ClearAllItemRows()

    -- Keep base category caches warm across ordinary filter/sort refreshes.
    -- Collection changes still invalidate through ETB:OnCollectionUpdated().
    if ETB.BlizzardData and ETB.BlizzardData.TrimSourceCache then
        ETB.BlizzardData:TrimSourceCache()
    end

    -- Load ALL items for category, filter, and sort — then paginate from result.
    owner._filteredItems = {}
    owner.totalItemCount = #owner._filteredItems

    -- Restore this slot's last selection (if any) so the gold highlight persists
    -- when the player returns to a slot — and land on the page that holds it.
    owner.currentPage = 1
    owner.selectedGlobalIndex = nil
    ItemGrid:ResolveSavedSelection(owner)

    -- Slice the (possibly resumed) page
    local per = owner.itemsPerPage
    local startIdx = (owner.currentPage - 1) * per + 1
    local endIdx = startIdx + per - 1

    owner.items = {}
    for i = startIdx, math.min(endIdx, #owner._filteredItems) do
        if owner._filteredItems[i] then
            local item = owner._filteredItems[i]
            if item.itemID and ETB.BlizzardData and ETB.BlizzardData.itemCache then
                local cached = ETB.BlizzardData.itemCache:Get(item.itemID)
                if cached then item = cached end
            end
            owner.items[#owner.items + 1] = item
        end
    end

    owner.lastPageLoaded = owner.currentPage
    collectgarbage("step", 100)

    owner:UpdateDisplay()
    owner:UpdateStats()
end

-- Low-hitch refresh path: the later definition intentionally replaces the
-- original synchronous implementation above.
function ItemGrid:RefreshItems(owner)
    if not owner.frame then return end

    if ETB.ModelPreview then
        ETB.ModelPreview:Hide()
    end

    local signature = ETB.Filtering and ETB.Filtering.GetSignature and ETB.Filtering:GetSignature()
    if owner._filteredItems ~= nil and owner._filterSignature == signature and not owner._loadingItems then
        owner.lastPageLoaded = 0
        owner:LoadPageData(owner.currentPage or 1)
        owner:UpdateStats()
        return
    end

    owner._refreshSerial = (owner._refreshSerial or 0) + 1
    local serial = owner._refreshSerial
    owner._loadingItems = true
    owner._filterSignature = nil

    if owner._filteredItems then wipe(owner._filteredItems) end
    owner._filteredItems = nil
    owner.items = {}
    owner.lastPageLoaded = 0
    owner.totalItemCount = 0
    owner.currentPage = 1
    owner.selectedGlobalIndex = nil

    owner:ClearAllItemRows()
    owner:UpdateDisplay()
    if owner.frame.itemCountText then owner.frame.itemCountText:SetText("Building item list...") end
    if owner.frame.pageText then owner.frame.pageText:SetText("Loading...") end
    if owner.frame.collectionStats then owner.frame.collectionStats:SetText("Loading appearances...") end
    if owner.frame.statsShowing then owner.frame.statsShowing:SetText("Showing: loading") end

    if ETB.BlizzardData and ETB.BlizzardData.TrimSourceCache then
        ETB.BlizzardData:TrimSourceCache()
    end

    local function cancelled()
        return owner._refreshSerial ~= serial
            or not owner.frame
            or (owner.frame.IsShown and not owner.frame:IsShown())
    end

    local function finish(items)
        if cancelled() then return end
        owner._loadingItems = false
        owner._filteredItems = items or {}
        owner._filterSignature = signature
        owner.totalItemCount = #owner._filteredItems
        owner.currentPage = 1
        owner.selectedGlobalIndex = nil

        ItemGrid:ResolveSavedSelection(owner)
        sliceCurrentPage(owner)
        collectgarbage("step", 100)

        owner:UpdateDisplay()
        owner:UpdateStats()

        if C_Timer and C_Timer.After then
            C_Timer.After(0.05, function()
                if not cancelled() and not owner._loadingItems then
                    owner:PreloadFirstPageItemInfo()
                end
            end)
            -- Safety net: names resolve asynchronously after the build. Normally the
            -- GET_ITEM_INFO_RECEIVED -> UpdateDisplay path repaints them, but if that
            -- chain hiccups the rows can sit on "Loading..." forever. A couple of
            -- deferred repaints guarantee the visible page picks up resolved names
            -- (each row reads the live GetItemInfo, so this is a cheap ~page-sized pass).
            for _, delay in ipairs({ 0.6, 1.5, 3.0 }) do
                C_Timer.After(delay, function()
                    if not cancelled() and not owner._loadingItems
                       and owner.frame and owner.frame:IsShown() then
                        owner:UpdateDisplay()
                    end
                end)
            end
        else
            owner:PreloadFirstPageItemInfo()
        end
    end

    local function progress(phase, done, total)
        owner._lastProgressAt = (GetTime and GetTime()) or 0
        if cancelled() or not owner.frame or not owner.frame.itemCountText then return end
        if phase == "building" and total and total > 1 then
            owner.frame.itemCountText:SetText(string.format("Building: %d/%d", done or 0, total))
        elseif phase == "grouping" and total and total > 1 then
            owner.frame.itemCountText:SetText(string.format("Grouping: %d/%d", done or 0, total))
        elseif phase == "filtering" and total and total > 1 then
            owner.frame.itemCountText:SetText(string.format("Filtering: %d/%d", done or 0, total))
        elseif phase == "sorting" then
            owner.frame.itemCountText:SetText("Sorting...")
        elseif phase == "cached" then
            owner.frame.itemCountText:SetText("Using cached list...")
        end
    end

    if ETB.Filtering and ETB.Filtering.GetItemsAsync then
        local budgetMs = (ETB.GetFrameWorkBudget and ETB:GetFrameWorkBudget()) or 1.5
        owner._lastProgressAt = (GetTime and GetTime()) or 0
        ETB.Filtering:GetItemsAsync({ limit = 100000, budgetMs = budgetMs }, {
            cancelled = cancelled,
            onProgress = progress,
            onComplete = finish,
        })
        -- Watchdog: the async builder drives itself through the shared work queue,
        -- which can pause (combat, moving) and silently drops a job if its step
        -- errors — either of which would leave the grid "Loading..." forever. If no
        -- progress lands for a few seconds while still loading, fall back to the
        -- synchronous build so the grid always populates.
        if C_Timer and C_Timer.After then
            local stalls = 0
            local function watchdog()
                if cancelled() or not owner._loadingItems then return end
                local idle = ((GetTime and GetTime()) or 0) - (owner._lastProgressAt or 0)
                if idle >= 3 then
                    stalls = stalls + 1
                    owner._lastProgressAt = (GetTime and GetTime()) or 0
                    if owner.frame and owner.frame.itemCountText then
                        owner.frame.itemCountText:SetText("Waiting for background work...")
                    end
                    if ETB.EnsureWorkPump then ETB:EnsureWorkPump() end
                    -- Bounded last resort: if the pump still hasn't produced anything
                    -- after several re-kicks and we're safely out of combat, build once
                    -- so the grid can't be permanently dead (the original "gear never
                    -- loads" bug). A brief one-time hitch out of combat beats an empty
                    -- browser forever. In combat we keep waiting — never hitch there.
                    local inCombat = (InCombatLockdown and InCombatLockdown())
                        or (UnitAffectingCombat and UnitAffectingCombat("player"))
                    if stalls >= 3 and not inCombat then
                        local items = (ETB.Filtering and ETB.Filtering.GetItems
                            and ETB.Filtering:GetItems({ limit = 100000 })) or {}
                        finish(items)
                        return
                    end
                else
                    stalls = 0  -- progress resumed; reset the stall counter
                end
                C_Timer.After(1, watchdog)
            end
            C_Timer.After(3, watchdog)
        end
    else
        if owner.frame and owner.frame.itemCountText then
            owner.frame.itemCountText:SetText("Async filtering unavailable")
        end
        finish({})
    end
end

-- =============================================================================
-- PAGINATION — load a specific page from cached _filteredItems
-- =============================================================================

function ItemGrid:_LoadPageDataSyncLegacy(owner, pageNum)
    if not owner.frame then return end
    if owner.lastPageLoaded == pageNum and #owner.items > 0 then
        return
    end

    owner.items = {}
    owner.lastPageLoaded = pageNum

    -- Use cached filtered results if available, otherwise reload
    if not owner._filteredItems or #owner._filteredItems == 0 then
        owner._filteredItems = {}
        owner.totalItemCount = #owner._filteredItems
    end

    local startIdx = (pageNum - 1) * owner.itemsPerPage + 1
    local endIdx = startIdx + owner.itemsPerPage - 1

    for i = startIdx, math.min(endIdx, #owner._filteredItems) do
        if owner._filteredItems[i] then
            local item = owner._filteredItems[i]
            if item.itemID and ETB.BlizzardData and ETB.BlizzardData.itemCache then
                local cached = ETB.BlizzardData.itemCache:Get(item.itemID)
                if cached then
                    item = cached
                end
            end
            owner.items[#owner.items + 1] = item
        end
    end

    owner:UpdateDisplay()

    if pageNum == 1 then
        owner:PreloadFirstPageItemInfo()
    else
        owner:RequestItemInfoAsync()
    end
end

function ItemGrid:LoadPageData(owner, pageNum)
    if not owner.frame then return end
    if owner._loadingItems then return end

    pageNum = pageNum or owner.currentPage or 1
    if owner.lastPageLoaded == pageNum and #owner.items > 0 then
        return
    end

    owner.currentPage = pageNum
    owner.items = {}

    if owner._filteredItems == nil then
        owner:RefreshItems()
        return
    end

    sliceCurrentPage(owner)
    owner:UpdateDisplay()

    if owner.currentPage == 1 then
        owner:PreloadFirstPageItemInfo()
    else
        owner:RequestItemInfoAsync()
    end
end

-- =============================================================================
-- ASYNC NAME RESOLUTION
-- =============================================================================

function ItemGrid:PreloadFirstPageItemInfo(owner)
    -- Pre-load item info for first page - try both GetItemInfoInstant and GetItemInfo
    if not ETB.BlizzardData then return end

    local updated = false
    local processed = 0
    local maxPerLoad = 8

    for i, item in ipairs(owner.items) do
        if item and item.itemID and processed < maxPerLoad then
            -- Get the cached version to ensure we're updating the right object
            local cached = ETB.BlizzardData.itemCache and ETB.BlizzardData.itemCache:Get(item.itemID)
            if cached then
                item = cached  -- Use cached version
                owner.items[i] = cached  -- Update reference in items array
            end

            -- Check if we need to load the name
            if not item.name or item.name == "Loading..." or item.name == "Unknown" or type(item.name) == "number" then
                local name, link, quality, itemLevel, minLevel, itemType, itemSubType, stackCount, equipLoc, texture

                -- Use instant metadata only; missing names are queued below.
                name = nil

                -- Try GetItemInfoInstant for icon only
                -- NOTE: GetItemInfoInstant returns (itemID, itemType, itemSubType, equipLoc, icon, classID, subclassID)
                -- It does NOT return name/link/quality — different signature from GetItemInfo!
                if GetItemInfoInstant then
                    local _, _, _, instEquipLoc, instTexture = GetItemInfoInstant(item.itemID)
                    -- Only use for icon and equipLoc, NOT for name/quality
                    texture = instTexture
                    equipLoc = instEquipLoc
                end

                -- If we got a name, update immediately
                if name and name ~= "" then
                    item.name = name
                    if type(quality) == "number" then item.quality = quality end
                    item.icon = texture or item.icon
                    item.invType = equipLoc or item.invType
                    updated = true
                else
                    -- Request async loading through the shared throttle.
                    ETB.BlizzardData.pendingItemInfo[item.itemID] = true
                    if ETB.RequestItemInfoThrottled then
                        ETB:RequestItemInfoThrottled(item.itemID)
                    else
                        GetItemInfo(item.itemID)
                    end
                end
                processed = processed + 1
            end
        end
    end

    -- If we got instant results, update display immediately
    if updated then
        owner:UpdateDisplay()
    end

    -- Also try to update any async items that might have loaded
    local asyncUpdated = false
    for _ = 1, 5 do  -- More passes to catch items
        if ETB.BlizzardData:UpdatePendingItems() then
            asyncUpdated = true
        end
    end
    if asyncUpdated then
        -- Refresh item references from cache before updating display
        for i, item in ipairs(owner.items) do
            if item and item.itemID and ETB.BlizzardData.itemCache then
                local cached = ETB.BlizzardData.itemCache:Get(item.itemID)
                if cached then
                    owner.items[i] = cached
                end
            end
        end
        owner:UpdateDisplay()
    end
end

function ItemGrid:RequestItemInfoAsync(owner)
    -- Request item info async for all items on the current page
    if not ETB.BlizzardData then return end

    for _, item in ipairs(owner.items) do
        if item and item.itemID and
           (not item.name or item.name == "Loading..." or item.name == "Unknown" or type(item.name) == "number") then
            ETB.BlizzardData.pendingItemInfo[item.itemID] = true
            if ETB.RequestItemInfoThrottled then
                ETB:RequestItemInfoThrottled(item.itemID)
            else
                GetItemInfo(item.itemID)
            end
        end
    end

    -- Try to update items immediately if available
    local updated = false

    for _, item in ipairs(owner.items) do
        if item and item.itemID and ETB.BlizzardData.pendingItemInfo[item.itemID] then
            if ETB.BlizzardData:UpdateItemInfo(item.itemID) then
                ETB.BlizzardData.pendingItemInfo[item.itemID] = nil
                updated = true
            end
        end
    end

    if updated then
        owner:UpdateDisplay()
    end
end

-- =============================================================================
-- ROW MANAGEMENT
-- =============================================================================

function ItemGrid:ClearAllItemRows(owner)
    -- Clear itemData from all rows to free memory
    for _, row in ipairs(owner.itemRows) do
        if row then
            row.itemData = nil
            row.index = nil
            row:Hide()
        end
    end
end

function ItemGrid:UpdateDisplayWithRefresh(owner)
    -- Force clear old data first
    owner:ClearAllItemRows()

    -- Load new page data
    owner:LoadPageData(owner.currentPage)

    -- Display is already updated in LoadPageData if items were updated
    -- Just ensure display is current
    owner:UpdateDisplay()
end

-- ============================================================
-- Row selection + arrow-key navigation (list + 3D grid)
-- ============================================================
--
-- Selection is remembered PER SLOT (keyed by category mode + category id) by the
-- selected item's itemID, so the gold highlight persists when the player leaves a
-- slot and returns to it — and returning lands on the page that holds it.
--
-- Arrow keys are captured via OVERRIDE BINDINGS on just the four arrow keys while
-- the browser is shown (WASD movement is untouched; SetPropagateKeyboardInput is
-- protected on this client, and a plain keyboard frame doesn't reliably preempt
-- the movement bindings — override bindings do). Cleared when the frame hides.

-- A stable key for "which slot are we looking at" so each slot keeps its own pick.
local function viewKey(owner)
    local cat = ETB.Filtering and ETB.Filtering.filters and ETB.Filtering.filters.category
    return tostring(owner.categoryMode or "?") .. "#" .. tostring(cat or "?")
end

-- Selection highlight for LIST rows (distinct from the mouse-hover highlight):
-- a soft gold fill marking the row currently on the Live Preview.
local function ensureSelTex(row)
    if row._selTex then return row._selTex end
    local t = row:CreateTexture(nil, "ARTWORK")
    t:SetAllPoints()
    t:SetColorTexture(1, 0.82, 0, 0.22)
    t:Hide()
    row._selTex = t
    return t
end

function ItemGrid:HighlightSelectedRow(owner)
    if not owner.itemRows then return end
    local sel = owner.selectedGlobalIndex
    for _, row in ipairs(owner.itemRows) do
        local t = ensureSelTex(row)
        if sel and row:IsShown() and row.index == sel and row.itemData then
            t:Show()
        else
            t:Hide()
        end
    end
end

-- Paint the selection in whichever view is active (list rows or 3D cells).
function ItemGrid:RepaintSelection(owner)
    local MG = ETB.MainFrame and ETB.MainFrame.ModelGrid
    if owner.viewMode == "model" and MG and MG.HighlightSelected then
        MG:HighlightSelected(owner)
    else
        self:HighlightSelectedRow(owner)
    end
end

-- Restore this slot's remembered selection after a re-filter: find the saved
-- itemID in the freshly built _filteredItems and set selectedGlobalIndex +
-- currentPage so the player lands back on the highlighted item.
function ItemGrid:ResolveSavedSelection(owner)
    owner.selBySlot = owner.selBySlot or {}
    local savedID = owner.selBySlot[viewKey(owner)]
    if not savedID or not owner._filteredItems then return end
    for i, it in ipairs(owner._filteredItems) do
        if it.itemID == savedID then
            owner.selectedGlobalIndex = i
            owner.currentPage = math.floor((i - 1) / (owner.itemsPerPage or 19)) + 1
            return
        end
    end
end

-- Select the item at a GLOBAL index (1..totalItemCount): flip pages if needed,
-- remember it for this slot, repaint the highlight, optionally push to Live Preview.
function ItemGrid:SelectGlobalIndex(owner, gi, applyPreview)
    local total = owner.totalItemCount or 0
    if total <= 0 then return end
    if gi < 1 then gi = 1 elseif gi > total then gi = total end

    local per = owner.itemsPerPage or 19
    local page = math.floor((gi - 1) / per) + 1
    if page ~= owner.currentPage then
        owner.currentPage = page
        owner:LoadPageData(page)
        owner:UpdateDisplay()
    end

    owner.selectedGlobalIndex = gi

    local pageLocal = gi - (page - 1) * per
    local item = owner.items and owner.items[pageLocal]
    if item and item.itemID then
        owner.selBySlot = owner.selBySlot or {}
        owner.selBySlot[viewKey(owner)] = item.itemID  -- persist this slot's pick
    end

    self:RepaintSelection(owner)

    if applyPreview and item and ETB.MainFrame and ETB.MainFrame.AddToLivePreview then
        ETB.MainFrame:AddToLivePreview(item)
    end
end

-- Move selection by a direction. List view: Up/Down = ±1 item, Left/Right = ±1
-- page. 3D grid: Up/Down = ±one row (COLS), Left/Right = ±1 cell. First press with
-- no active selection just seeds the first item on the current page.
function ItemGrid:MoveSelection(owner, dir)
    local total = owner.totalItemCount or 0
    if total <= 0 then return end
    local per = owner.itemsPerPage or 19

    if not owner.selectedGlobalIndex then
        self:SelectGlobalIndex(owner, (owner.currentPage - 1) * per + 1, true)
        return
    end

    local delta
    if owner.viewMode == "model" then
        local cols = (ETB.MainFrame and ETB.MainFrame.ModelGrid and ETB.MainFrame.ModelGrid.COLS) or 5
        if dir == "up" then delta = -cols
        elseif dir == "down" then delta = cols
        elseif dir == "left" then delta = -1
        elseif dir == "right" then delta = 1 end
    else
        if dir == "up" then delta = -1
        elseif dir == "down" then delta = 1
        elseif dir == "left" then delta = -per
        elseif dir == "right" then delta = per end
    end

    if delta then self:SelectGlobalIndex(owner, owner.selectedGlobalIndex + delta, true) end
end

-- Arrow-key capture via override bindings (set while shown, cleared on hide).
-- Only the four arrow keys are rebound, so character movement (WASD) is untouched.
function ItemGrid:EnsureArrowNav(owner)
    if owner._navReady then return end
    local frame = owner.frame
    if not frame then return end
    owner._navReady = true

    -- Named, template-less buttons (names are required for SetOverrideBindingClick;
    -- no template means no auto-created secure child frames).
    local function mk(name, dir)
        local b = CreateFrame("Button", name, frame)
        b:RegisterForClicks("AnyDown")
        b:SetScript("OnClick", function() ItemGrid:MoveSelection(owner, dir) end)
        return b
    end
    mk("ETBNavUp", "up");   mk("ETBNavDown", "down")
    mk("ETBNavLeft", "left"); mk("ETBNavRight", "right")

    local function apply()
        if InCombatLockdown() then return end  -- override bindings are protected in combat
        ClearOverrideBindings(frame)
        SetOverrideBindingClick(frame, true, "UP",    "ETBNavUp")
        SetOverrideBindingClick(frame, true, "DOWN",  "ETBNavDown")
        SetOverrideBindingClick(frame, true, "LEFT",  "ETBNavLeft")
        SetOverrideBindingClick(frame, true, "RIGHT", "ETBNavRight")
    end
    local function clear()
        if InCombatLockdown() then return end
        ClearOverrideBindings(frame)
    end

    frame:HookScript("OnShow", apply)
    frame:HookScript("OnHide", clear)

    -- Combat safety: never touch bindings during combat. When combat ends, sync to
    -- the current shown state (covers a show/hide that happened mid-combat).
    local ev = CreateFrame("Frame")
    ev:RegisterEvent("PLAYER_REGEN_ENABLED")
    ev:SetScript("OnEvent", function()
        if frame:IsShown() then apply() else clear() end
    end)

    if frame:IsShown() then apply() end
end

function ItemGrid:UpdateDisplay(owner)
    if not owner.frame then return end

    local startIdx = (owner.currentPage - 1) * owner.itemsPerPage + 1

    -- Refresh item references from cache before displaying to ensure we have latest data
    if ETB.BlizzardData and ETB.BlizzardData.itemCache then
        for i, item in ipairs(owner.items) do
            if item and item.itemID then
                local cached = ETB.BlizzardData.itemCache:Get(item.itemID)
                if cached then
                    owner.items[i] = cached  -- Use cached version with latest data
                end
            end
        end
    end

    -- Update visible rows and clear unused ones
    if owner.viewMode == "model" and ETB.MainFrame and ETB.MainFrame.ModelGrid then
        -- Model view: hide all rows, render the per-cell 3D grid instead.
        for _, row in ipairs(owner.itemRows) do
            row.itemData = nil
            row.index = nil
            row:Hide()
        end
        ETB.MainFrame.ModelGrid:Render(owner)
    else
        for i, row in ipairs(owner.itemRows) do
            local item = owner.items[i]  -- Items array now only contains current page
            if item then
                ETB.ItemRow:Update(row, item, startIdx + i - 1)
                row:Show()
            else
                -- Clear data from unused rows
                row.itemData = nil
                row.index = nil
                row:Hide()
            end
        end
    end

    local totalPages = math.max(1, math.ceil(owner.totalItemCount / owner.itemsPerPage))
    owner.frame.pageText:SetText(string.format("Page %d / %d", owner.currentPage, totalPages))
    owner.frame.itemCountText:SetText(string.format("%d Appearances", owner.totalItemCount))
    owner.frame.prevBtn:SetEnabled(owner.currentPage > 1)
    owner.frame.nextBtn:SetEnabled(owner.currentPage < totalPages)

    -- Arrow-key navigation + re-paint the selection highlight after rows
    -- re-render (page change, refresh, sort, etc.).
    self:EnsureArrowNav(owner)
    self:RepaintSelection(owner)

    -- Collection stats
    if owner.frame.collectionStats then
        local collected = 0
        for _, item in ipairs(owner.items) do
            if item and item.isCollected then collected = collected + 1 end
        end
        local shown = #owner.items
        if shown > 0 then
            local pct = math.floor(100 * collected / owner.totalItemCount + 0.5)
            owner.frame.collectionStats:SetText(string.format(
                "|cff00ff00%d|r|cff888888 / %d collected (%d%%)|r", collected, owner.totalItemCount, pct))
        else
            owner.frame.collectionStats:SetText("")
        end
    end
end

function ItemGrid:UpdatePendingItemsForCurrentPage(owner)
    -- Only update items that are on the current page to reduce work
    if not ETB.BlizzardData or not ETB.BlizzardData.pendingItemInfo then
        return false
    end

    local updated = false
    local processed = 0
    local maxPerTick = 5  -- Limit to 5 items per tick to avoid lag

    -- Check only items on current page
    for _, item in ipairs(owner.items) do
        if item and item.itemID and ETB.BlizzardData.pendingItemInfo[item.itemID] then
            if ETB.BlizzardData:UpdateItemInfo(item.itemID) then
                ETB.BlizzardData.pendingItemInfo[item.itemID] = nil
                updated = true
                processed = processed + 1
                if processed >= maxPerTick then
                    break  -- Don't process too many at once
                end
            end
        end
    end

    return updated
end

function ItemGrid:UpdateChangedRows(owner)
    -- Only update rows whose items have changed, not all rows
    if not owner.frame then return end

    local startIdx = (owner.currentPage - 1) * owner.itemsPerPage + 1

    for i, row in ipairs(owner.itemRows) do
        local item = owner.items[i]
        if item then
            -- Check if this row needs updating
            local needsUpdate = false
            if not row.itemData or row.itemData.itemID ~= item.itemID then
                -- Different item or no item data
                needsUpdate = true
            elseif row.itemData.name ~= item.name then
                -- Name changed
                needsUpdate = true
            end

            if needsUpdate then
                ETB.ItemRow:Update(row, item, startIdx + i - 1)
                row:Show()
            end
        elseif row:IsShown() then
            -- Item removed, hide row
            row.itemData = nil
            row.index = nil
            row:Hide()
        end
    end
end

-- =============================================================================
-- STATS — collection counts in bottom bar + sidebar
-- =============================================================================

function ItemGrid:_UpdateStatsSyncLegacy(owner)
    if not owner.frame then return end

    -- Overall stats
    local allStats = ETB.BlizzardData:GetAllStats()
    local overallElement = owner.frame.statsOverall
    if overallElement then
        overallElement:SetText(string.format("|cffffd100Overall:|r %d/%d (%.1f%%)",
            allStats.collected, allStats.total, allStats.percent))
    end

    -- Current slot stats
    local categoryID = ETB.Filtering.filters.category
    local slotStats = ETB.BlizzardData:GetCollectionStats(categoryID)
    local catInfo = ETB.CATEGORY_INFO[categoryID]
    local slotElement = owner.frame.statsSlot
    if slotElement then
        slotElement:SetText(string.format("|cffffd100%s:|r %d/%d (%.1f%%)",
            catInfo and catInfo.name or "Slot",
            slotStats.collected, slotStats.total, slotStats.percent))
    end

    -- Current view count: collected-of-filtered ratio (Feature 1.5)
    local showingElement = owner.frame.statsShowing
    if showingElement then
        local total = owner.totalItemCount or 0
        if total == 0 then
            showingElement:SetText("Showing: 0 items")
        else
            local collectedCount = 0
            if owner._filteredItems then
                for i = 1, #owner._filteredItems do
                    if owner._filteredItems[i].isCollected then
                        collectedCount = collectedCount + 1
                    end
                end
            end
            local pct = total > 0 and (collectedCount / total * 100) or 0
            showingElement:SetText(string.format(
                "Showing: |cff00ff00%d|r/|cffffd100%d|r collected (%.1f%%)",
                collectedCount, total, pct))
        end
    end

    -- Update the "Currently Viewing" label too
    owner:UpdateCurrentViewingLabel(catInfo and catInfo.name or "Unknown")
end

function ItemGrid:ScheduleOverallStats(owner)
    if owner._overallStatsPending or not ETB.BlizzardData then return end
    owner._overallStatsPending = true

    local categoryIDs = {}
    for categoryID in pairs(ETB.CATEGORY_INFO or {}) do
        categoryIDs[#categoryIDs + 1] = categoryID
    end
    table.sort(categoryIDs)

    local index = 1
    local total, collected = 0, 0
    local byCategory = {}
    local serial = owner._refreshSerial or 0

    local function step()
        if owner._refreshSerial ~= serial or not owner.frame
            or (owner.frame.IsShown and not owner.frame:IsShown())
        then
            owner._overallStatsPending = nil
            return
        end

        local categoryID = categoryIDs[index]
        if categoryID then
            local stats = ETB.BlizzardData:GetCollectionStats(categoryID)
            byCategory[categoryID] = stats
            total = total + (stats.total or 0)
            collected = collected + (stats.collected or 0)
            index = index + 1

            if C_Timer and C_Timer.After then
                C_Timer.After(0.03, step)
            else
                step()
            end
            return
        end

        owner._overallStats = {
            total = total,
            collected = collected,
            percent = total > 0 and (collected / total * 100) or 0,
            byCategory = byCategory,
        }
        owner._overallStatsPending = nil
        if owner.frame and owner.frame:IsShown() then
            owner:UpdateStats()
        end
    end

    step()
end

function ItemGrid:UpdateStats(owner)
    if not owner.frame then return end

    local categoryID = ETB.Filtering.filters.category
    local catInfo = ETB.CATEGORY_INFO[categoryID]

    if owner._loadingItems then
        if owner.frame.statsOverall then owner.frame.statsOverall:SetText("|cffffd100Overall:|r loading") end
        if owner.frame.statsSlot then owner.frame.statsSlot:SetText("|cffffd100Slot:|r loading") end
        if owner.frame.statsShowing then owner.frame.statsShowing:SetText("Showing: loading") end
        owner:UpdateCurrentViewingLabel(catInfo and catInfo.name or "Unknown")
        return
    end

    local overallElement = owner.frame.statsOverall
    if overallElement then
        if ETB.IsLowMemoryMode and ETB:IsLowMemoryMode() then
            if owner._overallStats then
                overallElement:SetText(string.format("|cffffd100Overall:|r %d/%d (%.1f%%)",
                    owner._overallStats.collected, owner._overallStats.total, owner._overallStats.percent))
            else
                overallElement:SetText("|cffffd100Overall:|r calculating")
                ItemGrid:ScheduleOverallStats(owner)
            end
        else
            local allStats = ETB.BlizzardData:GetAllStats()
            overallElement:SetText(string.format("|cffffd100Overall:|r %d/%d (%.1f%%)",
                allStats.collected, allStats.total, allStats.percent))
        end
    end

    local slotStats = ETB.BlizzardData:GetCollectionStats(categoryID)
    local slotElement = owner.frame.statsSlot
    if slotElement then
        slotElement:SetText(string.format("|cffffd100%s:|r %d/%d (%.1f%%)",
            catInfo and catInfo.name or "Slot",
            slotStats.collected, slotStats.total, slotStats.percent))
    end

    local showingElement = owner.frame.statsShowing
    if showingElement then
        local total = owner.totalItemCount or 0
        if total == 0 then
            showingElement:SetText("Showing: 0 items")
        else
            local collectedCount = 0
            if owner._filteredItems then
                for i = 1, #owner._filteredItems do
                    if owner._filteredItems[i].isCollected then
                        collectedCount = collectedCount + 1
                    end
                end
            end
            local pct = total > 0 and (collectedCount / total * 100) or 0
            showingElement:SetText(string.format(
                "Showing: |cff00ff00%d|r/|cffffd100%d|r collected (%.1f%%)",
                collectedCount, total, pct))
        end
    end

    owner:UpdateCurrentViewingLabel(catInfo and catInfo.name or "Unknown")
end
