local addonName, ns = ...

if addonName ~= "NomTools" then
    ns = _G.NomTools
    addonName = ns and ns.ADDON_NAME or "NomTools"
end

if not ns then
    return
end

-- ============================================================================
-- Constants
-- ============================================================================

local FRAME_NAME          = "NomToolsShoppingListFrame"
local EDIT_MODE_LABEL     = "Shopping List"
local CONFIG_KEY          = "shoppingList"
local FRAME_WIDTH_DEFAULT = 260

if ns.SHOPPING_LIST_MAX_CUSTOM_ITEMS == nil then
    ns.SHOPPING_LIST_MAX_CUSTOM_ITEMS = 100
end

if ns.SHOPPING_LIST_MAX_ITEM_IDS_PER_ENTRY == nil then
    ns.SHOPPING_LIST_MAX_ITEM_IDS_PER_ENTRY = 8
end

if ns.SHOPPING_LIST_MAX_CUSTOM_ITEM_ID == nil then
    ns.SHOPPING_LIST_MAX_CUSTOM_ITEM_ID = 2147483647
end

local BLIZZARD_FRAME_BACKDROP = {
    bgFile   = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
    edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
    tile     = true,
    tileSize = 32,
    edgeSize = 16,
    insets   = { left = 4, right = 4, top = 4, bottom = 4 },
}
local BLIZZARD_FRAME_BG_COLOR     = { r = 0, g = 0, b = 0, a = 0.75 }
local BLIZZARD_FRAME_BORDER_COLOR = { r = 0, g = 0, b = 0, a = 1 }

local NOMTOOLS_FRAME_BACKDROP = {
    bgFile   = "Interface\\Buttons\\WHITE8x8",
    edgeFile = "Interface\\Buttons\\WHITE8x8",
    tile     = true,
    tileSize = 1,
    edgeSize = 1,
    insets   = { left = 1, right = 1, top = 1, bottom = 1 },
}

-- ============================================================================
-- State
-- ============================================================================

local shoppingListFrame              = nil
local shoppingListEventFrame         = nil
local shoppingListRefreshTimer       = nil
local shoppingListRegisteredWithEM   = false
local shoppingListEventsRegistered   = false

-- ============================================================================
-- Helper utilities
-- ============================================================================

local function NormalizeColor(color, fallback)
    fallback = fallback or { r = 1, g = 1, b = 1, a = 1 }
    if type(color) ~= "table" then
        return { r = fallback.r, g = fallback.g, b = fallback.b, a = fallback.a }
    end
    return {
        r = tonumber(color.r) or fallback.r,
        g = tonumber(color.g) or fallback.g,
        b = tonumber(color.b) or fallback.b,
        a = tonumber(color.a) or fallback.a,
    }
end

local function Clamp(value, minVal, maxVal, fallback)
    local v = tonumber(value)
    if not v then v = tonumber(fallback) or minVal end
    if v < minVal then return minVal end
    if v > maxVal then return maxVal end
    return v
end

local function NormalizeBorderSize(value, fallback)
    local v = tonumber(value)
    if not v then v = tonumber(fallback) or 1 end
    return math.max(-10, math.min(10, math.floor(math.abs(v) + 0.5) * (v >= 0 and 1 or -1)))
end

local function NormalizeThreshold(value, fallback)
    local threshold = tonumber(value)
    if not threshold or threshold ~= threshold or math.abs(threshold) == math.huge then
        threshold = tonumber(fallback) or 1
    end

    threshold = math.floor(threshold)
    if threshold < 1 then
        return 1
    end

    return math.min(threshold, 2147483647)
end

-- ============================================================================
-- Settings helpers
-- ============================================================================

local function GetSettings()
    return ns.GetShoppingListSettings and ns.GetShoppingListSettings() or { enabled = false }
end

local function IsModuleEnabled(settings)
    local resolved = settings or GetSettings()
    local miscSettings = ns.GetMiscellaneousSettings and ns.GetMiscellaneousSettings() or nil
    if miscSettings and miscSettings.enabled == false then
        return false
    end
    if ns.IsModuleRuntimeEnabled then
        return ns.IsModuleRuntimeEnabled("shoppingList", resolved.enabled)
    end
    return resolved.enabled ~= false
end

local function GetEditModeConfig(layoutName)
    local defaults = ns.DEFAULTS and ns.DEFAULTS.editMode and ns.DEFAULTS.editMode.shoppingList
        or { point = "TOPLEFT", x = 20, y = -400 }
    return ns.GetEditModeConfig and ns.GetEditModeConfig(CONFIG_KEY, defaults, layoutName) or defaults
end

-- ============================================================================
-- Visibility
-- ============================================================================

local function HasAnyEnabledFilters(enabledFilters)
    for _, filter in ipairs(ns.INSTANCE_FILTERS or {}) do
        if enabledFilters[filter.key] then
            return true
        end
    end
    return false
end

local function HasAnyEnabledDisplayItems(settings)
    local items = (settings and settings.items) or {}

    for key, config in pairs(items) do
        if type(key) == "string"
            and key:match("^custom_%d+$")
            and type(config) == "table"
            and config.enabled == true
        then
            return true
        end
    end

    for _, catEntry in ipairs(ns.SHOPPING_LIST_CATALOG or {}) do
        local config = items[catEntry.key]
        if type(config) == "table" and config.enabled == true then
            return true
        end
    end

    return false
end

local function DoesFilterMatchInstance(filter, instanceType, difficultyID)
    if filter.instanceTypes then
        for _, t in ipairs(filter.instanceTypes) do
            if t == "other" then
                local known = instanceType == "party" or instanceType == "raid"
                    or instanceType == "scenario" or instanceType == "pvp" or instanceType == "arena"
                if not known then return true end
            elseif t == instanceType then
                return true
            end
        end
    end
    if filter.difficulties then
        for _, d in ipairs(filter.difficulties) do
            if d == difficultyID then return true end
        end
    end
    return false
end

local function DoesVisibilityMatch(visibility)
    local enabledFilters = visibility and visibility.enabledFilters or {}
    if not HasAnyEnabledFilters(enabledFilters) then
        return false
    end
    local inInstance, instanceType = IsInInstance()
    if not inInstance then
        if enabledFilters.city_rest_area and IsResting() then
            return true
        end
        return enabledFilters.open_world == true
    end
    local _, _, difficultyID = GetInstanceInfo()
    for _, filter in ipairs(ns.INSTANCE_FILTERS or {}) do
        if enabledFilters[filter.key] and DoesFilterMatchInstance(filter, instanceType, difficultyID) then
            return true
        end
    end
    return false
end

local function ShouldShowShoppingList(settings)
    if not ns.db then return false end
    if ns.IsOptionsPreviewActive and ns.IsOptionsPreviewActive("miscellaneous_shopping_list") then
        return true
    end
    local resolved = settings or GetSettings()
    if not IsModuleEnabled(resolved) then return false end
    if not HasAnyEnabledDisplayItems(resolved) then return false end
    if UnitIsDeadOrGhost and UnitIsDeadOrGhost("player") then return false end
    return DoesVisibilityMatch(resolved.visibility)
end

-- ============================================================================
-- Item counting
-- ============================================================================

local function GetBagCountForIDs(itemIDs)
    local total = 0
    for _, itemID in ipairs(itemIDs or {}) do
        total = total + (GetItemCount(itemID, false) or 0)
    end
    return total
end

local function GetBankCountForIDs(itemIDs)
    local total = 0
    for _, itemID in ipairs(itemIDs or {}) do
        local withAll = 0
        if C_Item and C_Item.GetItemCount then
            withAll = C_Item.GetItemCount(itemID, true, false, true) or 0
        else
            withAll = GetItemCount(itemID, true) or 0
        end
        local bagsOnly = GetItemCount(itemID, false) or 0
        total = total + math.max(0, withAll - bagsOnly)
        if C_Bank and C_Bank.GetItemCountForBankType then
            local ok, count = pcall(C_Bank.GetItemCountForBankType, "Account", itemID)
            if ok and type(count) == "number" and count > 0 then
                total = total + count
            end
        end
    end
    return total
end

local function CountEntry(entry, includeBank)
    local bagCount  = GetBagCountForIDs(entry.itemIDs)
    local bankCount = includeBank and GetBankCountForIDs(entry.itemIDs) or 0
    return bagCount, bankCount
end

-- ============================================================================
-- Display entry list
-- ============================================================================

local displayEntries    = {}
local displayEntryCount = 0

local function BuildDisplayEntries(settings)
    displayEntryCount = 0
    local items = (settings and settings.items) or {}
    local maxCustomItems = tonumber(ns.SHOPPING_LIST_MAX_CUSTOM_ITEMS) or 100
    local maxItemIDsPerEntry = tonumber(ns.SHOPPING_LIST_MAX_ITEM_IDS_PER_ENTRY) or 8

    for _, catEntry in ipairs(ns.SHOPPING_LIST_CATALOG or {}) do
        local config = items[catEntry.key]
        if type(config) == "table" and config.enabled == true then
            displayEntryCount = displayEntryCount + 1
            local e = displayEntries[displayEntryCount]
            if not e then
                e = {}
                displayEntries[displayEntryCount] = e
            end
            e.key       = catEntry.key
            e.label     = catEntry.label
            e.itemIDs   = catEntry.itemIDs
            e.threshold = NormalizeThreshold(config and config.threshold, catEntry.defaultThreshold or 1)
            e.isCustom  = false
        end
    end

    local customKeys = {}
    local customKeyCount = 0
    for key, config in pairs(items) do
        if type(key) == "string"
            and type(config) == "table"
            and key:find("^custom_")
            and config.enabled == true
            and tonumber(key:match("^custom_(%d+)$"))
        then
            customKeyCount = customKeyCount + 1
            customKeys[customKeyCount] = key
            if customKeyCount >= maxCustomItems then
                break
            end
        end
    end

    local customCount = customKeyCount
    table.sort(customKeys, function(leftKey, rightKey)
        local leftID = tonumber(leftKey and leftKey:match("^custom_(%d+)$")) or 0
        local rightID = tonumber(rightKey and rightKey:match("^custom_(%d+)$")) or 0
        return leftID < rightID
    end)

    for customIndex = 1, customCount do
        local key = customKeys[customIndex]
        local config = items[key]
        local customItemID = tonumber(key:match("^custom_(%d+)$"))
        if customItemID then
            local itemIDs = {}
            local itemIDCount = 0
            local seenItemIDs = {}
            if type(config.itemIDs) == "table" then
                for _, rawItemID in ipairs(config.itemIDs) do
                    local numericItemID = tonumber(rawItemID)
                    if numericItemID
                        and numericItemID > 0
                        and numericItemID == math.floor(numericItemID)
                        and not seenItemIDs[numericItemID]
                    then
                        itemIDCount = itemIDCount + 1
                        itemIDs[itemIDCount] = numericItemID
                        seenItemIDs[numericItemID] = true
                        if itemIDCount >= maxItemIDsPerEntry then
                            break
                        end
                    end
                end
            end
            if itemIDCount == 0 then
                itemIDs[1] = customItemID
            end
            local label = config.label
            if not label then
                local cachedName = GetItemInfo(customItemID)
                label = cachedName or ("Item #" .. customItemID)
            end
            displayEntryCount = displayEntryCount + 1
            local e = displayEntries[displayEntryCount]
            if not e then
                e = {}
                displayEntries[displayEntryCount] = e
            end
            e.key       = key
            e.label     = label
            e.itemIDs   = itemIDs
            e.threshold = NormalizeThreshold(config.threshold, 1)
            e.isCustom  = true
        end
    end
end

-- ============================================================================
-- Preview data
-- ============================================================================

local PREVIEW_DATA = {
    { label = "Utility Drums",              itemIDs = { 244640, 244641 }, threshold = 2, bagPreview = 1, bankPreview = 0 },
    { label = "Utility Consumable 240094",  itemIDs = { 240094, 240133 }, threshold = 1, bagPreview = 0, bankPreview = 2 },
    { label = "Utility Consumable 240154",  itemIDs = { 240154, 240155 }, threshold = 1, bagPreview = 1, bankPreview = 1 },
    { label = "Void-Touched Augment Rune",  itemIDs = { 259085 }, threshold = 3, bagPreview = 0, bankPreview = 1 },
}

-- ============================================================================
-- Row pool
-- ============================================================================

local rowPool      = {}
local activeRows   = 0

local function EnsureRow(index, parent, iconSize)
    local row = rowPool[index]
    if not row then
        row = {}
        row.frame = CreateFrame("Frame", nil, parent)

        row.iconButton = CreateFrame("Button", nil, row.frame)
        row.iconButton:SetPoint("LEFT", row.frame, "LEFT", 0, 0)
        local iconTex = row.iconButton:CreateTexture(nil, "ARTWORK")
        iconTex:SetAllPoints(row.iconButton)
        iconTex:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        row.iconTexture = iconTex

        row.nameText = row.frame:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
        row.nameText:SetJustifyH("LEFT")

        row.countText = row.frame:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
        row.countText:SetJustifyH("RIGHT")

        row.iconButton:SetScript("OnEnter", function(self)
            if row.tooltipItemID then
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                GameTooltip:SetItemByID(row.tooltipItemID)
                GameTooltip:Show()
            end
        end)
        row.iconButton:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)

        rowPool[index] = row
    end

    row.frame:SetParent(parent)
    row.frame:SetHeight(iconSize)
    row.iconButton:SetSize(iconSize, iconSize)

    row.nameText:ClearAllPoints()
    row.nameText:SetPoint("LEFT", row.iconButton, "RIGHT", 4, 0)

    row.countText:ClearAllPoints()
    row.countText:SetPoint("RIGHT", row.frame, "RIGHT", 0, 0)

    row.nameText:SetPoint("RIGHT", row.countText, "LEFT", -4, 0)

    return row
end

local function HideExtraRows(count)
    for i = count + 1, #rowPool do
        if rowPool[i] and rowPool[i].frame then
            rowPool[i].frame:Hide()
        end
    end
end

-- ============================================================================
-- Frame construction
-- ============================================================================

local function EnsureShoppingListFrame()
    if shoppingListFrame then return end

    shoppingListFrame = CreateFrame("Frame", FRAME_NAME, UIParent, "BackdropTemplate")
    shoppingListFrame:SetSize(FRAME_WIDTH_DEFAULT, 60)
    shoppingListFrame:SetFrameStrata("MEDIUM")
    shoppingListFrame:SetClampedToScreen(true)
    shoppingListFrame:EnableMouse(false)

    shoppingListFrame:SetBackdrop(BLIZZARD_FRAME_BACKDROP)
    shoppingListFrame:SetBackdropColor(
        BLIZZARD_FRAME_BG_COLOR.r, BLIZZARD_FRAME_BG_COLOR.g,
        BLIZZARD_FRAME_BG_COLOR.b, BLIZZARD_FRAME_BG_COLOR.a
    )
    shoppingListFrame:SetBackdropBorderColor(
        BLIZZARD_FRAME_BORDER_COLOR.r, BLIZZARD_FRAME_BORDER_COLOR.g,
        BLIZZARD_FRAME_BORDER_COLOR.b, BLIZZARD_FRAME_BORDER_COLOR.a
    )

    local bgTex = shoppingListFrame:CreateTexture(nil, "BACKGROUND")
    bgTex:SetPoint("TOPLEFT",     shoppingListFrame, "TOPLEFT",     1, -1)
    bgTex:SetPoint("BOTTOMRIGHT", shoppingListFrame, "BOTTOMRIGHT", -1, 1)
    bgTex:SetTexture("Interface\\Buttons\\WHITE8x8")
    bgTex:SetVertexColor(0, 0, 0, 1)
    bgTex:SetAlpha(0.85)
    bgTex:Hide()
    shoppingListFrame.backgroundTexture = bgTex

    local borderFrame = CreateFrame("Frame", nil, shoppingListFrame, "BackdropTemplate")
    borderFrame:SetFrameLevel((shoppingListFrame:GetFrameLevel() or 0) + 1)
    borderFrame:Hide()
    shoppingListFrame.borderFrame = borderFrame

    if ns.AttachEditModeSelectionProxy then
        ns.AttachEditModeSelectionProxy(shoppingListFrame)
    end

    ns.shoppingListFrame = shoppingListFrame
    shoppingListFrame.editModeName = EDIT_MODE_LABEL

    local cfg = GetEditModeConfig()
    shoppingListFrame:ClearAllPoints()
    shoppingListFrame:SetPoint(
        cfg.point or "TOPLEFT", UIParent, cfg.point or "TOPLEFT",
        cfg.x or 20, cfg.y or -400
    )

    shoppingListFrame:Hide()
end

-- ============================================================================
-- Edit Mode registration
-- ============================================================================

local function ApplyEditModeLayout(layoutName)
    if not shoppingListFrame then return end
    local cfg = GetEditModeConfig(layoutName)
    shoppingListFrame:ClearAllPoints()
    shoppingListFrame:SetPoint(
        cfg.point or "TOPLEFT", UIParent, cfg.point or "TOPLEFT",
        cfg.x or 20, cfg.y or -400
    )
end

local function RegisterShoppingListWithEditMode()
    if shoppingListRegisteredWithEM or not shoppingListFrame or not ns.RegisterEditModeFrame then
        return
    end
    local defaults = GetEditModeConfig()
    shoppingListRegisteredWithEM = ns.RegisterEditModeFrame(shoppingListFrame, {
        label    = EDIT_MODE_LABEL,
        defaults = { point = defaults.point, x = defaults.x, y = defaults.y },
        applyLayout = ApplyEditModeLayout,
        onPositionChanged = function(layoutName, point, x, y)
            local cfg = GetEditModeConfig(layoutName)
            cfg.point = point
            cfg.x     = x
            cfg.y     = y
            ApplyEditModeLayout(layoutName)
        end,
    }) == true
end

-- ============================================================================
-- Appearance
-- ============================================================================

local function ApplyShoppingListAppearance(settings)
    if not shoppingListFrame then return end
    local appearance = (settings and settings.appearance) or {}
    local preset     = appearance.preset or "nomtools"

    if preset == "nomtools" then
        local s       = appearance.nomtools or {}
        local bgColor = NormalizeColor(s.backgroundColor, { r = 0, g = 0, b = 0, a = 1 })
        local bdColor = NormalizeColor(s.borderColor,    { r = 0, g = 0, b = 0, a = 1 })
        local opacity = Clamp(s.backgroundOpacity, 0, 100, 85) / 100
        local bsz     = NormalizeBorderSize(s.borderSize, 1)
        local texPath = ns.GetStatusBarTexturePath
            and ns.GetStatusBarTexturePath(s.texture or ns.GLOBAL_CHOICE_KEY)
            or "Interface\\Buttons\\WHITE8x8"

        shoppingListFrame:SetBackdrop(NOMTOOLS_FRAME_BACKDROP)
        shoppingListFrame:SetBackdropColor(0, 0, 0, 0)
        shoppingListFrame:SetBackdropBorderColor(0, 0, 0, 0)

        shoppingListFrame.backgroundTexture:SetTexture(texPath)
        shoppingListFrame.backgroundTexture:SetVertexColor(bgColor.r, bgColor.g, bgColor.b, 1)
        shoppingListFrame.backgroundTexture:SetAlpha(opacity)
        shoppingListFrame.backgroundTexture:Show()

        if bsz ~= 0 then
            local bdKey  = s.borderTexture or ns.GLOBAL_CHOICE_KEY
            local bdDef  = ns.GetBorderTextureDefinition and ns.GetBorderTextureDefinition(bdKey) or nil
            local edgeSz = math.max(1, math.abs(bsz))
            local inset  = math.max(0, math.floor(edgeSz / 4))
            local edgePth = (bdDef and bdDef.path) or "Interface\\Buttons\\WHITE8x8"

            local bf = shoppingListFrame.borderFrame
            bf:ClearAllPoints()
            if bsz > 0 then
                bf:SetPoint("TOPLEFT",     shoppingListFrame, "TOPLEFT",     -0, 0)
                bf:SetPoint("BOTTOMRIGHT", shoppingListFrame, "BOTTOMRIGHT",  0, 0)
            else
                bf:SetPoint("TOPLEFT",     shoppingListFrame, "TOPLEFT",     0, 0)
                bf:SetPoint("BOTTOMRIGHT", shoppingListFrame, "BOTTOMRIGHT", 0, 0)
            end
            bf:SetBackdrop({
                edgeFile = edgePth,
                tile     = true,
                tileSize = 8,
                edgeSize = edgeSz,
                insets   = { left = inset, right = inset, top = inset, bottom = inset },
            })
            bf:SetBackdropColor(0, 0, 0, 0)
            bf:SetBackdropBorderColor(bdColor.r, bdColor.g, bdColor.b, bdColor.a)
            bf:Show()
        else
            shoppingListFrame.borderFrame:Hide()
        end
    else
        -- blizzard preset
        shoppingListFrame:SetBackdrop(BLIZZARD_FRAME_BACKDROP)
        shoppingListFrame:SetBackdropColor(
            BLIZZARD_FRAME_BG_COLOR.r, BLIZZARD_FRAME_BG_COLOR.g,
            BLIZZARD_FRAME_BG_COLOR.b, BLIZZARD_FRAME_BG_COLOR.a
        )
        shoppingListFrame:SetBackdropBorderColor(
            BLIZZARD_FRAME_BORDER_COLOR.r, BLIZZARD_FRAME_BORDER_COLOR.g,
            BLIZZARD_FRAME_BORDER_COLOR.b, BLIZZARD_FRAME_BORDER_COLOR.a
        )
        shoppingListFrame.backgroundTexture:Hide()
        shoppingListFrame.borderFrame:Hide()
    end
end

-- ============================================================================
-- Display update
-- ============================================================================

local function UpdateShoppingListDisplay(settings)
    if not shoppingListFrame then return end

    local appearance  = (settings and settings.appearance) or {}
    local preset      = appearance.preset or "blizzard"
    local specApp     = appearance[preset] or {}
    local includeBank = settings and settings.includeBank ~= false
    local hideOverThreshold = settings and settings.hideOverThreshold ~= false

    local fontPath    = ns.GetFontPath
        and ns.GetFontPath(specApp.font or ns.GLOBAL_CHOICE_KEY)
        or "Fonts\\FRIZQT__.TTF"
    local fontFlags   = ns.GetFontOutlineFlags
        and ns.GetFontOutlineFlags(specApp.fontOutline or ns.GLOBAL_CHOICE_KEY)
        or "OUTLINE"
    local fontSize    = math.floor(Clamp(specApp.fontSize, 6, 48, 12) + 0.5)
    local iconSize    = math.floor(Clamp(specApp.iconSize, 8, 64, 24) + 0.5)
    local rowSpacing  = math.floor(Clamp(specApp.rowSpacing, 0, 32, 4) + 0.5)
    local paddingX    = math.floor(Clamp(specApp.paddingX, 0, 32, 10) + 0.5)
    local paddingY    = math.floor(Clamp(specApp.paddingY, 0, 32, 8) + 0.5)

    local normalColor   = NormalizeColor(specApp.normalColor,   { r = 1, g = 1, b = 1, a = 1 })
    local lowStockColor = NormalizeColor(specApp.lowStockColor, { r = 1, g = 0.3, b = 0.3, a = 1 })

    if preset == "blizzard" then
        iconSize   = 24
        rowSpacing = 4
        paddingX   = 10
        paddingY   = 8
    end

    -- Determine entries (preview or real)
    local isPreview = ns.IsOptionsPreviewActive and ns.IsOptionsPreviewActive("miscellaneous_shopping_list")
    local entries
    local entryCount

    if isPreview then
        entries    = PREVIEW_DATA
        entryCount = #PREVIEW_DATA
    else
        BuildDisplayEntries(settings)
        entries    = displayEntries
        entryCount = displayEntryCount
    end

    if entryCount == 0 then
        shoppingListFrame:Hide()
        return false
    end

    local yOffset = -paddingY
    local visibleRowCount = 0

    for i = 1, entryCount do
        local e = entries[i]
        local bagCount, bankCount
        if isPreview then
            bagCount  = e.bagPreview  or 0
            bankCount = includeBank and (e.bankPreview or 0) or 0
        else
            bagCount, bankCount = CountEntry(e, includeBank)
        end

        local ownedCount = includeBank and (bagCount + bankCount) or bagCount
        local threshold = NormalizeThreshold(e.threshold, 1)
        local isLow     = ownedCount < threshold

        -- Apply threshold filter
        if hideOverThreshold and ownedCount > threshold then
        else
            local rowIndex = visibleRowCount + 1
            local row = EnsureRow(rowIndex, shoppingListFrame, iconSize)

            -- Icon
            local firstID = e.itemIDs and e.itemIDs[1]
            if firstID then
                local icon = GetItemIcon and GetItemIcon(firstID) or "Interface\\Icons\\INV_Misc_QuestionMark"
                row.iconTexture:SetTexture(icon)
                row.tooltipItemID = firstID
            else
                row.iconTexture:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
                row.tooltipItemID = nil
            end

            -- Name
            local nameColor = isLow and lowStockColor or normalColor
            row.nameText:SetTextColor(nameColor.r, nameColor.g, nameColor.b, nameColor.a)
            row.nameText:SetFont(fontPath, fontSize, fontFlags)
            row.nameText:SetText(e.label or "?")

            -- Count: "bags / threshold" or "total (bags+bank) / threshold"
            local countStr
            if includeBank and bankCount > 0 then
                countStr = string.format("%d (%d+%d) / %d",
                    bagCount + bankCount, bagCount, bankCount, threshold)
            else
                countStr = string.format("%d / %d", bagCount, threshold)
            end
            local countColor = isLow and lowStockColor or normalColor
            row.countText:SetTextColor(countColor.r, countColor.g, countColor.b, countColor.a)
            row.countText:SetFont(fontPath, fontSize, fontFlags)
            row.countText:SetText(countStr)

            -- Position row
            row.frame:ClearAllPoints()
            row.frame:SetPoint("TOPLEFT", shoppingListFrame, "TOPLEFT", paddingX, yOffset)
            row.frame:SetPoint("RIGHT",   shoppingListFrame, "RIGHT",   -paddingX, 0)
            row.frame:SetHeight(iconSize)
            row.frame:Show()

            yOffset = yOffset - iconSize - rowSpacing
            visibleRowCount = rowIndex
        end
    end

    HideExtraRows(visibleRowCount)
    activeRows = visibleRowCount

    if visibleRowCount == 0 then
        shoppingListFrame:Hide()
        return false
    end

    local totalHeight = paddingY * 2
        + visibleRowCount * iconSize
        + (visibleRowCount > 1 and (visibleRowCount - 1) * rowSpacing or 0)
    shoppingListFrame:SetHeight(math.max(totalHeight, 20))

    return true
end

-- ============================================================================
-- Events
-- ============================================================================

local SHOPPING_LIST_EVENTS = {
    "BAG_UPDATE_DELAYED",
    "BANKFRAME_OPENED",
    "PLAYER_ENTERING_WORLD",
    "PLAYERBANKSLOTS_CHANGED",
    "ZONE_CHANGED_NEW_AREA",
    "ZONE_CHANGED",
    "PLAYER_UPDATE_RESTING",
}

local function OnShoppingListEvent()
    if shoppingListRefreshTimer then return end
    shoppingListRefreshTimer = true
    C_Timer.After(0.2, function()
        shoppingListRefreshTimer = nil
        if ns.RefreshShoppingListUI then
            ns.RefreshShoppingListUI()
        end
    end)
end

local function UpdateShoppingListEventRegistration(enabled)
    if not shoppingListEventFrame then
        shoppingListEventFrame = CreateFrame("Frame")
        shoppingListEventFrame:SetScript("OnEvent", OnShoppingListEvent)
    end
    if enabled and not shoppingListEventsRegistered then
        for _, event in ipairs(SHOPPING_LIST_EVENTS) do
            shoppingListEventFrame:RegisterEvent(event)
        end
        shoppingListEventsRegistered = true
    elseif not enabled and shoppingListEventsRegistered then
        shoppingListEventFrame:UnregisterAllEvents()
        shoppingListEventsRegistered = false
    end
end

-- ============================================================================
-- Custom item management
-- ============================================================================

function ns.AddShoppingListCustomItem(itemID)
    local numericID = tonumber(itemID)
    local maxCustomItemID = tonumber(ns.SHOPPING_LIST_MAX_CUSTOM_ITEM_ID) or 2147483647
    if not numericID
        or numericID ~= numericID
        or math.abs(numericID) == math.huge
        or numericID <= 0
        or numericID > maxCustomItemID
        or numericID ~= math.floor(numericID)
    then
        return false
    end

    local id = math.floor(numericID)
    local key = "custom_" .. id
    local settings = ns.GetShoppingListSettings and ns.GetShoppingListSettings() or nil
    if type(settings) ~= "table" then
        return false
    end

    if type(settings.items) ~= "table" then
        settings.items = {}
    end

    local items = settings.items
    local existing = items[key]
    if type(existing) == "table" then
        if type(existing.itemIDs) ~= "table" or #existing.itemIDs == 0 then
            existing.itemIDs = { id }
        end
        if existing.threshold == nil then
            existing.threshold = 1
        end
        existing.enabled = true
        existing.custom = true
        return true
    end

    local maxCustomItems = tonumber(ns.SHOPPING_LIST_MAX_CUSTOM_ITEMS) or 100
    local customCount = 0
    for itemKey in pairs(items) do
        if type(itemKey) == "string" and itemKey:find("^custom_%d+$") then
            customCount = customCount + 1
            if customCount >= maxCustomItems then
                return false
            end
        end
    end

    local config = ns.GetShoppingListItemConfig and ns.GetShoppingListItemConfig(key)
    if type(config) ~= "table" then
        return false
    end

    if type(config.itemIDs) ~= "table" or #config.itemIDs == 0 then
        config.itemIDs = { id }
    end
    config.enabled = true
    config.custom = true
    return true
end

function ns.RemoveShoppingListCustomItem(key)
    if type(key) ~= "string" then return end
    if not key:match("^custom_%d+$") then return end
    local settings = ns.GetShoppingListSettings and ns.GetShoppingListSettings()
    if settings and type(settings.items) == "table" then
        settings.items[key] = nil
    end
end

-- ============================================================================
-- Public interface
-- ============================================================================

function ns.InitializeShoppingListUI()
    EnsureShoppingListFrame()
    RegisterShoppingListWithEditMode()
    UpdateShoppingListEventRegistration(IsModuleEnabled(GetSettings()))
end

function ns.RefreshShoppingListUI()
    EnsureShoppingListFrame()
    RegisterShoppingListWithEditMode()

    local settings = GetSettings()
    local enabled  = IsModuleEnabled(settings)
    UpdateShoppingListEventRegistration(enabled)

    local shouldShow = ShouldShowShoppingList(settings)

    if not shouldShow and not ns.isEditMode then
        shoppingListFrame:Hide()
        return
    end

    shoppingListFrame:EnableMouse(ns.isEditMode == true)
    ApplyShoppingListAppearance(settings)
    local hasDisplayEntries = UpdateShoppingListDisplay(settings)
    if hasDisplayEntries == false and not ns.isEditMode then
        shoppingListFrame:Hide()
        return
    end
    ApplyEditModeLayout()
    shoppingListFrame:Show()
end
