local _, privateDB = ...
local HDG = _G.HousingDecorGuide
local L = HDG_L or {}
local CONFIG

-- ============================================================================
-- ZONE MODE MODULE
-- Standalone lightweight popup for zone vendor alerts
-- ============================================================================

HDG_ZoneMode = {}

-- State
local currentZoneMapID = nil
local currentZoneVendors = nil
local expandedVendors = {}
local showCollected = false
local searchFilter = ""

-- Popup
local popup = nil
local gearPopup = nil
local rows = {}
local activeRowCount = 0
local pendingRefreshTimer = nil
local RefreshIfShowing -- forward declaration

-- Constants
local POP_W = 300
local MAX_CONTENT_H = 420
local TITLE_H = 28
local FOOTER_H = 30
local ROW_H = 22
local ICON_SZ = 16
local VENDOR_INDENT = 6
local ITEM_INDENT = 24
local PAD = 8

function HDG_ZoneMode.GetCurrentMapID()
    return currentZoneMapID
end

-- ============================================================================
-- HELPERS
-- ============================================================================

local function GetConfig(key)
    return HDG.Store and HDG.Store:GetState().config[key]
end

-- ============================================================================
-- ALL-ITEMS ZONE QUERY (collected + uncollected)
-- ============================================================================

local function GetAllVendorsInZone(uiMapID)
    if not uiMapID or not HDG_VendorDB or not HDG_VendorByMapID[uiMapID] then return nil end
    local pTag = HDG_VendorResolver.GetPlayerFactionTag and HDG_VendorResolver.GetPlayerFactionTag()
        or (UnitFactionGroup("player") == "Horde" and "H" or "A")
    local result = {}
    for _, npcID in ipairs(HDG_VendorByMapID[uiMapID]) do
        local ve = HDG_VendorDB[npcID]
        if ve and ve.items then
            local vFaction = ve[6] or "N"
            if vFaction == "N" or vFaction == pTag then
                local items = {}
                for itemID, itemEntry in pairs(ve.items) do
                    local collected = HDG_Data and HDG_Data.IsItemCollected(itemID) or false
                    items[#items + 1] = { itemID = itemID, goldCost = itemEntry[1] or 0, collected = collected }
                end
                if #items > 0 then
                    table.insert(result, {
                        npcID = npcID,
                        name = HDG_VendorResolver.GetVendorNameByID(npcID),
                        zone = ve[2] or "Unknown",
                        mapID = ve[3] or 0, x = ve[4] or 0, y = ve[5] or 0,
                        uncollectedItems = items, -- reuse field name for compat
                    })
                end
            end
        end
    end
    return #result > 0 and result or nil
end

-- ============================================================================
-- FILTERING
-- ============================================================================

-- Filter out unresolved and unreleased items; only keep items with real names
local function FilterVendors(rawVendors)
    local out = {}
    local hasLoading = false
    for _, vendor in ipairs(rawVendors) do
        local items = {}
        for _, ui in ipairs(vendor.uncollectedItems) do
            local name = HDG_Data and HDG_Data.GetCachedItemName(ui.itemID)
            if not name or name == "Loading..." then
                hasLoading = true
            elseif name ~= "Unreleased" then
                items[#items + 1] = ui
            end
        end
        if #items > 0 then
            out[#out + 1] = {
                npcID = vendor.npcID, name = vendor.name, zone = vendor.zone,
                mapID = vendor.mapID, x = vendor.x, y = vendor.y,
                uncollectedItems = items,
            }
        end
    end
    return out, hasLoading
end

-- ============================================================================
-- ROW FRAME CACHE (no pooling -- simple create/reuse)
-- ============================================================================

local function HideAllRows()
    for i = 1, activeRowCount do
        if rows[i] then rows[i]:Hide() end
    end
    activeRowCount = 0
end

local function GetRow(parent, idx)
    if rows[idx] then
        local r = rows[idx]
        r:Show()
        r:SetScript("OnClick", nil)
        r:SetScript("OnEnter", nil)
        r:SetScript("OnLeave", nil)
        r.icon:Hide()
        r.arrow:Hide()
        r.badge:SetText("")
        r.rightText:SetText("")
        return r
    end

    local r = CreateFrame("Button", nil, parent, "BackdropTemplate")
    r:SetHeight(ROW_H)
    r:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })
    r:SetBackdropColor(0, 0, 0, 0)

    r.arrow = r:CreateTexture(nil, "OVERLAY")
    r.arrow:SetSize(10, 10)
    r.arrow:SetPoint("LEFT", 2, 0)
    r.arrow:Hide()

    r.icon = r:CreateTexture(nil, "ARTWORK")
    r.icon:SetSize(ICON_SZ, ICON_SZ)
    r.icon:SetPoint("LEFT", 4, 0)
    r.icon:Hide()

    r.text = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    r.text:SetJustifyH("LEFT")
    r.text:SetWordWrap(false)

    r.badge = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    r.badge:SetJustifyH("LEFT")

    r.rightText = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    r.rightText:SetPoint("RIGHT", -4, 0)
    r.rightText:SetJustifyH("RIGHT")

    rows[idx] = r
    return r
end

-- ============================================================================
-- POPUP CREATION
-- ============================================================================

local function RefreshPopup() end  -- forward declaration

local function CreatePopup()
    popup = CreateFrame("Frame", "HDG_ZonePopup", UIParent, "BackdropTemplate")
    popup:SetWidth(POP_W)
    local saved = HDG.Store and HDG.Store:GetState().layout.zonePopup
    if saved then
        popup:SetPoint(saved.point, UIParent, saved.relPoint, saved.x, saved.y)
    else
        popup:SetPoint("RIGHT", UIParent, "RIGHT", -40, 50)
    end
    popup:SetFrameStrata("DIALOG")
    popup:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Buttons\\WHITE8X8",
        edgeSize = 1,
    })
    popup:SetBackdropColor(unpack(CONFIG.COLORS.PANEL_POPUP))
    popup:SetBackdropBorderColor(unpack(CONFIG.COLORS.PANEL_DARK_BORDER_COOL))
    popup:SetMovable(true)
    popup:EnableMouse(true)
    popup:RegisterForDrag("LeftButton")
    popup:SetScript("OnDragStart", popup.StartMoving)
    popup:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        local point, _, relPoint, x, y = self:GetPoint(1)
        HDG.Store:UpdateLayout("zonePopup", { point = point, relPoint = relPoint, x = x, y = y })
    end)
    popup:SetClampedToScreen(true)

    -- Title
    popup.title = popup:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    popup.title:SetPoint("TOPLEFT", PAD, -6)
    popup.title:SetWidth(POP_W - 62)
    popup.title:SetJustifyH("LEFT")
    popup.title:SetWordWrap(false)

    -- Close button
    local close = CreateFrame("Button", nil, popup, "UIPanelCloseButton")
    close:SetSize(20, 20)
    close:SetPoint("TOPRIGHT", -2, -2)
    close:SetScript("OnClick", function() popup:Hide() end)

    -- Gear button (zone scanner settings)
    local gear = CreateFrame("Button", nil, popup)
    gear:SetSize(18, 18)
    gear:SetPoint("RIGHT", close, "LEFT", -2, 0)
    gear:SetNormalAtlas("decor-controls-settings-default")
    gear:SetHighlightAtlas("decor-controls-settings-default")
    gear:SetScript("OnClick", function()
        if not gearPopup then gearPopup = HDG_ZoneMode.CreateGearPopup(gear) end
        gearPopup:SetShown(not gearPopup:IsShown())
    end)
    gear:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(L["TIP_ZONE_SCANNER_SETTINGS"], 1, 1, 1)
        GameTooltip:Show()
    end)
    gear:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- Subtitle
    popup.subtitle = popup:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    popup.subtitle:SetPoint("TOPLEFT", PAD, -24)
    popup.subtitle:SetWidth(POP_W - PAD * 2)
    popup.subtitle:SetJustifyH("LEFT")

    -- Separator
    local sep = popup:CreateTexture(nil, "ARTWORK")
    local bc = CONFIG.COLORS.PANEL_DARK_BORDER_COOL
    sep:SetColorTexture(bc[1], bc[2], bc[3], 0.5)
    sep:SetHeight(1)
    sep:SetPoint("TOPLEFT", PAD, -38)
    sep:SetPoint("TOPRIGHT", -PAD, -38)

    -- Search bar + eye toggle row
    local TOOLBAR_Y = -40
    local TOOLBAR_H = 22

    popup.searchBox = CreateFrame("EditBox", nil, popup, "BackdropTemplate")
    popup.searchBox:SetHeight(TOOLBAR_H)
    popup.searchBox:SetPoint("TOPLEFT", PAD, TOOLBAR_Y)
    popup.searchBox:SetPoint("RIGHT", popup, "RIGHT", -(PAD + 44), 0)
    popup.searchBox:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
    popup.searchBox:SetBackdropColor(0.08, 0.10, 0.13, 0.9)
    popup.searchBox:SetBackdropBorderColor(bc[1], bc[2], bc[3], 0.6)
    popup.searchBox:SetFontObject(GameFontNormalSmall)
    popup.searchBox:SetTextColor(0.89, 0.91, 0.94)
    popup.searchBox:SetTextInsets(6, 20, 0, 0)
    popup.searchBox:SetAutoFocus(false)
    popup.searchBox:SetMaxLetters(30)
    popup.searchBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    popup.searchBox:SetScript("OnTextChanged", function(self)
        searchFilter = self:GetText():lower()
        RefreshPopup()
    end)

    -- Search placeholder
    popup.searchPlaceholder = popup.searchBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    popup.searchPlaceholder:SetPoint("LEFT", 6, 0)
    popup.searchPlaceholder:SetTextColor(0.5, 0.5, 0.5)
    popup.searchPlaceholder:SetText("Search...")
    popup.searchBox:SetScript("OnEditFocusGained", function() popup.searchPlaceholder:Hide() end)
    popup.searchBox:SetScript("OnEditFocusLost", function(self)
        if self:GetText() == "" then popup.searchPlaceholder:Show() end
    end)

    -- Clear button (X) inside search
    local clearBtn = CreateFrame("Button", nil, popup.searchBox)
    clearBtn:SetSize(12, 12)
    clearBtn:SetPoint("RIGHT", -4, 0)
    clearBtn:SetNormalAtlas("128-RedButton-x")
    clearBtn:SetHighlightAtlas("128-RedButton-x")
    clearBtn:SetScript("OnClick", function()
        popup.searchBox:SetText("")
        popup.searchBox:ClearFocus()
        popup.searchPlaceholder:Show()
    end)

    -- Eye toggle (show collected)
    popup.eyeBtn = CreateFrame("Button", nil, popup)
    popup.eyeBtn:SetSize(40, 40)
    popup.eyeBtn:SetPoint("LEFT", popup.searchBox, "RIGHT", 4, 0)
    local function UpdateEyeAtlas(btn)
        btn:SetNormalAtlas(showCollected and "GM-icon-visible-hover" or "GM-icon-visibleDis-hover")
    end
    UpdateEyeAtlas(popup.eyeBtn)
    popup.eyeBtn:SetHighlightAtlas("GM-icon-visible-hover")
    popup.eyeBtn:SetScript("OnClick", function(self)
        showCollected = not showCollected
        UpdateEyeAtlas(self)
        currentZoneVendors = nil -- force re-query with new mode
        RefreshPopup()
    end)
    popup.eyeBtn:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(showCollected and "Hide collected items" or "Show collected items", 1, 1, 1)
        GameTooltip:Show()
    end)
    popup.eyeBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- Scroll frame
    popup.scroll = CreateFrame("ScrollFrame", nil, popup, "UIPanelScrollFrameTemplate")
    popup.scroll:SetPoint("TOPLEFT", 4, TOOLBAR_Y - TOOLBAR_H - 4)
    popup.scroll:SetPoint("BOTTOMRIGHT", -22, FOOTER_H + 2)

    popup.child = CreateFrame("Frame", nil, popup.scroll)
    popup.child:SetWidth(POP_W - 28)
    popup.child:SetHeight(1)
    popup.scroll:SetScrollChild(popup.child)

    -- Style scrollbar
    local sb = popup.scroll.ScrollBar
    if sb then
        sb:SetWidth(8)
        if sb.ScrollUpButton then sb.ScrollUpButton:SetAlpha(0); sb.ScrollUpButton:SetSize(1, 1) end
        if sb.ScrollDownButton then sb.ScrollDownButton:SetAlpha(0); sb.ScrollDownButton:SetSize(1, 1) end
        local thumb = sb:GetThumbTexture()
        if thumb then thumb:SetAtlas("minimal-scrollbar-thumb-middle"); thumb:SetWidth(8); thumb:SetAlpha(0.7) end
    end

    -- Footer separator
    local fsep = popup:CreateTexture(nil, "ARTWORK")
    fsep:SetColorTexture(bc[1], bc[2], bc[3], 0.5)
    fsep:SetHeight(1)
    fsep:SetPoint("BOTTOMLEFT", PAD, FOOTER_H)
    fsep:SetPoint("BOTTOMRIGHT", -PAD, FOOTER_H)

    -- Footer buttons
    local btnW = (POP_W - PAD * 3) / 2

    popup.mapBtn = CreateFrame("Button", nil, popup, "BackdropTemplate")
    popup.mapBtn:SetSize(btnW, 22)
    popup.mapBtn:SetPoint("BOTTOMLEFT", PAD, 4)
    popup.mapBtn:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
    popup.mapBtn:SetBackdropColor(0.15, 0.18, 0.22, 0.8)
    popup.mapBtn:SetBackdropBorderColor(bc[1], bc[2], bc[3], 0.6)
    popup.mapBtn.text = popup.mapBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    popup.mapBtn.text:SetPoint("CENTER")
    popup.mapBtn.text:SetText("|cFFe2e8f0" .. L["ACQ_MAP_ALL"] .. "|r")
    popup.mapBtn:SetScript("OnClick", function() HDG_ZoneMode.MapWaypoints() end)
    popup.mapBtn:SetScript("OnEnter", function(self) self:SetBackdropColor(0.22, 0.25, 0.30, 0.9) end)
    popup.mapBtn:SetScript("OnLeave", function(self) self:SetBackdropColor(0.15, 0.18, 0.22, 0.8) end)

    popup.listBtn = CreateFrame("Button", nil, popup, "BackdropTemplate")
    popup.listBtn:SetSize(btnW, 22)
    popup.listBtn:SetPoint("BOTTOMRIGHT", -PAD, 4)
    popup.listBtn:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
    popup.listBtn:SetBackdropColor(0.15, 0.18, 0.22, 0.8)
    popup.listBtn:SetBackdropBorderColor(bc[1], bc[2], bc[3], 0.6)
    popup.listBtn.text = popup.listBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    popup.listBtn.text:SetPoint("CENTER")
    popup.listBtn.text:SetText("|cFFe2e8f0" .. L["BTN_OPEN_LIST"] .. "|r")
    popup.listBtn:SetScript("OnClick", function()
        popup:Hide()
        if HDG_VendorShoppingList and HDG_VendorShoppingList.Show then
            HDG_VendorShoppingList.Show()
        end
    end)
    popup.listBtn:SetScript("OnEnter", function(self) self:SetBackdropColor(0.22, 0.25, 0.30, 0.9) end)
    popup.listBtn:SetScript("OnLeave", function(self) self:SetBackdropColor(0.15, 0.18, 0.22, 0.8) end)

    table.insert(UISpecialFrames, "HDG_ZonePopup")
    popup:Hide()

    -- Re-render when collection data arrives (async owned search)
    if HDG.EventBus then
        HDG.EventBus:Register("HDG_COLLECTION_READY", function()
            currentZoneVendors = nil -- stale, re-query with fresh collection data
            RefreshIfShowing()
        end)
    end
end

-- ============================================================================
-- POPUP REFRESH
-- ============================================================================

RefreshPopup = function()
    if not popup then return end

    HideAllRows()

    currentZoneMapID = currentZoneMapID or C_Map.GetBestMapForUnit("player")
    local raw
    if showCollected then
        raw = currentZoneVendors or GetAllVendorsInZone(currentZoneMapID)
    else
        raw = currentZoneVendors or HDG_VendorResolver.GetVendorsInZoneWithUncollected(currentZoneMapID)
    end
    currentZoneVendors = raw

    local TOOLBAR_EXTRA = 26 -- search bar height offset

    if not raw then
        popup.title:SetText("|cFFe2e8f0" .. L["HEADER_ZONE_VENDORS"] .. "|r")
        popup.subtitle:SetText("|cFFcbd5e1" .. L["STATUS_NO_ZONE_VENDORS"] .. "|r")
        popup.child:SetHeight(1)
        popup:SetHeight(TITLE_H + 40 + TOOLBAR_EXTRA + FOOTER_H)
        return
    end

    local vendors, hasLoading = FilterVendors(raw)

    -- Apply search filter
    if searchFilter ~= "" then
        local filtered = {}
        for _, vendor in ipairs(vendors) do
            local matchItems = {}
            local vendorMatch = vendor.name and vendor.name:lower():find(searchFilter, 1, true)
            for _, ui in ipairs(vendor.uncollectedItems) do
                local name = HDG_Data and HDG_Data.GetCachedItemName(ui.itemID) or ""
                if vendorMatch or name:lower():find(searchFilter, 1, true) then
                    matchItems[#matchItems + 1] = ui
                end
            end
            if #matchItems > 0 then
                local copy = {}
                for k, v in pairs(vendor) do copy[k] = v end
                copy.uncollectedItems = matchItems
                filtered[#filtered + 1] = copy
            end
        end
        vendors = filtered
    end

    if #vendors == 0 then
        popup.title:SetText("|cFFe2e8f0" .. L["HEADER_ZONE_VENDORS"] .. "|r")
        popup.subtitle:SetText("|cFFcbd5e1" .. (searchFilter ~= "" and "No matches" or L["STATUS_NO_ZONE_VENDORS"]) .. "|r")
        popup.child:SetHeight(1)
        popup:SetHeight(TITLE_H + 40 + TOOLBAR_EXTRA + FOOTER_H)
        return
    end

    -- Re-render once loading items resolve
    if hasLoading then
        if pendingRefreshTimer then pendingRefreshTimer:Cancel() end
        pendingRefreshTimer = C_Timer.NewTimer(1.5, function()
            pendingRefreshTimer = nil
            if popup and popup:IsShown() then RefreshPopup() end
        end)
    end

    -- Title & subtitle
    local zoneName = vendors[1] and vendors[1].zone or "Unknown"
    local totalItems, collectedCount = 0, 0
    for _, v in ipairs(vendors) do
        for _, ui in ipairs(v.uncollectedItems) do
            totalItems = totalItems + 1
            if ui.collected then collectedCount = collectedCount + 1 end
        end
    end
    local uncollectedCount = totalItems - collectedCount

    popup.title:SetText("|cFFe2e8f0" .. zoneName .. "|r")
    if showCollected then
        popup.subtitle:SetText("|cFFcbd5e1" .. #vendors .. " vendors, " .. uncollectedCount .. " uncollected, " .. collectedCount .. " collected|r")
    else
        popup.subtitle:SetText("|cFFcbd5e1" .. #vendors .. " vendors, " .. totalItems .. " uncollected|r")
    end

    table.sort(vendors, function(a, b) return (a.name or "") < (b.name or "") end)

    -- Render rows
    local contentW = popup.child:GetWidth()
    local yOff = 0
    local idx = 0

    for _, vendor in ipairs(vendors) do
        idx = idx + 1
        local isExpanded = expandedVendors[vendor.npcID] ~= false

        -- Vendor row
        local vr = GetRow(popup.child, idx)
        vr:SetPoint("TOPLEFT", VENDOR_INDENT, -yOff)
        vr:SetPoint("RIGHT", popup.child, "RIGHT", 0, 0)
        vr:SetBackdropColor(0.10, 0.12, 0.15, 0.5)

        vr.arrow:Show()
        vr.arrow:SetAtlas(isExpanded and "common-button-collapseExpand-down" or "common-button-collapseExpand-up")

        vr.text:ClearAllPoints()
        vr.text:SetPoint("LEFT", vr.arrow, "RIGHT", 3, 0)
        vr.text:SetWidth(contentW - VENDOR_INDENT - 80)
        local accentColor = HDG.Theme and HDG.Theme.C_Accent() or "|cFF268bd2"
        local vUncollected = 0
        for _, ui in ipairs(vendor.uncollectedItems) do
            if not ui.collected then vUncollected = vUncollected + 1 end
        end
        vr.text:SetText(accentColor .. vendor.name .. "|r |cFFcbd5e1(" .. vUncollected .. "/" .. #vendor.uncollectedItems .. ")|r")

        if vendor.x and vendor.x > 0 then
            vr.rightText:SetText("|cFFcbd5e1" .. string.format("%.0f, %.0f", vendor.x, vendor.y) .. "|r")
        end

        local vRef = vendor
        vr:SetScript("OnClick", function()
            if IsShiftKeyDown() then
                local count = 0
                for _, ui in ipairs(vRef.uncollectedItems) do
                    if HDG.Store:AddToVendorShoppingList(ui.itemID, vRef.npcID) then count = count + 1 end
                end
                if count > 0 then
                    print((HDG.Theme and HDG.Theme.C_Success() or "|cFF00FF00") .. "[HDG]|r Added " .. count .. " items from " .. vRef.name .. " to shopping list")
                    RefreshPopup()
                end
                return
            end
            expandedVendors[vRef.npcID] = not (expandedVendors[vRef.npcID] ~= false)
            RefreshPopup()
        end)
        vr:SetScript("OnEnter", function(self)
            self:SetBackdropColor(0.15, 0.18, 0.22, 0.6)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(vRef.name, 1, 1, 1)
            if vRef.x and vRef.x > 0 then
                GameTooltip:AddLine(string.format("%.1f, %.1f", vRef.x, vRef.y), 0.7, 0.7, 0.7)
            end
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine(L["TIP_CLICK_EXPAND_COLLAPSE"], 0.78, 0.78, 0.78)
            GameTooltip:AddLine(L["TIP_SHIFTCLICK_ADD_ALL_LIST"], 0.78, 0.78, 0.78)
            GameTooltip:Show()
        end)
        vr:SetScript("OnLeave", function(self)
            self:SetBackdropColor(0.10, 0.12, 0.15, 0.5)
            GameTooltip:Hide()
        end)

        yOff = yOff + ROW_H

        -- Expanded items
        if isExpanded then
            -- Sort: uncollected first, then alphabetical
            table.sort(vendor.uncollectedItems, function(a, b)
                if (a.collected or false) ~= (b.collected or false) then
                    return not (a.collected or false) -- uncollected first
                end
                local na = HDG_Data and HDG_Data.GetCachedItemName(a.itemID) or ""
                local nb = HDG_Data and HDG_Data.GetCachedItemName(b.itemID) or ""
                return na < nb
            end)
            for _, ui in ipairs(vendor.uncollectedItems) do
                idx = idx + 1
                local ir = GetRow(popup.child, idx)
                ir:SetPoint("TOPLEFT", ITEM_INDENT, -yOff)
                ir:SetPoint("RIGHT", popup.child, "RIGHT", 0, 0)
                ir:SetBackdropColor(0.06, 0.07, 0.09, 0.3)

                ir.icon:ClearAllPoints()
                ir.icon:SetPoint("LEFT", 2, 0)
                ir.icon:Show()
                ir.icon:SetTexture(134400)  -- question mark placeholder

                local isItemCollected = ui.collected or false
                local textColor = isItemCollected and "|cFF666666" or "|cFFe2e8f0"
                local itemName = HDG_Data and HDG_Data.GetCachedItemName(ui.itemID) or ("Item " .. ui.itemID)
                ir.text:ClearAllPoints()
                ir.text:SetPoint("LEFT", ir.icon, "RIGHT", 3, 0)
                ir.text:SetWidth(contentW - ITEM_INDENT - 26)
                ir.text:SetText(textColor .. itemName .. "|r")

                -- Collected checkmark
                if isItemCollected then
                    ir.badge:SetText("|A:common-icon-checkmark:14:14|a")
                    ir.badge:ClearAllPoints()
                    ir.badge:SetPoint("RIGHT", -4, 0)
                    ir.icon:SetDesaturated(true)
                    ir.icon:SetAlpha(0.5)
                else
                    ir.badge:SetText("")
                    ir.icon:SetDesaturated(false)
                    ir.icon:SetAlpha(1.0)
                end

                -- Async icon/name resolution
                local rowRef, collected = ir, isItemCollected
                local itm = Item:CreateFromItemID(ui.itemID)
                itm:ContinueOnItemLoad(function()
                    if not rowRef:IsShown() then return end
                    local name, _, _, _, _, _, _, _, _, ic = C_Item.GetItemInfo(ui.itemID)
                    if ic then rowRef.icon:SetTexture(ic); if collected then rowRef.icon:SetDesaturated(true); rowRef.icon:SetAlpha(0.5) end end
                    local clr = collected and "|cFF666666" or "|cFFe2e8f0"
                    if name then rowRef.text:SetText(clr .. name .. "|r") end
                end)

                -- Shift-click: add to list
                local iID, nID = ui.itemID, vendor.npcID
                ir:SetScript("OnClick", function()
                    if not IsShiftKeyDown() then return end
                    if HDG.Store:AddToVendorShoppingList(iID, nID) then
                        local n = HDG_Data and HDG_Data.GetCachedItemName(iID) or ("Item " .. iID)
                        print((HDG.Theme and HDG.Theme.C_Success() or "|cFF00FF00") .. "[HDG]|r Added " .. n .. " to shopping list")
                    end
                end)

                -- Tooltip with cost info
                ir:SetScript("OnEnter", function(self)
                    self:SetBackdropColor(0.12, 0.14, 0.18, 0.5)
                    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                    local it = Item:CreateFromItemID(iID)
                    it:ContinueOnItemLoad(function()
                        if not GameTooltip:IsOwned(self) then return end
                        GameTooltip:SetItemByID(iID)
                        local ve = HDG_VendorDB and HDG_VendorDB[nID]
                        local ie = ve and ve.items and ve.items[iID]
                        if ie then
                            local gc = ie[1] or 0
                            if gc > 0 then GameTooltip:AddLine(string.format(L["TIP_COST_PREFIX"], GetCoinTextureString(gc)), 1, 1, 1) end
                            if ie[5] then
                                for _, ct in ipairs(ie[5]) do
                                    if ct[1] == "c" then
                                        local info = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo(ct[2])
                                        local cName = info and info.name or ("Currency " .. ct[2])
                                        local cIcon = info and info.iconFileID
                                        local iconStr = cIcon and string.format("|T%s:14:14|t", cIcon) or ""
                                        GameTooltip:AddLine(iconStr .. " " .. ct[3] .. " " .. cName, 0.7, 0.8, 1.0)
                                    end
                                end
                            end
                        end
                        GameTooltip:AddLine(" ")
                        GameTooltip:AddLine(L["TIP_SHIFTCLICK_ADD_SHOPPING"], 0.78, 0.78, 0.78)
                        GameTooltip:Show()
                    end)
                end)
                ir:SetScript("OnLeave", function(self)
                    self:SetBackdropColor(0.06, 0.07, 0.09, 0.3)
                    GameTooltip:Hide()
                end)

                yOff = yOff + ROW_H
            end
        end
    end

    activeRowCount = idx

    -- Size popup to content
    popup.child:SetHeight(math.max(yOff, 1))
    local contentH = math.min(yOff, MAX_CONTENT_H)
    popup:SetHeight(TITLE_H + 14 + TOOLBAR_EXTRA + contentH + FOOTER_H + 4)
end

-- ============================================================================
-- POPUP API
-- ============================================================================

function HDG_ZoneMode.ShowPopup()
    if not CONFIG then CONFIG = privateDB.CONFIG end
    if not popup then CreatePopup() end
    currentZoneVendors = nil -- force fresh collection check
    popup:Show()
    -- If collection cache is warm, render immediately; otherwise warm it first
    if HDG_Data and HDG_Data.IsCollectionCacheWarmed and HDG_Data.IsCollectionCacheWarmed() then
        RefreshPopup()
    elseif HDG_Data and HDG_Data.WarmCollectionCache then
        RefreshPopup() -- show loading state with what we have
        HDG_Data.WarmCollectionCache(nil, function()
            currentZoneVendors = nil -- stale, re-query with fresh data
            RefreshIfShowing()
        end)
    else
        RefreshPopup()
    end
end

function HDG_ZoneMode.HidePopup()
    if popup then popup:Hide() end
end

function HDG_ZoneMode.IsPopupShown()
    return popup and popup:IsShown()
end

-- ============================================================================
-- WAYPOINTS
-- ============================================================================

function HDG_ZoneMode.MapWaypoints()
    if not currentZoneVendors or not HDG.Waypoints then return end
    local waypoints = {}
    for _, vendor in ipairs(currentZoneVendors) do
        waypoints[#waypoints + 1] = { vendor.mapID, vendor.x, vendor.y, vendor.name }
    end
    if #waypoints == 0 then
        print("|cFFFFAA00[HDG]|r No valid vendor locations to map.")
        return
    end
    local count, provider = HDG.Waypoints:SetMultiple(waypoints)
    HDG.Waypoints:PrintResult(count > 0, provider, nil, count, waypoints[1] and waypoints[1][1])
end

-- ============================================================================
-- ZONE SCANNER
-- ============================================================================

RefreshIfShowing = function()
    if popup and popup:IsShown() then RefreshPopup() end
end

-- Pre-warm item cache, call onReady only if real (non-unreleased) items exist
local function WhenZoneItemsReady(mapID, onReady)
    local raw = HDG_VendorResolver.GetVendorsInZoneWithUncollected(mapID)
    if not raw then return end

    local pending = 0
    local done = false
    local function finish()
        if done then return end
        done = true
        local vendors = FilterVendors(raw)
        if #vendors > 0 then
            currentZoneVendors = raw  -- cache so RefreshPopup() skips expensive re-query
            onReady()
        end
    end

    for _, vendor in ipairs(raw) do
        for _, ui in ipairs(vendor.uncollectedItems) do
            local name = HDG_Data and HDG_Data.GetCachedItemName(ui.itemID)
            if not name or name == "Loading..." then
                pending = pending + 1
                local itm = Item:CreateFromItemID(ui.itemID)
                itm:ContinueOnItemLoad(function()
                    pending = pending - 1
                    if pending == 0 then finish() end
                end)
            end
        end
    end

    if pending == 0 then finish()
    else C_Timer.After(3.0, finish) end  -- unreleased items never callback
end

local function FireAlerts(wantPopup, wantSquirrels, wantChat, wantSound, getQuote)
    if wantPopup and not HDG_ZoneMode.IsPopupShown() then
        HDG_ZoneMode.ShowPopup()
    end
    if wantSquirrels then
        local quote, speaker = getQuote()
        privateDB.HDG_Vamoose.DisplayQuote(quote, speaker)
        if wantChat then print("|cFFA0522D[Squirrel]|r " .. quote) end
    elseif wantChat then
        local quote = getQuote()
        print("|cFFA0522D[Squirrel]|r " .. quote)
    end
    if wantSound then PlaySound(SOUNDKIT.IG_MAINMENU_OPEN) end
end

local function CheckUncollectedAlert(mapID, wantPopup, wantSquirrels, wantChat, wantSound)
    if not HDG_Data or not HDG_Data.IsCollectionCacheWarmed or not HDG_Data.IsCollectionCacheWarmed() then return false end
    if not HDG_VendorResolver.ZoneHasUncollectedVendorItems(mapID) then return false end
    WhenZoneItemsReady(mapID, function()
        FireAlerts(wantPopup, wantSquirrels, wantChat, wantSound,
            privateDB.HDG_CraftQuotes.GetRandomZoneAlertQuote)
    end)
    return true
end

local function CheckShoppingListAlert(mapID, wantPopup, wantSquirrels, wantChat, wantSound, didAlert)
    if not mapID then return end
    local activeItems = HDG.Store:GetActiveShoppingList().items
    local found = false
    for _, entry in ipairs(activeItems) do
        local ve = HDG_VendorDB and HDG_VendorDB[entry.npcID]
        if ve and ve[3] == mapID then found = true; break end
    end
    if not found then return end
    WhenZoneItemsReady(mapID, function()
        local skipHead = didAlert
        if wantPopup and not HDG_ZoneMode.IsPopupShown() then
            HDG_ZoneMode.ShowPopup()
        end
        if wantSquirrels then
            local quote, speaker = privateDB.HDG_CraftQuotes.GetRandomShoppingZoneQuote()
            if not skipHead then privateDB.HDG_Vamoose.DisplayQuote(quote, speaker) end
            if wantChat then print("|cFFA0522D[Squirrel]|r " .. quote) end
        elseif wantChat then
            local quote = privateDB.HDG_CraftQuotes.GetRandomShoppingZoneQuote()
            print("|cFFA0522D[Squirrel]|r " .. quote)
        end
        if wantSound and not skipHead then PlaySound(SOUNDKIT.IG_MAINMENU_OPEN) end
    end)
end

function HDG_ZoneMode.OnZoneChanged(mapID)
    currentZoneMapID = mapID
    currentZoneVendors = nil

    if not GetConfig("zoneScannerEnabled") or InCombatLockdown() then
        return RefreshIfShowing()
    end

    local wantPopup = GetConfig("zoneScannerPopup") or false
    local wantSquirrels = GetConfig("zoneScannerSquirrels") or false
    local wantChat = GetConfig("zoneScannerChat") ~= false
    local wantSound = GetConfig("zoneScannerSound") or false

    local didAlert = CheckUncollectedAlert(mapID, wantPopup, wantSquirrels, wantChat, wantSound)
    CheckShoppingListAlert(mapID, wantPopup, wantSquirrels, wantChat, wantSound, didAlert)
    RefreshIfShowing()
end

-- ============================================================================
-- GEAR POPUP (Zone Scanner Settings)
-- ============================================================================

function HDG_ZoneMode.CreateGearPopup(anchorFrame)
    if not CONFIG then CONFIG = privateDB.CONFIG end
    local gp = CreateFrame("Frame", "HDG_ZoneScannerSettings", anchorFrame, "BackdropTemplate")
    gp:SetSize(200, 230)
    gp:SetPoint("TOPRIGHT", anchorFrame, "BOTTOMRIGHT", 0, -2)
    gp:SetFrameStrata("TOOLTIP")
    gp:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
    gp:SetBackdropColor(0.15, 0.15, 0.16, 0.97)
    local gbc = CONFIG.COLORS.PANEL_DARK_BORDER_COOL
    gp:SetBackdropBorderColor(gbc[1], gbc[2], gbc[3], 1)

    local ptitle = gp:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    ptitle:SetPoint("TOPLEFT", 10, -8)
    ptitle:SetText("|cFFe2e8f0" .. L["HEADER_ZONE_SCANNER"] .. "|r")

    local sep = gp:CreateTexture(nil, "ARTWORK")
    sep:SetColorTexture(gbc[1], gbc[2], gbc[3], 0.6)
    sep:SetHeight(1)
    sep:SetPoint("TOPLEFT", 8, -26)
    sep:SetPoint("TOPRIGHT", -8, -26)

    local yOff = -32
    local function MakeCheckbox(label, configKey, indent)
        local xOff = 8 + (indent or 0)
        local cb = CreateFrame("CheckButton", nil, gp, "UICheckButtonTemplate")
        cb:SetSize(22, 22)
        cb:SetPoint("TOPLEFT", xOff, yOff)
        cb:SetChecked(GetConfig(configKey) or false)
        local lbl = gp:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        lbl:SetPoint("LEFT", cb, "RIGHT", 4, 0)
        lbl:SetText("|cFFe2e8f0" .. label .. "|r")
        cb:SetScript("OnClick", function(self)
            HDG.Store:Dispatch("SET_CONFIG", { key = configKey, value = self:GetChecked() })
            if configKey == "zoneScannerEnabled" and gp._updateSubs then gp._updateSubs() end
        end)
        yOff = yOff - 26
        return cb
    end

    gp.cbMaster = MakeCheckbox(L["CFG_ZONE_SCANNER_ENABLE"], "zoneScannerEnabled")
    gp.cbPopup = MakeCheckbox(L["CFG_ZONE_SCANNER_POPUP"], "zoneScannerPopup", 16)
    gp.cbSquirrels = MakeCheckbox(L["CFG_ZONE_SCANNER_SQUIRRELS"], "zoneScannerSquirrels", 16)
    gp.cbChat = MakeCheckbox(L["CFG_ZONE_SCANNER_CHAT"], "zoneScannerChat", 16)
    gp.cbSound = MakeCheckbox(L["CFG_ZONE_SCANNER_SOUND"], "zoneScannerSound", 16)

    local subs = { gp.cbPopup, gp.cbSquirrels, gp.cbChat, gp.cbSound }
    gp._updateSubs = function()
        local enabled = GetConfig("zoneScannerEnabled") or false
        local alpha = enabled and 1.0 or 0.35
        for _, cb in ipairs(subs) do cb:SetAlpha(alpha); cb:SetEnabled(enabled) end
    end

    -- Shopping List section
    local sep2 = gp:CreateTexture(nil, "ARTWORK")
    sep2:SetColorTexture(gbc[1], gbc[2], gbc[3], 0.6)
    sep2:SetHeight(1)
    sep2:SetPoint("TOPLEFT", 8, yOff - 4)
    sep2:SetPoint("TOPRIGHT", -8, yOff - 4)
    local slTitle = gp:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    slTitle:SetPoint("TOPLEFT", 10, yOff - 10)
    slTitle:SetText("|cFFe2e8f0" .. (L["HEADER_SHOPPING_LIST"] or "Shopping List") .. "|r")
    yOff = yOff - 22
    gp.cbAutoDecrement = MakeCheckbox(L["CFG_SHOP_AUTO_DECREMENT"] or "Auto-remove on purchase", "shopListAutoDecrement")

    gp:SetScript("OnShow", function()
        gp.cbMaster:SetChecked(GetConfig("zoneScannerEnabled") or false)
        gp.cbPopup:SetChecked(GetConfig("zoneScannerPopup") or false)
        gp.cbSquirrels:SetChecked(GetConfig("zoneScannerSquirrels") or false)
        gp.cbChat:SetChecked(GetConfig("zoneScannerChat") ~= false)
        gp.cbSound:SetChecked(GetConfig("zoneScannerSound") or false)
        gp.cbAutoDecrement:SetChecked(GetConfig("shopListAutoDecrement") ~= false)
        gp._updateSubs()
    end)
    table.insert(UISpecialFrames, "HDG_ZoneScannerSettings")
    gp:Hide()
    return gp
end
