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

-- ============================================================================
-- VENDOR SHOPPING LIST
-- Floating widget showing vendor shopping list items
-- ============================================================================

HDG_VendorShoppingList = {}

local widget          -- FloatingWidget result table
local CONFIG
local scrollFrame, scrollContent
local created = false
local rowPool = {}
local expandedVendors = {}  -- vendor expand/collapse state
local collapsedZones = {}   -- zone collapse state (collapsed = true)
local collapsedWishList = false

-- UI elements for new layout
local titleSummary     -- FontString: "12 . 450g" in header
local listDropdown     -- Button: list selector trigger
local attrBanner       -- Frame: attribution banner
local attrText         -- FontString in attribution banner
local attrIcon         -- Texture in attribution banner
local attrUrlBtn       -- Button: open source URL
local actionBar        -- Frame: bottom action bar
local actionMapBtn, actionClearBtn, actionExportBtn, actionImportBtn
local listSelectorPopup -- popup for switching between lists

local ShowUrlPopup -- forward declaration
local GetScheme = function() return HDG_UIHelpers and HDG_UIHelpers.GetScheme() end

local currencyInfoCache = {}
local function GetCachedCurrencyInfo(cid)
    if not currencyInfoCache[cid] then
        currencyInfoCache[cid] = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo(cid)
    end
    return currencyInfoCache[cid]
end

local function SortVendorsByName(a, b)
    return (HDG_VendorResolver.GetVendorNameByID(a) or "") < (HDG_VendorResolver.GetVendorNameByID(b) or "")
end

local function SortItemsByName(a, b)
    local na = HDG_Data.GetCachedItemName(a.itemID) or ""
    local nb = HDG_Data.GetCachedItemName(b.itemID) or ""
    return na < nb
end

local RegisterWidget = function(w, t) HDG_UIHelpers.RegisterWidget(w, t) end
local ApplyFont = function(fs, s, sz, so) if HDG.Theme then HDG.Theme.ApplyFont(fs, s, sz, so) end end

-- ============================================================================
-- ROW POOLING
-- ============================================================================

local function GetPooledRow(parent, rowHeight)
    for _, row in ipairs(rowPool) do
        if not row.inUse then
            row.inUse = true
            row:SetParent(parent)
            row:SetHeight(rowHeight)
            row:Show()
            return row
        end
    end

    local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
    row:SetHeight(rowHeight)
    row.inUse = true

    row:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })

    -- Icon (hidden by default)
    local icon = row:CreateTexture(nil, "ARTWORK")
    icon:SetSize(16, 16)
    icon:SetPoint("LEFT", 4, 0)
    icon:Hide()
    row.iconTex = icon

    -- Expand/collapse arrow (atlas button)
    local arrow = CreateFrame("Button", nil, row)
    arrow:SetSize(20, 20)
    arrow:SetPoint("LEFT", 2, 0)
    arrow:SetHighlightAtlas("common-button-collapseExpand-hover")
    arrow:Hide()
    row.expandArrow = arrow

    -- Name
    local nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    nameText:SetPoint("LEFT", 4, 0)
    nameText:SetJustifyH("LEFT")
    nameText:SetWordWrap(false)
    row.nameText = nameText

    -- Right-aligned text (gold cost, counts, etc.)
    local rightText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rightText:SetPoint("RIGHT", -4, 0)
    rightText:SetJustifyH("RIGHT")
    rightText:SetWordWrap(false)
    row.rightText = rightText

    -- Badge text (for header row counts like "(3)")
    local badge = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightExtraSmall")
    badge:SetJustifyH("LEFT")
    badge:Hide()
    row.badgeText = badge

    -- Badge button (non-clickable label: Craft, Rep, Quest, etc.)
    local badgeBtn = CreateFrame("Frame", nil, row, "BackdropTemplate")
    badgeBtn:SetSize(36, 14)
    badgeBtn:SetPoint("RIGHT", -42, 0) -- left of +/- buttons
    badgeBtn:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 0.5 })
    badgeBtn:SetBackdropColor(0.3, 0.3, 0.3, 0.5)
    badgeBtn:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.4)
    local badgeBtnText = badgeBtn:CreateFontString(nil, "OVERLAY", "GameFontHighlightExtraSmall")
    badgeBtnText:SetPoint("CENTER", 0, 0)
    badgeBtnText:SetJustifyH("CENTER")
    badgeBtn.text = badgeBtnText
    badgeBtn:Hide()
    row.badgeBtn = badgeBtn

    -- Add button (cart-plus) -- hidden by default, anchored left of removeBtn
    local addBtn = HDG_UIHelpers.CreateAtlasButton(row, "communities-chat-icon-plus", 16,
        { pressedAtlas = "communities-chat-icon-plus", highlight = true })
    addBtn:SetPoint("RIGHT", -22, 0)
    HDG_UIHelpers.SetupTooltip(addBtn, { title = L["TIP_ADD_ONE_MORE"], anchor = "ANCHOR_RIGHT" })
    addBtn:Hide()
    row.addBtn = addBtn

    -- Remove button (cart-minus atlas) -- hidden by default
    local removeBtn = HDG_UIHelpers.CreateAtlasButton(row, "communities-chat-icon-minus", 16,
        { pressedAtlas = "communities-chat-icon-minus", highlight = true })
    removeBtn:SetPoint("RIGHT", -2, 0)
    HDG_UIHelpers.SetupTooltip(removeBtn, { title = L["TIP_REMOVE_FROM_SHOPPING_LIST"], anchor = "ANCHOR_RIGHT" })
    removeBtn:Hide()
    row.removeBtn = removeBtn

    -- Pin button (waypoint) -- hidden by default
    local pinBtn = CreateFrame("Button", nil, row)
    pinBtn:SetSize(16, 16)
    pinBtn:SetPoint("RIGHT", -2, 0)
    pinBtn:SetNormalAtlas("Waypoint-MapPin-ChatIcon")
    pinBtn:SetHighlightTexture(136580) -- common highlight
    HDG_UIHelpers.SetupTooltip(pinBtn, { title = L["TIP_PIN_ZONE_VENDORS"], anchor = "ANCHOR_RIGHT" })
    pinBtn:Hide()
    row.pinBtn = pinBtn

    table.insert(rowPool, row)
    return row
end

local function ReleaseAllRows()
    for _, row in ipairs(rowPool) do
        row.inUse = false
        row._assignedItemID = nil
        row:Hide()
        row:ClearAllPoints()
        row.addBtn:Hide()
        row.pinBtn:Hide()
        row.removeBtn:Hide()
        row.expandArrow:Hide()
        row.badgeText:Hide()
        row.badgeBtn:Hide()
        row.iconTex:Hide()
        row.iconTex:SetTexture(nil)
        row.rightText:SetText("")
        row:SetScript("OnClick", nil)
        row:SetScript("OnEnter", nil)
        row:SetScript("OnLeave", nil)
    end
end

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

local function FormatGoldCompact(copper)
    if not copper or copper == 0 then return "" end
    local gold = math.floor(copper / 10000)
    if gold > 0 then return "|cFFfcd34d" .. gold .. "g|r" end
    local silver = math.floor((copper % 10000) / 100)
    return HDG.Theme.C_Dim() .. silver .. "s|r"
end

local function SetWaypoint(mapID, x, y, title)
    local HDG = _G.HousingDecorGuide
    if not HDG or not HDG.Waypoints then return false end
    local ok, provider = HDG.Waypoints:Set(mapID, x, y, title)
    return ok, provider
end

-- ============================================================================
-- STATIC POPUP DIALOGS (List Management)
-- ============================================================================

StaticPopupDialogs["HDG_NEW_SHOPPING_LIST"] = {
    text = "New Shopping List Name:",
    button1 = "Create",
    button2 = "Cancel",
    hasEditBox = true,
    editBoxWidth = 200,
    OnShow = function(self)
        local count = HDG.Store and #HDG.Store:GetShoppingLists() or 0
        self.EditBox:SetText("List " .. (count + 1))
        self.EditBox:SetMaxLetters(60)
        self.EditBox:HighlightText()
        self.EditBox:SetFocus()
    end,
    OnAccept = function(self)
        local name = self.EditBox:GetText()
        if name and name ~= "" and HDG.Store then
            local id = HDG.Store:CreateShoppingList(name)
            HDG.Store:SetActiveShoppingList(id)
            HDG_VendorShoppingList.Refresh()
        end
    end,
    EditBoxOnEnterPressed = function(self)
        local parent = self:GetParent()
        local name = self:GetText()
        if name and name ~= "" and HDG.Store then
            local id = HDG.Store:CreateShoppingList(name)
            HDG.Store:SetActiveShoppingList(id)
            HDG_VendorShoppingList.Refresh()
        end
        parent:Hide()
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

StaticPopupDialogs["HDG_RENAME_SHOPPING_LIST"] = {
    text = "Rename Shopping List:",
    button1 = "Rename",
    button2 = "Cancel",
    hasEditBox = true,
    editBoxWidth = 200,
    OnShow = function(self, data)
        self.EditBox:SetText(data and data.name or "")
        self.EditBox:SetMaxLetters(60)
        self.EditBox:HighlightText()
        self.EditBox:SetFocus()
    end,
    OnAccept = function(self, data)
        local name = self.EditBox:GetText()
        if name and name ~= "" and data and HDG.Store then
            HDG.Store:RenameShoppingList(data.id, name)
            HDG_VendorShoppingList.Refresh()
        end
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

StaticPopupDialogs["HDG_DELETE_SHOPPING_LIST"] = {
    text = "Delete shopping list \"%s\"?",
    button1 = "Delete",
    button2 = "Cancel",
    OnAccept = function(self, data)
        if HDG.Store and data and data.id then
            HDG.Store:DeleteShoppingList(data.id)
            HDG_VendorShoppingList.Refresh()
        end
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

-- ============================================================================
-- LIST SELECTOR POPUP
-- ============================================================================

local function ShowListSelectorPopup(anchor)
    if not HDG.Store then return end

    if not listSelectorPopup then
        listSelectorPopup = CreateFrame("Frame", "HDG_ListSelectorPopup", UIParent, "BackdropTemplate")
        listSelectorPopup:SetFrameStrata("TOOLTIP")
        listSelectorPopup:SetClampedToScreen(true)
        listSelectorPopup:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
        listSelectorPopup:EnableKeyboard(true)
        listSelectorPopup:SetScript("OnKeyDown", function(self, key)
            if key == "ESCAPE" then
                self:SetPropagateKeyboardInput(false)
                self:Hide()
            else
                self:SetPropagateKeyboardInput(true)
            end
        end)
        listSelectorPopup._rows = {}
        listSelectorPopup:SetScript("OnHide", function()
            if listDropdown and listDropdown._arrow then
                listDropdown._arrow:SetAtlas("common-button-collapseExpand-down")
            end
        end)
        table.insert(UISpecialFrames, "HDG_ListSelectorPopup")
    end

    -- Toggle off if already showing
    if listSelectorPopup:IsShown() then
        listSelectorPopup:Hide()
        return
    end

    local popup = listSelectorPopup
    local s = GetScheme()

    if s then
        popup:SetBackdropColor(s.bg.r, s.bg.g, s.bg.b, 0.95)
        popup:SetBackdropBorderColor(s.border.r, s.border.g, s.border.b, 1)
    else
        local pc = CONFIG and CONFIG.COLORS and CONFIG.COLORS.PANEL_POPUP or {0.05, 0.06, 0.08, 0.95}
        local bc = CONFIG and CONFIG.COLORS and CONFIG.COLORS.PANEL_DARK_BORDER_COOL or {0.29, 0.33, 0.41, 0.8}
        popup:SetBackdropColor(pc[1], pc[2], pc[3], 0.95)
        popup:SetBackdropBorderColor(bc[1], bc[2], bc[3], 1)
    end

    -- Hide existing rows
    for _, r in ipairs(popup._rows) do r:Hide() end

    local lists = HDG.Store:GetShoppingLists()
    local activeId = HDG.Store.state.activeShoppingListId or "default"
    local ROW_H = 22
    local popupWidth = anchor:GetWidth()

    local BTN_SIZE = 14
    local canDelete = #lists > 1

    for i, li in ipairs(lists) do
        local row = popup._rows[i]
        if not row then
            row = CreateFrame("Button", nil, popup, "BackdropTemplate")
            row:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })
            row._nameText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            row._nameText:SetPoint("LEFT", 6, 0)
            row._nameText:SetJustifyH("LEFT")
            row._nameText:SetWordWrap(false)
            row._countText = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            row._countText:SetJustifyH("RIGHT")
            -- Delete button (X)
            row._deleteBtn = CreateFrame("Button", nil, row)
            row._deleteBtn:SetSize(BTN_SIZE, BTN_SIZE)
            row._deleteBtn:SetPoint("RIGHT", -4, 0)
            row._deleteBtn._tex = row._deleteBtn:CreateTexture(nil, "ARTWORK")
            row._deleteBtn._tex:SetAllPoints()
            row._deleteBtn._tex:SetAtlas("transmog-icon-remove")
            row._deleteBtn:SetScript("OnEnter", function(self)
                self._tex:SetAlpha(1)
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                GameTooltip:SetText(L["TIP_DELETE_LIST"], 1, 0.3, 0.3)
                GameTooltip:Show()
            end)
            row._deleteBtn:SetScript("OnLeave", function(self) self._tex:SetAlpha(0.5); GameTooltip:Hide() end)
            -- Rename button (pencil)
            row._renameBtn = CreateFrame("Button", nil, row)
            row._renameBtn:SetSize(BTN_SIZE, BTN_SIZE)
            row._renameBtn:SetPoint("RIGHT", row._deleteBtn, "LEFT", -2, 0)
            row._renameBtn._tex = row._renameBtn:CreateTexture(nil, "ARTWORK")
            row._renameBtn._tex:SetAllPoints()
            row._renameBtn._tex:SetAtlas("Pencil-Icon")
            row._renameBtn:SetScript("OnEnter", function(self)
                self._tex:SetAlpha(1)
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                GameTooltip:SetText(L["TIP_RENAME_LIST"], 0.7, 0.7, 0.7)
                GameTooltip:Show()
            end)
            row._renameBtn:SetScript("OnLeave", function(self) self._tex:SetAlpha(0.5); GameTooltip:Hide() end)
            -- Count text anchors to the left of rename button
            row._countText:SetPoint("RIGHT", row._renameBtn, "LEFT", -4, 0)
            popup._rows[i] = row
        end

        row:SetSize(popupWidth - 4, ROW_H)
        row:SetPoint("TOPLEFT", 2, -((i - 1) * ROW_H + 2))
        row:Show()

        local isActive = (li.id == activeId)
        if s then
            row._nameText:SetPoint("RIGHT", row._countText, "LEFT", -4, 0)
            ApplyFont(row._nameText, s, "small")
            ApplyFont(row._countText, s, "small")
            if isActive then
                row:SetBackdropColor(s.accent.r, s.accent.g, s.accent.b, 0.25)
                row._nameText:SetTextColor(s.accent.r, s.accent.g, s.accent.b, 1)
            else
                row:SetBackdropColor(0, 0, 0, 0)
                row._nameText:SetTextColor(s.text.r, s.text.g, s.text.b, 1)
            end
            row._countText:SetTextColor(s.text_dim.r, s.text_dim.g, s.text_dim.b, 1)
        else
            if isActive then
                local tc = CONFIG and CONFIG.COLORS and CONFIG.COLORS.PANEL_DARK_BORDER_TEAL or {0.08, 0.72, 0.65, 1}
                row:SetBackdropColor(tc[1], tc[2], tc[3], 0.25)
            else
                row:SetBackdropColor(0, 0, 0, 0)
            end
        end

        row._nameText:SetText(li.name)
        row._countText:SetText(li.count > 0 and tostring(li.count) or "")

        -- Button visibility
        row._renameBtn._tex:SetAlpha(0.5)
        row._renameBtn:Show()
        if canDelete then
            row._deleteBtn._tex:SetAlpha(0.5)
            row._deleteBtn:Show()
        else
            row._deleteBtn:Hide()
        end

        local listId = li.id
        local listName = li.name
        local listCount = #lists

        -- Row click = switch list
        row:SetScript("OnClick", function()
            if HDG.Store:SetActiveShoppingList(listId) then
                popup:Hide()
                HDG_VendorShoppingList.Refresh()
            end
        end)
        -- Rename button
        row._renameBtn:SetScript("OnClick", function()
            popup:Hide()
            StaticPopup_Show("HDG_RENAME_SHOPPING_LIST", nil, nil, { id = listId, name = listName })
        end)
        -- Delete button
        row._deleteBtn:SetScript("OnClick", function()
            if listCount > 1 then
                popup:Hide()
                StaticPopup_Show("HDG_DELETE_SHOPPING_LIST", listName, nil, { id = listId })
            end
        end)

        row:SetScript("OnEnter", function(r)
            local c = GetScheme()
            if c then r:SetBackdropColor(c.button_hover.r, c.button_hover.g, c.button_hover.b, 0.4) end
        end)
        row:SetScript("OnLeave", function(r)
            local c = GetScheme()
            if c then
                if listId == (HDG.Store.state.activeShoppingListId or "default") then
                    r:SetBackdropColor(c.accent.r, c.accent.g, c.accent.b, 0.25)
                else
                    r:SetBackdropColor(0, 0, 0, 0)
                end
            end
        end)
    end

    popup:SetSize(popupWidth, #lists * ROW_H + 4)
    popup:ClearAllPoints()
    popup:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -2)
    popup:Show()
end

-- ============================================================================
-- UPDATE LIST SELECTOR BUTTON TEXT
-- ============================================================================

local function UpdateListDropdown()
    if not listDropdown or not HDG.Store then return end
    local activeList = HDG.Store:GetActiveShoppingList()
    listDropdown._text:SetText(activeList.name or L["HEADER_SHOPPING_LIST"])
end

-- ============================================================================
-- CREATE UI (lazy, on first show)
-- ============================================================================

local function CreatePanel()
    if created then return end
    created = true

    local FW = CONFIG.FLOATING_WIDGET
    local panelWidth = FW.VENDOR_LIST_WIDTH
    local panelHeight = FW.VENDOR_LIST_HEIGHT
    local INSET = 4
    local TITLE_H = FW.VENDOR_LIST_TITLE_HEIGHT
    local SELECTOR_H = FW.VENDOR_LIST_SELECTOR_HEIGHT
    local ATTR_H = FW.VENDOR_LIST_ATTRIBUTION_HEIGHT
    local ACTION_H = FW.VENDOR_LIST_ACTION_BAR_HEIGHT

    widget = HDG.UI:FloatingWidget({
        name = "HDG_VendorShoppingListFrame",
        width = panelWidth,
        height = panelHeight,
        strata = "DIALOG",
        onPositionSaved = function(point, relativePoint, x, y)
            HDG.Store:SetUI("vendorListPosition", { point, relativePoint, x, y })
        end,
    })

    local frame = widget.frame
    local scheme = GetScheme()

    -- Restore saved position
    local pos = HDG.Store:GetState().ui.vendorListPosition
    if pos and pos[1] then
        frame:ClearAllPoints()
        frame:SetPoint(pos[1], UIParent, pos[2] or pos[1], pos[3] or 0, pos[4] or 0)
    end

    frame:Hide()

    -- Vertical resize
    frame:SetResizable(true)
    frame:SetResizeBounds(panelWidth, panelHeight, panelWidth, 800)
    local savedH = HDG.Store and HDG.Store:GetState().ui.vendorListHeight
    if savedH then frame:SetHeight(savedH) end

    local grip = CreateFrame("Button", nil, frame)
    grip:SetHeight(16)
    grip:SetPoint("BOTTOMLEFT", 0, -6)
    grip:SetPoint("BOTTOMRIGHT", 0, -6)
    grip:EnableMouse(true)
    grip:SetScript("OnMouseDown", function() frame:StartSizing("BOTTOM") end)
    grip:SetScript("OnMouseUp", function()
        frame:StopMovingOrSizing()
        HDG.Store:SetUI("vendorListHeight", math.floor(frame:GetHeight() + 0.5))
    end)

    local gripLine = grip:CreateTexture(nil, "ARTWORK")
    gripLine:SetHeight(2)
    gripLine:SetPoint("LEFT", grip, "LEFT", 4, 3)
    gripLine:SetPoint("RIGHT", grip, "RIGHT", -4, 3)
    gripLine:SetTexture("Interface\\Buttons\\WHITE8x8")
    gripLine:SetVertexColor(1, 1, 1, 0.1)

    local gripIcon = grip:CreateTexture(nil, "ARTWORK")
    gripIcon:SetSize(36, 12)
    gripIcon:SetPoint("CENTER", grip, "CENTER", 0, -1)
    gripIcon:SetAtlas("lootroll-resizehandle")
    grip:SetScript("OnEnter", function() gripLine:SetVertexColor(1, 1, 1, 0.3) end)
    grip:SetScript("OnLeave", function() gripLine:SetVertexColor(1, 1, 1, 0.1) end)

    -- Dark background fill with warm wood-tone border (override thin BACKDROP_FLAT edge)
    frame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 2,
        insets = { left = 0, right = 0, top = 0, bottom = 0 }
    })
    frame:SetBackdropColor(unpack(CONFIG.COLORS.PANEL_DARK))
    frame:SetBackdropBorderColor(unpack(CONFIG.COLORS.PANEL_DARK_BORDER_WARM))

    -- ESC key closes (unless pinned)
    if not (HDG.Store and HDG.Store.state and HDG.Store.state.ui and HDG.Store.state.ui.vendorListPinned) then
        table.insert(UISpecialFrames, "HDG_VendorShoppingListFrame")
    end

    -- ====================================================================
    -- TITLE BAR
    -- ====================================================================
    local titleBar = CreateFrame("Frame", nil, frame)
    titleBar:SetPoint("TOPLEFT", INSET, -INSET)
    titleBar:SetPoint("TOPRIGHT", -INSET, -INSET)
    titleBar:SetHeight(TITLE_H)
    titleBar:SetFrameLevel(frame:GetFrameLevel() + 5)

    -- Title bar background
    local titleBg = titleBar:CreateTexture(nil, "BACKGROUND")
    titleBg:SetAtlas("catalog-nav-bg-primary")
    titleBg:SetAllPoints()
    titleBg:SetAlpha(0.8)

    -- Cart icon (tinted accent)
    local cartIcon = titleBar:CreateTexture(nil, "ARTWORK")
    cartIcon:SetSize(21, 21)
    cartIcon:SetPoint("LEFT", 8, 0)
    cartIcon:SetAtlas("Perks-ShoppingCart")
    if scheme then cartIcon:SetVertexColor(scheme.accent.r, scheme.accent.g, scheme.accent.b) end

    -- Title text
    local titleText = titleBar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    titleText:SetPoint("LEFT", cartIcon, "RIGHT", 6, 0)
    if scheme then
        ApplyFont(titleText, scheme, "body")
        titleText:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b)
    end
    titleText:SetText(L["HEADER_SHOPPING_LIST"])

    -- Summary text (item count + gold) right-aligned
    titleSummary = titleBar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    titleSummary:SetPoint("RIGHT", -48, 0)
    titleSummary:SetJustifyH("RIGHT")
    if scheme then ApplyFont(titleSummary, scheme, "small") end
    titleSummary:SetText("")

    -- Pin button (persist through Escape + reload)
    local pinBtn = CreateFrame("Button", nil, titleBar)
    pinBtn:SetSize(16, 16)
    pinBtn:SetPoint("RIGHT", -26, 0)
    pinBtn:SetFrameLevel(titleBar:GetFrameLevel() + 2)
    local pinIcon = pinBtn:CreateTexture(nil, "ARTWORK")
    pinIcon:SetAllPoints()
    pinIcon:SetAtlas("friendslist-recentallies-Pin-yellow")
    pinBtn.icon = pinIcon

    local function UpdatePinVisual()
        local isPinned = HDG.Store and HDG.Store.state and HDG.Store.state.ui and HDG.Store.state.ui.vendorListPinned
        pinIcon:SetDesaturated(not isPinned)
        pinIcon:SetVertexColor(isPinned and 1 or 0.6, isPinned and 1 or 0.6, isPinned and 0 or 0.6, 1)
    end
    UpdatePinVisual()

    pinBtn:SetScript("OnClick", function()
        local isPinned = HDG.Store:GetState().ui.vendorListPinned
        local newPinned = not isPinned
        HDG.Store:SetUI("vendorListPinned", newPinned)
        UpdatePinVisual()
        local frameName = "HDG_VendorShoppingListFrame"
        if newPinned then
            for i = #UISpecialFrames, 1, -1 do
                if UISpecialFrames[i] == frameName then table.remove(UISpecialFrames, i); break end
            end
        else
            local found = false
            for _, v in ipairs(UISpecialFrames) do if v == frameName then found = true; break end end
            if not found then table.insert(UISpecialFrames, frameName) end
        end
    end)
    pinBtn:SetScript("OnEnter", function(self)
        local isPinned = HDG.Store.state.ui.vendorListPinned
        pinIcon:SetVertexColor(isPinned and 1 or 0.85, isPinned and 1 or 0.85, isPinned and 0.7 or 0.85, 1)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(L["TIP_PIN_WINDOW"], 1, 1, 1)
        if isPinned then
            GameTooltip:AddLine(L["TIP_PINNED_DESC"], 0.7, 0.7, 0.7)
        else
            GameTooltip:AddLine(L["TIP_CLICK_PIN"], 0.7, 0.7, 0.7)
        end
        GameTooltip:Show()
    end)
    pinBtn:SetScript("OnLeave", function() UpdatePinVisual(); GameTooltip:Hide() end)

    -- Close button
    local closeBtn = CreateFrame("Button", nil, titleBar)
    closeBtn:SetSize(16, 16)
    closeBtn:SetPoint("RIGHT", -6, 0)
    closeBtn:SetFrameLevel(titleBar:GetFrameLevel() + 2)
    local closeTex = closeBtn:CreateTexture(nil, "ARTWORK")
    closeTex:SetSize(16, 16)
    closeTex:SetPoint("CENTER")
    closeTex:SetAtlas("XMarksTheSpot")
    closeBtn:SetScript("OnClick", function() frame:Hide() end)
    HDG_UIHelpers.SetupTooltip(closeBtn, { title = L["TIP_CLOSE"], anchor = "ANCHOR_RIGHT" })

    -- ====================================================================
    -- LIST SELECTOR BAR
    -- ====================================================================
    local selectorBar = CreateFrame("Frame", nil, frame)
    selectorBar:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 0, 0)
    selectorBar:SetPoint("TOPRIGHT", titleBar, "BOTTOMRIGHT", 0, 0)
    selectorBar:SetHeight(SELECTOR_H)
    selectorBar:SetFrameLevel(frame:GetFrameLevel() + 5)

    -- Selector background (slightly darker)
    local selBg = selectorBar:CreateTexture(nil, "BACKGROUND")
    selBg:SetColorTexture(0.06, 0.06, 0.08, 0.9)
    selBg:SetAllPoints()

    -- Selector button (acts as dropdown trigger)
    local selectorBtnW = panelWidth - 2 * INSET - 58
    local selectorBtn = CreateFrame("Button", nil, selectorBar, "BackdropTemplate")
    selectorBtn:SetSize(selectorBtnW, SELECTOR_H - 4)
    selectorBtn:SetPoint("LEFT", 4, 0)
    selectorBtn:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
    if scheme then
        selectorBtn:SetBackdropColor(scheme.panel.r, scheme.panel.g, scheme.panel.b, 0.8)
        selectorBtn:SetBackdropBorderColor(scheme.border.r, scheme.border.g, scheme.border.b, 0.6)
    else
        local rc = CONFIG and CONFIG.COLORS and CONFIG.COLORS.PANEL_ROW or {0.12, 0.12, 0.15, 1}
        local bc2 = CONFIG and CONFIG.COLORS and CONFIG.COLORS.PANEL_DARK_BORDER_COOL or {0.29, 0.33, 0.41, 0.8}
        selectorBtn:SetBackdropColor(rc[1], rc[2], rc[3], 0.8)
        selectorBtn:SetBackdropBorderColor(bc2[1], bc2[2], bc2[3], 0.6)
    end

    local selText = selectorBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    selText:SetPoint("LEFT", 6, 0)
    selText:SetPoint("RIGHT", -16, 0)
    selText:SetJustifyH("LEFT")
    selText:SetWordWrap(false)
    if scheme then
        ApplyFont(selText, scheme, "small")
        selText:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, 1)
    end
    selText:SetText(L["HEADER_SHOPPING_LIST"])
    selectorBtn._text = selText

    -- Dropdown arrow (collapseExpand atlas, flips when popup open)
    local selArrow = selectorBtn:CreateTexture(nil, "OVERLAY")
    selArrow:SetSize(20, 20)
    selArrow:SetPoint("RIGHT", -3, 0)
    selArrow:SetAtlas("common-button-collapseExpand-down")
    selectorBtn._arrow = selArrow

    selectorBtn:SetScript("OnClick", function(self)
        ShowListSelectorPopup(self)
        local isOpen = listSelectorPopup and listSelectorPopup:IsShown()
        selArrow:SetAtlas(isOpen and "common-button-collapseExpand-up" or "common-button-collapseExpand-down")
    end)
    selectorBtn:SetScript("OnEnter", function(self)
        local c = GetScheme()
        if c then self:SetBackdropColor(c.button_hover.r, c.button_hover.g, c.button_hover.b, 0.8) end
    end)
    selectorBtn:SetScript("OnLeave", function(self)
        local c = GetScheme()
        if c then self:SetBackdropColor(c.panel.r, c.panel.g, c.panel.b, 0.8) end
    end)
    listDropdown = selectorBtn

    -- [+] Create new list button
    local addBtn = CreateFrame("Button", nil, selectorBar)
    addBtn:SetSize(24, 24)
    addBtn:SetPoint("LEFT", selectorBtn, "RIGHT", 2, 0)
    addBtn:SetFrameLevel(selectorBar:GetFrameLevel() + 2)
    local addText = addBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    addText:SetPoint("CENTER", 0, 1)
    addText:SetText("|cFFe2e8f0+|r")
    addBtn:SetHighlightTexture("Interface\\Buttons\\UI-PlusButton-Hilight")
    addBtn:SetScript("OnClick", function()
        StaticPopup_Show("HDG_NEW_SHOPPING_LIST")
    end)
    HDG_UIHelpers.SetupTooltip(addBtn, { title = L["TIP_CREATE_NEW_LIST"], anchor = "ANCHOR_RIGHT" })

    -- Gear button (zone scanner settings)
    local gearBtn = CreateFrame("Button", nil, selectorBar)
    gearBtn:SetSize(24, 24)
    gearBtn:SetPoint("LEFT", addBtn, "RIGHT", 2, 0)
    gearBtn:SetFrameLevel(selectorBar:GetFrameLevel() + 2)
    local gearTex = gearBtn:CreateTexture(nil, "ARTWORK")
    gearTex:SetSize(20, 20)
    gearTex:SetPoint("CENTER")
    gearTex:SetAtlas("decor-controls-settings-default")
    local gearPopup
    gearBtn:SetScript("OnClick", function()
        if not gearPopup then gearPopup = HDG_ZoneMode.CreateGearPopup(gearBtn) end
        gearPopup:SetShown(not gearPopup:IsShown())
    end)
    HDG_UIHelpers.SetupTooltip(gearBtn, { title = L["TIP_SHOPPING_LIST_SETTINGS"] or "Settings", anchor = "ANCHOR_RIGHT" })

    -- ====================================================================
    -- ATTRIBUTION BANNER (conditional, shown when imported from external source)
    -- ====================================================================
    attrBanner = CreateFrame("Frame", nil, frame)
    attrBanner:SetPoint("TOPLEFT", selectorBar, "BOTTOMLEFT", 0, 0)
    attrBanner:SetPoint("TOPRIGHT", selectorBar, "BOTTOMRIGHT", 0, 0)
    attrBanner:SetHeight(ATTR_H)
    attrBanner:SetFrameLevel(frame:GetFrameLevel() + 5)
    attrBanner:Hide()

    -- Teal accent background
    local attrBg = attrBanner:CreateTexture(nil, "BACKGROUND")
    attrBg:SetColorTexture(0.08, 0.72, 0.65, 0.10)
    attrBg:SetAllPoints()

    -- Left accent bar (2px teal stripe)
    local attrBar = attrBanner:CreateTexture(nil, "ARTWORK")
    attrBar:SetColorTexture(0.08, 0.72, 0.65, 0.8)
    attrBar:SetSize(2, ATTR_H)
    attrBar:SetPoint("TOPLEFT", 0, 0)

    -- Source icon (Wowhead logo or generic)
    attrIcon = attrBanner:CreateTexture(nil, "ARTWORK")
    attrIcon:SetSize(20, 20)
    attrIcon:SetPoint("LEFT", 8, 0)

    -- Attribution text
    attrText = attrBanner:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    attrText:SetPoint("LEFT", attrIcon, "RIGHT", 6, 0)
    attrText:SetJustifyH("LEFT")
    if scheme then ApplyFont(attrText, scheme, "small") end

    attrUrlBtn = CreateFrame("Button", nil, attrBanner)
    attrUrlBtn:SetSize(22, 22)
    attrUrlBtn:SetPoint("RIGHT", -26, 0)
    local urlIcon = attrUrlBtn:CreateTexture(nil, "ARTWORK")
    urlIcon:SetAllPoints()
    urlIcon:SetTexture("Interface\\AddOns\\HousingDecorGuide\\textures\\url_link")
    attrUrlBtn:SetScript("OnClick", function() ShowUrlPopup(attrUrlBtn.url) end)
    attrUrlBtn:SetScript("OnEnter", function(self)
        urlIcon:SetAlpha(0.7)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(L["TIP_OPEN_SOURCE_URL"], 1, 1, 1)
        GameTooltip:Show()
    end)
    attrUrlBtn:SetScript("OnLeave", function() urlIcon:SetAlpha(1); GameTooltip:Hide() end)
    attrUrlBtn:Hide()

    attrText:SetPoint("RIGHT", attrUrlBtn, "LEFT", -4, 0)

    -- Divider between header and scroll content
    local divider = frame:CreateTexture(nil, "ARTWORK")
    divider:SetAtlas("controls-frame-guest")
    divider:SetHeight(2)

    -- ====================================================================
    -- SCROLL FRAME
    -- ====================================================================
    scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
    scrollFrame:SetFrameLevel(frame:GetFrameLevel() + 3)

    scrollContent = CreateFrame("Frame", nil, scrollFrame)
    scrollContent:SetWidth(panelWidth - 2 * INSET - 20)
    scrollContent:SetHeight(1)
    scrollFrame:SetScrollChild(scrollContent)

    -- Style scrollbar with minimal-scrollbar atlas
    local scrollBar = scrollFrame.ScrollBar
    if scrollBar then
        scrollBar:SetWidth(8)
        if scrollBar.ScrollUpButton then scrollBar.ScrollUpButton:SetAlpha(0); scrollBar.ScrollUpButton:SetSize(1, 1) end
        if scrollBar.ScrollDownButton then scrollBar.ScrollDownButton:SetAlpha(0); scrollBar.ScrollDownButton:SetSize(1, 1) end
        local trackBg = scrollBar:CreateTexture(nil, "BACKGROUND")
        trackBg:SetAtlas("!minimal-scrollbar-track-middle")
        trackBg:SetPoint("TOPLEFT", 0, 0)
        trackBg:SetPoint("BOTTOMRIGHT", 0, 0)
        trackBg:SetAlpha(0.3)
        local thumb = scrollBar:GetThumbTexture()
        if thumb then
            thumb:SetAtlas("minimal-scrollbar-thumb-middle")
            thumb:SetWidth(8)
            thumb:SetAlpha(0.7)
        end
    end

    -- ====================================================================
    -- ACTION BAR (bottom)
    -- ====================================================================
    actionBar = CreateFrame("Frame", nil, frame)
    actionBar:SetPoint("BOTTOMLEFT", INSET, INSET)
    actionBar:SetPoint("BOTTOMRIGHT", -INSET, INSET)
    actionBar:SetHeight(ACTION_H)
    actionBar:SetFrameLevel(frame:GetFrameLevel() + 5)

    -- Top separator line
    local actionSep = actionBar:CreateTexture(nil, "ARTWORK")
    if scheme then
        actionSep:SetColorTexture(scheme.border.r, scheme.border.g, scheme.border.b, 0.5)
    else
        actionSep:SetColorTexture(0.29, 0.33, 0.41, 0.5)
    end
    actionSep:SetHeight(1)
    actionSep:SetPoint("TOPLEFT", 0, 0)
    actionSep:SetPoint("TOPRIGHT", 0, 0)

    local btnH = 20
    local smallBtnW = 65

    -- Map All button (left)
    actionMapBtn = HDG.UI:Button(actionBar, "Map All", smallBtnW, btnH, function()
        HDG_VendorShoppingList.MapListWaypoints()
    end)
    local CORNER_PAD = 30
    actionMapBtn:SetPoint("BOTTOMLEFT", CORNER_PAD, 2)
    local mapBtnIcon = actionMapBtn:CreateTexture(nil, "ARTWORK")
    mapBtnIcon:SetSize(12, 12)
    mapBtnIcon:SetPoint("LEFT", 4, 0)
    mapBtnIcon:SetAtlas("Waypoint-MapPin-ChatIcon")
    local mapBtnText = actionMapBtn:GetFontString()
    if mapBtnText then mapBtnText:SetPoint("CENTER", 6, 0) end

    -- Clear button
    actionClearBtn = HDG.UI:Button(actionBar, "Clear", smallBtnW, btnH, function()
        HDG.Store:ClearVendorShoppingList()
        HDG_VendorShoppingList.Refresh()
    end)
    actionClearBtn:SetPoint("LEFT", actionMapBtn, "RIGHT", 4, 0)

    -- Import button (right)
    actionImportBtn = HDG.UI:Button(actionBar, "Import", smallBtnW, btnH, function()
        HDG_VendorShoppingList.ShowExportImport("import")
    end)
    actionImportBtn:SetPoint("BOTTOMRIGHT", -CORNER_PAD, 2)

    -- Export button (left of Import)
    actionExportBtn = HDG.UI:Button(actionBar, "Export", smallBtnW, btnH, function()
        HDG_VendorShoppingList.ShowExportImport("export")
    end)
    actionExportBtn:SetPoint("RIGHT", actionImportBtn, "LEFT", -4, 0)

    -- ====================================================================
    -- DYNAMIC LAYOUT (called on attribution visibility change)
    -- ====================================================================
    widget._updateLayout = function()
        local anchor = attrBanner:IsShown() and attrBanner or selectorBar
        divider:ClearAllPoints()
        divider:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", INSET, -5)
        divider:SetPoint("TOPRIGHT", anchor, "BOTTOMRIGHT", -INSET, -5)
        scrollFrame:ClearAllPoints()
        scrollFrame:SetPoint("TOPLEFT", divider, "BOTTOMLEFT", -INSET, -5)
        scrollFrame:SetPoint("BOTTOMRIGHT", actionBar, "TOPRIGHT", -16, 2)
    end

    widget._updateLayout()

end

-- ============================================================================
-- HELPERS (content rendering)
-- ============================================================================

local function GetSourceBadge(sourceType)
    local CONFIG = privateDB and privateDB.CONFIG
    local info = CONFIG and CONFIG.SOURCE_TYPES and CONFIG.SOURCE_TYPES[sourceType]
    return (info and info.short) or ""
end

local urlPopupFrame
ShowUrlPopup = function(url)
    if not url or url == "" then return end
    if not urlPopupFrame then
        local f = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
        f:SetSize(340, 50)
        f:SetFrameStrata("TOOLTIP")
        f:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 2, insets = { left = 0, right = 0, top = 0, bottom = 0 } })
        f:SetBackdropColor(unpack(CONFIG.COLORS.PANEL_DARK))
        f:SetBackdropBorderColor(unpack(CONFIG.COLORS.PANEL_DARK_BORDER_WARM))
        f:EnableMouse(true)
        f:SetClampedToScreen(true)
        local eb = CreateFrame("EditBox", nil, f)
        eb:SetPoint("TOPLEFT", 8, -8)
        eb:SetPoint("BOTTOMRIGHT", -8, 8)
        eb:SetFontObject(GameFontHighlightSmall)
        eb:SetAutoFocus(false)
        eb:SetScript("OnEscapePressed", function() f:Hide() end)
        eb:SetScript("OnEditFocusLost", function() f:Hide() end)
        f.eb = eb
        urlPopupFrame = f
    end
    urlPopupFrame:ClearAllPoints()
    urlPopupFrame:SetPoint("TOP", attrBanner, "BOTTOM", 0, -2)
    urlPopupFrame.eb:SetText(url)
    urlPopupFrame:Show()
    urlPopupFrame.eb:HighlightText()
    urlPopupFrame.eb:SetFocus()
end

-- Helper: ensure main HDG window is open before cross-tab navigation
local function EnsureMainFrameOpen()
    if not HDG_UI then return end
    if HDG_UI.CreateUI then HDG_UI.CreateUI() end -- no-op if already created
    local f = _G["HDG_MainFrame"]
    if f and not f:IsShown() then f:Show() end
end

-- ============================================================================
-- LIST MODE
-- ============================================================================

local function RefreshListMode()
    if not HDG.Store then return end

    local list = HDG.Store:GetActiveShoppingList().items
    local FW = CONFIG.FLOATING_WIDGET

    ReleaseAllRows()

    if #list == 0 then
        -- Empty state with icon and helper text
        local emptyH = 80
        local row = GetPooledRow(scrollContent, emptyH)
        row:SetPoint("TOPLEFT", 0, 0)
        row:SetPoint("RIGHT", scrollContent, "RIGHT", 0, 0)
        row:SetBackdropColor(0, 0, 0, 0)
        row:SetBackdropBorderColor(0, 0, 0, 0)

        row.iconTex:ClearAllPoints()
        row.iconTex:SetSize(32, 32)
        row.iconTex:SetPoint("TOP", row, "TOP", 0, -8)
        row.iconTex:SetAtlas("Professions-Icon-Quality-Tier1-Small")
        local scheme = GetScheme()
        if scheme then row.iconTex:SetVertexColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b) end
        row.iconTex:Show()

        row.nameText:ClearAllPoints()
        row.nameText:SetPoint("TOP", row.iconTex, "BOTTOM", 0, -6)
        row.nameText:SetPoint("LEFT", 16, 0)
        row.nameText:SetPoint("RIGHT", -16, 0)
        row.nameText:SetJustifyH("CENTER")
        row.nameText:SetText("|cFFe2e8f0" .. L["STATUS_SHOPPING_LIST_EMPTY"] .. "|r\n" .. HDG.Theme.C_Dim() .. L["STATUS_SHOPPING_LIST_HINT"] .. "|r")
        row.nameText:SetWordWrap(true)
        scrollContent:SetHeight(emptyH)
        return
    end

    -- Build zone -> vendor -> items hierarchy + wish list for nil-npcID entries
    local zones = {}
    local zoneOrder = {}
    local wishListItems = {} -- entries with nil npcID
    for _, entry in ipairs(list) do
        if not entry.npcID then
            table.insert(wishListItems, entry)
        else
            local npcID = entry.npcID
            local ve = HDG_VendorDB and HDG_VendorDB[npcID]
            local zoneName = ve and ve[2] or "Unknown"
            if not zones[zoneName] then
                zones[zoneName] = { vendors = {} }
                table.insert(zoneOrder, zoneName)
            end
            if not zones[zoneName].vendors[npcID] then
                zones[zoneName].vendors[npcID] = { items = {}, totalGold = 0, currencies = {} }
            end
            local ie = ve and ve.items and ve.items[entry.itemID]
            local goldCost = ie and ie[1] or 0
            local entryQty = entry.qty or 1
            table.insert(zones[zoneName].vendors[npcID].items, entry)
            zones[zoneName].vendors[npcID].totalGold = zones[zoneName].vendors[npcID].totalGold + goldCost * entryQty
            if ie and ie[5] then
                for _, ct in ipairs(ie[5]) do
                    if ct[1] == "c" then
                        local cid = ct[2]
                        zones[zoneName].vendors[npcID].currencies[cid] = (zones[zoneName].vendors[npcID].currencies[cid] or 0) + ct[3] * entryQty
                    end
                end
            end
        end
    end
    table.sort(zoneOrder)

    local yOffset = 0
    local contentWidth = scrollContent:GetWidth()
    local scheme = GetScheme()

    for _, zoneName in ipairs(zoneOrder) do
        local zoneData = zones[zoneName]

        -- Zone totals (gold + currencies)
        local zoneTotalGold = 0
        local zoneCurrencies = {}
        for _, vd in pairs(zoneData.vendors) do
            zoneTotalGold = zoneTotalGold + vd.totalGold
            for cid, amt in pairs(vd.currencies) do
                zoneCurrencies[cid] = (zoneCurrencies[cid] or 0) + amt
            end
        end

        -- Zone header row
        local isZoneCollapsed = collapsedZones[zoneName]
        local zRow = GetPooledRow(scrollContent, FW.VENDOR_LIST_ZONE_HEADER_HEIGHT)
        zRow:SetPoint("TOPLEFT", 0, -yOffset)
        zRow:SetPoint("RIGHT", scrollContent, "RIGHT", 0, 0)
        if scheme then
            zRow:SetBackdropColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.15)
            zRow:SetBackdropBorderColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.3)
        end

        -- Collapse/expand arrow
        zRow.expandArrow:ClearAllPoints()
        zRow.expandArrow:SetPoint("LEFT", 2, 0)
        if isZoneCollapsed then
            zRow.expandArrow:SetNormalAtlas("common-button-collapseExpand-up")
            zRow.expandArrow:SetPushedAtlas("common-button-collapseExpand-up-pressed")
        else
            zRow.expandArrow:SetNormalAtlas("common-button-collapseExpand-down")
            zRow.expandArrow:SetPushedAtlas("common-button-collapseExpand-down-pressed")
        end
        zRow.expandArrow:Show()
        local zToggleName = zoneName
        local function toggleZone()
            collapsedZones[zToggleName] = not collapsedZones[zToggleName]
            HDG_VendorShoppingList.Refresh()
        end
        zRow.expandArrow:SetScript("OnClick", toggleZone)
        zRow:SetScript("OnClick", toggleZone)

        zRow.nameText:ClearAllPoints()
        zRow.nameText:SetPoint("LEFT", 20, 0)
        zRow.nameText:SetWidth(contentWidth - 74)
        zRow.nameText:SetJustifyH("LEFT")
        zRow.nameText:SetWordWrap(false)
        zRow.nameText:SetText("|cFFe2e8f0" .. zoneName .. "|r")
        if scheme then ApplyFont(zRow.nameText, scheme, "body", 13) end
        local costParts = {}
        if zoneTotalGold > 0 then table.insert(costParts, FormatGoldCompact(zoneTotalGold)) end
        for cid, amt in pairs(zoneCurrencies) do
            local info = GetCachedCurrencyInfo(cid)
            local icon = info and info.iconFileID
            local iconStr = icon and string.format("|T%s:12:12|t", icon) or ""
            table.insert(costParts, iconStr .. HDG.Theme.C_Accent() .. amt .. "|r")
        end
        zRow.rightText:ClearAllPoints()
        zRow.rightText:SetPoint("RIGHT", -22, 0)
        zRow.rightText:SetText(table.concat(costParts, " "))
        if scheme then ApplyFont(zRow.rightText, scheme, "small") end

        -- Pin-all button for this zone
        zRow.pinBtn:ClearAllPoints()
        zRow.pinBtn:SetPoint("RIGHT", -2, 0)
        zRow.pinBtn:Show()
        local zoneVendorData = zoneData
        zRow.pinBtn:SetScript("OnClick", function()
            local count = 0
            for nid, _ in pairs(zoneVendorData.vendors) do
                local vdb = HDG_VendorDB and HDG_VendorDB[nid]
                if vdb and vdb[4] and vdb[4] > 0 then
                    local vName = HDG_VendorResolver.GetVendorNameByID(nid) or "Vendor"
                    SetWaypoint(vdb[3], vdb[4], vdb[5], vName)
                    count = count + 1
                end
            end
            if count > 0 then
                print(HDG.Theme.C_Success() .. "[HDG]|r Pinned " .. HDG.Theme.C_Accent() .. count .. "|r vendors")
            end
        end)
        HDG_UIHelpers.SetupTooltip(zRow.pinBtn, {
            title  = string.format(L["TIP_PIN_VENDORS_IN"], zoneName),
            anchor = "ANCHOR_RIGHT",
        })
        yOffset = yOffset + FW.VENDOR_LIST_ZONE_HEADER_HEIGHT

        if isZoneCollapsed then
            -- skip to next zone
        else
        -- Sort vendors by name
        local vendorOrder = {}
        for npcID, _ in pairs(zoneData.vendors) do table.insert(vendorOrder, npcID) end
        table.sort(vendorOrder, SortVendorsByName)

        for _, npcID in ipairs(vendorOrder) do
            local vData = zoneData.vendors[npcID]
            local vendorName = HDG_VendorResolver.GetVendorNameByID(npcID)
            local ve = HDG_VendorDB and HDG_VendorDB[npcID]
            local isExpanded = expandedVendors[npcID] ~= false

            -- Vendor row
            local vRow = GetPooledRow(scrollContent, FW.VENDOR_LIST_VENDOR_ROW_HEIGHT)
            vRow:SetPoint("TOPLEFT", FW.VENDOR_LIST_INDENT_VENDOR, -yOffset)
            vRow:SetPoint("RIGHT", scrollContent, "RIGHT", 0, 0)
            if scheme then
                vRow:SetBackdropColor(scheme.bg.r, scheme.bg.g, scheme.bg.b, 0.3)
                vRow:SetBackdropBorderColor(0, 0, 0, 0)
            end

            -- Expand arrow (atlas)
            vRow.expandArrow:ClearAllPoints()
            vRow.expandArrow:SetPoint("LEFT", 2, 0)
            if isExpanded then
                vRow.expandArrow:SetNormalAtlas("common-button-collapseExpand-down")
                vRow.expandArrow:SetPushedAtlas("common-button-collapseExpand-down-pressed")
            else
                vRow.expandArrow:SetNormalAtlas("common-button-collapseExpand-up")
                vRow.expandArrow:SetPushedAtlas("common-button-collapseExpand-up-pressed")
            end
            vRow.expandArrow:Show()

            vRow.nameText:ClearAllPoints()
            vRow.nameText:SetPoint("LEFT", 18, 0)
            vRow.nameText:SetWidth(contentWidth - FW.VENDOR_LIST_INDENT_VENDOR - 64)
            vRow.nameText:SetText(HDG.Theme.C_Accent() .. vendorName .. "|r")
            if scheme then ApplyFont(vRow.nameText, scheme, "body", 13) end

            -- Item count badge
            vRow.badgeText:ClearAllPoints()
            vRow.badgeText:SetPoint("LEFT", vRow.nameText, "RIGHT", 4, 0)
            vRow.badgeText:SetText(HDG.Theme.C_Dim() .. "(" .. #vData.items .. ")|r")
            vRow.badgeText:Show()

            -- Store per-row data for shared handlers
            vRow._npcID = npcID
            vRow._vendorName = vendorName
            vRow._ve = ve

            if not vRow._handlersSet then
                vRow.expandArrow:SetScript("OnClick", function(self)
                    local row = self:GetParent()
                    expandedVendors[row._npcID] = not (expandedVendors[row._npcID] ~= false)
                    HDG_VendorShoppingList.Refresh()
                end)
                vRow:SetScript("OnClick", function(self)
                    if IsShiftKeyDown() then
                        local v = self._ve
                        if v then
                            local ok, provider = SetWaypoint(v[3], v[4], v[5], self._vendorName)
                            if ok then
                                HDG.Waypoints:PrintResult(true, provider, self._vendorName, nil, v[3], v[6])
                            end
                        end
                    else
                        expandedVendors[self._npcID] = not (expandedVendors[self._npcID] ~= false)
                        HDG_VendorShoppingList.Refresh()
                    end
                end)
                vRow:SetScript("OnEnter", function(self)
                    local s = GetScheme()
                    if s then self:SetBackdropColor(s.button_hover.r, s.button_hover.g, s.button_hover.b, 0.3) end
                    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                    GameTooltip:SetText(self._vendorName, 1, 1, 1)
                    local v = self._ve
                    if v then
                        GameTooltip:AddLine(v[2] or "Unknown Zone", 0.7, 0.7, 0.7)
                        if v[4] and v[5] and v[4] > 0 then
                            GameTooltip:AddLine(string.format("%.1f, %.1f", v[4], v[5]), 0.78, 0.78, 0.78)
                        end
                    end
                    GameTooltip:AddLine(" ")
                    GameTooltip:AddLine(L["TIP_CLICK_EXPAND_COLLAPSE"], 0.78, 0.78, 0.78)
                    GameTooltip:AddLine(L["TIP_SHIFTCLICK_WAYPOINT"], 0.78, 0.78, 0.78)
                    GameTooltip:Show()
                end)
                vRow:SetScript("OnLeave", function(self)
                    local s = GetScheme()
                    if s then self:SetBackdropColor(s.bg.r, s.bg.g, s.bg.b, 0.3) end
                    GameTooltip:Hide()
                end)
                vRow._handlersSet = true
            end
            yOffset = yOffset + FW.VENDOR_LIST_VENDOR_ROW_HEIGHT

            -- Item rows (only if expanded)
            if isExpanded then
                -- Sort items by name
                table.sort(vData.items, SortItemsByName)

                for _, entry in ipairs(vData.items) do
                    local iRow = GetPooledRow(scrollContent, FW.VENDOR_LIST_ITEM_ROW_HEIGHT)
                    iRow:SetPoint("TOPLEFT", FW.VENDOR_LIST_INDENT_ITEM, -yOffset)
                    iRow:SetPoint("RIGHT", scrollContent, "RIGHT", 0, 0)
                    if scheme then
                        iRow:SetBackdropColor(scheme.panel.r, scheme.panel.g, scheme.panel.b, 0.3)
                        iRow:SetBackdropBorderColor(0, 0, 0, 0)
                    end

                    -- Icon
                    iRow.iconTex:SetSize(FW.VENDOR_LIST_ITEM_ICON_SIZE, FW.VENDOR_LIST_ITEM_ICON_SIZE)
                    iRow.iconTex:Show()
                    -- Name (with qty prefix) -- set immediately, update on load
                    local qty = entry.qty or 1
                    local qtyPrefix = qty > 1 and (HDG.Theme.C_Accent() .. qty .. "x|r ") or ""
                    local itemName = HDG_Data.GetCachedItemName(entry.itemID) or ("Item " .. entry.itemID)
                    iRow.nameText:ClearAllPoints()
                    iRow.nameText:SetPoint("LEFT", iRow.iconTex, "RIGHT", 4, 0)
                    iRow.nameText:SetWidth(contentWidth - FW.VENDOR_LIST_INDENT_ITEM - 70)
                    iRow.nameText:SetText(qtyPrefix .. itemName)
                    iRow.nameText:SetJustifyH("LEFT")
                    iRow.nameText:SetWordWrap(false)
                    if scheme then ApplyFont(iRow.nameText, scheme, "small") end

                    iRow._assignedItemID = entry.itemID
                    local cachedName, _, _, _, _, _, _, _, _, cachedIcon = C_Item.GetItemInfo(entry.itemID)
                    if cachedIcon then
                        iRow.iconTex:SetTexture(cachedIcon)
                        if cachedName then iRow.nameText:SetText(qtyPrefix .. cachedName) end
                    else
                        local item = Item:CreateFromItemID(entry.itemID)
                        local iRowRef = iRow
                        local expectedID = entry.itemID
                        item:ContinueOnItemLoad(function()
                            if iRowRef.inUse and iRowRef._assignedItemID == expectedID then
                                local name, _, _, _, _, _, _, _, _, ic = C_Item.GetItemInfo(expectedID)
                                if ic then iRowRef.iconTex:SetTexture(ic) end
                                if name then iRowRef.nameText:SetText(qtyPrefix .. name) end
                            end
                        end)
                    end

                    local itemEntry = ve and ve.items and ve.items[entry.itemID]

                    -- Gate badge (Rep/Quest/Achieve/Race)
                    local repReq = itemEntry and itemEntry[2] and itemEntry[2] > 0
                    local questReq = itemEntry and itemEntry[6] and itemEntry[6] > 0
                    local achieveReq = itemEntry and itemEntry[7] and itemEntry[7] > 0
                    local raceReq = ve and ve.race and #ve.race > 0
                    if repReq or questReq or achieveReq or raceReq then
                        local badge = repReq and "Rep" or (questReq and "Quest" or (achieveReq and "Achieve" or "Race"))
                        iRow.badgeBtn.text:SetText(badge)
                        iRow.badgeBtn:Show()
                    end

                    -- Store per-row data for shared handlers
                    iRow._itemID = entry.itemID
                    iRow._npcID = entry.npcID or npcID
                    iRow._itemEntry = itemEntry

                    iRow.removeBtn:ClearAllPoints()
                    iRow.removeBtn:SetPoint("RIGHT", -2, 0)
                    iRow.removeBtn:Show()
                    iRow.addBtn:ClearAllPoints()
                    iRow.addBtn:SetPoint("RIGHT", -(2 + 16 + 4), 0)
                    iRow.addBtn:Show()

                    if not iRow._handlersSet then
                        iRow.addBtn:SetScript("OnClick", function(self)
                            local r = self:GetParent()
                            HDG.Store:AddToVendorShoppingList(r._itemID, r._npcID, 1)
                            HDG_VendorShoppingList.Refresh()
                        end)
                        iRow.removeBtn:SetScript("OnClick", function(self)
                            local r = self:GetParent()
                            HDG.Store:RemoveFromVendorShoppingList(r._itemID, r._npcID)
                            HDG_VendorShoppingList.Refresh()
                        end)
                        iRow:SetScript("OnEnter", function(self)
                            local s = GetScheme()
                            if s then self:SetBackdropColor(s.button_hover.r, s.button_hover.g, s.button_hover.b, 0.3) end
                            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                            local itm = Item:CreateFromItemID(self._itemID)
                            itm:ContinueOnItemLoad(function()
                                if GameTooltip:IsOwned(self) then
                                    GameTooltip:SetItemByID(self._itemID)
                                    local ie = self._itemEntry
                                    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 = GetCachedCurrencyInfo(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_FIND_ACQUIRE"], 0.5, 0.8, 0.5)
                                    GameTooltip:Show()
                                end
                            end)
                        end)
                        iRow:SetScript("OnLeave", function(self)
                            local s = GetScheme()
                            if s then self:SetBackdropColor(s.panel.r, s.panel.g, s.panel.b, 0.3) end
                            GameTooltip:Hide()
                        end)
                        iRow:SetScript("OnClick", function(self)
                            if not IsShiftKeyDown() then return end
                            if HDG_UI and HDG_UI.SwitchTab then
                                EnsureMainFrameOpen()
                                HDG_UI.SwitchTab("acquisition")
                                HDG_AcquisitionTab:Show()
                                HDG_AcquisitionTab:SelectItem(self._itemID, self._npcID)
                            end
                        end)
                        iRow._handlersSet = true
                    end

                    yOffset = yOffset + FW.VENDOR_LIST_ITEM_ROW_HEIGHT
                end
            end
        end
        end -- if not isZoneCollapsed
    end

    -- Wish List section (non-vendor items)
    if #wishListItems > 0 then
        -- Header row
        local wRow = GetPooledRow(scrollContent, FW.VENDOR_LIST_ZONE_HEADER_HEIGHT)
        wRow:SetPoint("TOPLEFT", 0, -yOffset)
        wRow:SetPoint("RIGHT", scrollContent, "RIGHT", 0, 0)
        if scheme then
            wRow:SetBackdropColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.15)
            wRow:SetBackdropBorderColor(scheme.accent.r, scheme.accent.g, scheme.accent.b, 0.3)
        end

        -- Collapse/expand arrow
        wRow.expandArrow:ClearAllPoints()
        wRow.expandArrow:SetPoint("LEFT", 2, 0)
        if collapsedWishList then
            wRow.expandArrow:SetNormalAtlas("common-button-collapseExpand-up")
            wRow.expandArrow:SetPushedAtlas("common-button-collapseExpand-up-pressed")
        else
            wRow.expandArrow:SetNormalAtlas("common-button-collapseExpand-down")
            wRow.expandArrow:SetPushedAtlas("common-button-collapseExpand-down-pressed")
        end
        wRow.expandArrow:Show()
        local function toggleWishList()
            collapsedWishList = not collapsedWishList
            HDG_VendorShoppingList.Refresh()
        end
        wRow.expandArrow:SetScript("OnClick", toggleWishList)
        wRow:SetScript("OnClick", toggleWishList)

        wRow.nameText:ClearAllPoints()
        wRow.nameText:SetPoint("LEFT", 20, 0)
        wRow.nameText:SetWidth(contentWidth - 74)
        wRow.nameText:SetJustifyH("LEFT")
        wRow.nameText:SetWordWrap(false)
        wRow.nameText:SetText("|cFFe2e8f0" .. L["HEADER_WISH_LIST"] .. "|r")
        if scheme then ApplyFont(wRow.nameText, scheme, "body", 13) end
        local wishTotal = 0
        for _, we in ipairs(wishListItems) do wishTotal = wishTotal + (we.qty or 1) end
        wRow.rightText:SetText(HDG.Theme.C_Dim() .. wishTotal .. " " .. L["MISC_ITEMS"] .. "|r")
        if scheme then ApplyFont(wRow.rightText, scheme, "small") end
        yOffset = yOffset + FW.VENDOR_LIST_ZONE_HEADER_HEIGHT

        if not collapsedWishList then
        -- Sort wish list items by name
        table.sort(wishListItems, SortItemsByName)

        for _, entry in ipairs(wishListItems) do
            local iRow = GetPooledRow(scrollContent, FW.VENDOR_LIST_ITEM_ROW_HEIGHT)
            iRow:SetPoint("TOPLEFT", FW.VENDOR_LIST_INDENT_VENDOR, -yOffset)
            iRow:SetPoint("RIGHT", scrollContent, "RIGHT", 0, 0)
            if scheme then
                iRow:SetBackdropColor(scheme.panel.r, scheme.panel.g, scheme.panel.b, 0.3)
                iRow:SetBackdropBorderColor(0, 0, 0, 0)
            end

            -- Icon
            iRow.iconTex:SetSize(FW.VENDOR_LIST_ITEM_ICON_SIZE, FW.VENDOR_LIST_ITEM_ICON_SIZE)
            iRow.iconTex:Show()
            -- Name (with qty prefix)
            local itemName = HDG_Data.GetCachedItemName(entry.itemID) or ("Item " .. entry.itemID)
            local qty = entry.qty or 1
            local qtyPrefix = qty > 1 and (HDG.Theme.C_Accent() .. qty .. "x|r ") or ""
            iRow.nameText:ClearAllPoints()
            iRow.nameText:SetPoint("LEFT", iRow.iconTex, "RIGHT", 4, 0)
            iRow.nameText:SetWidth(contentWidth - FW.VENDOR_LIST_INDENT_VENDOR - 70)
            iRow.nameText:SetText(qtyPrefix .. itemName)
            iRow.nameText:SetJustifyH("LEFT")
            iRow.nameText:SetWordWrap(false)
            if scheme then ApplyFont(iRow.nameText, scheme, "small") end

            iRow._assignedItemID = entry.itemID
            local cachedName, _, _, _, _, _, _, _, _, cachedIcon = C_Item.GetItemInfo(entry.itemID)
            if cachedIcon then
                iRow.iconTex:SetTexture(cachedIcon)
                if cachedName then iRow.nameText:SetText(qtyPrefix .. cachedName) end
            else
                local item = Item:CreateFromItemID(entry.itemID)
                local iRowRef = iRow
                local expectedID = entry.itemID
                item:ContinueOnItemLoad(function()
                    if iRowRef.inUse and iRowRef._assignedItemID == expectedID then
                        local name, _, _, _, _, _, _, _, _, ic = C_Item.GetItemInfo(expectedID)
                        if ic then iRowRef.iconTex:SetTexture(ic) end
                        if name then iRowRef.nameText:SetText(qtyPrefix .. name) end
                    end
                end)
            end

            -- Source badge (Craft/Drop/AH etc)
            local srcType = HDG_AllDecorDB and HDG_AllDecorDB[entry.itemID] and HDG_AllDecorDB[entry.itemID][2] or 0
            local badge = GetSourceBadge(srcType)
            if badge ~= "" then
                iRow.badgeBtn.text:SetText(badge)
                iRow.badgeBtn:Show()
            end

            -- +/- buttons (add/remove qty)
            local entryItemID = entry.itemID
            iRow.removeBtn:ClearAllPoints()
            iRow.removeBtn:SetPoint("RIGHT", -2, 0)
            iRow.removeBtn:Show()
            iRow.addBtn:ClearAllPoints()
            iRow.addBtn:SetPoint("RIGHT", -(2 + 16 + 4), 0)
            iRow.addBtn:Show()
            iRow.addBtn:SetScript("OnClick", function()
                HDG.Store:AddToVendorShoppingList(entryItemID, nil, 1)
                HDG_VendorShoppingList.Refresh()
            end)
            iRow.removeBtn:SetScript("OnClick", function()
                HDG.Store:RemoveFromVendorShoppingList(entryItemID, nil)
                HDG_VendorShoppingList.Refresh()
            end)

            -- Tooltip
            local eItemID = entry.itemID
            local eIsCraftable = (srcType == 6) -- "Craft" source
            iRow:SetScript("OnEnter", function(self)
                if scheme then self:SetBackdropColor(scheme.button_hover.r, scheme.button_hover.g, scheme.button_hover.b, 0.3) end
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                local itm = Item:CreateFromItemID(eItemID)
                itm:ContinueOnItemLoad(function()
                    if GameTooltip:IsOwned(self) then
                        GameTooltip:SetItemByID(eItemID)
                        GameTooltip:AddLine(" ")
                        if eIsCraftable then
                            GameTooltip:AddLine(L["TIP_SHIFTCLICK_ADD_QUEUE"], 0.5, 0.8, 0.5)
                        else
                            GameTooltip:AddLine(L["TIP_SHIFTCLICK_FIND_ACQUIRE"], 0.5, 0.8, 0.5)
                        end
                        GameTooltip:Show()
                    end
                end)
            end)
            iRow:SetScript("OnLeave", function(self)
                if scheme then self:SetBackdropColor(scheme.panel.r, scheme.panel.g, scheme.panel.b, 0.3) end
                GameTooltip:Hide()
            end)

            -- Shift-click: add to crafting queue (craftable) or search AH
            local eShiftItemID = entry.itemID
            local eShiftItemName = itemName
            iRow:SetScript("OnClick", function()
                if not IsShiftKeyDown() then return end
                -- Try AcqData cache first, fall back to scanning DecorDB directly
                local recipe = HDG_AcqData and HDG_AcqData.recipeByItem[eShiftItemID]
                if not recipe and HDG_DecorDB then
                    for _, r in pairs(HDG_DecorDB) do
                        if r.itemID == eShiftItemID and r.reagents and next(r.reagents) then
                            recipe = r
                            break
                        end
                    end
                end
                if recipe and HDG_UI and HDG_UI.AddToCraftingQueue then
                    HDG_UI.AddToCraftingQueue(recipe)
                    print(HDG.Theme.C_Success() .. "[HDG]|r Added " .. HDG.Theme.C_Accent() .. (recipe.name or eShiftItemName) .. "|r to crafting queue")
                else
                    -- Navigate to Acquire tab By Item view
                    if HDG_UI and HDG_UI.SwitchTab then
                        EnsureMainFrameOpen()
                        HDG_UI.SwitchTab("acquisition")
                        HDG_AcquisitionTab:Show()
                        HDG_AcquisitionTab:ShowByItem(eShiftItemID)
                    end
                end
            end)

            yOffset = yOffset + FW.VENDOR_LIST_ITEM_ROW_HEIGHT
        end
        end -- if not collapsedWishList
    end

    scrollContent:SetHeight(math.max(yOffset, 1))
end


-- ============================================================================
-- WAYPOINT MAPPING
-- ============================================================================

function HDG_VendorShoppingList.MapListWaypoints()
    if not HDG.Store or not HDG.Waypoints then return end
    local list = HDG.Store:GetActiveShoppingList().items
    local seen = {}
    local waypoints = {}
    for _, entry in ipairs(list) do
        if entry.npcID and not seen[entry.npcID] then
            seen[entry.npcID] = true
            local ve = HDG_VendorDB and HDG_VendorDB[entry.npcID]
            if ve then
                local name = HDG_VendorResolver.GetVendorNameByID(entry.npcID)
                waypoints[#waypoints + 1] = { ve[3], ve[4], ve[5], name }
            end
        end
    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


-- ============================================================================
-- EXPORT / IMPORT
-- ============================================================================

-- Format: "HDGVL:1:<base64>" where payload is newline-separated entries
-- Header line: "source=wowhead,url=https://..."
-- Item lines: "<itemID>,<npcID>,<qty>"

local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local b64enc, b64dec = {}, {}
for i = 1, 64 do
    local c = b64chars:sub(i, i)
    b64enc[i - 1] = c
    b64dec[c:byte()] = i - 1
end

local function B64Encode(data)
    local out = {}
    local len = #data
    for i = 1, len, 3 do
        local a = data:byte(i)
        local b = i + 1 <= len and data:byte(i + 1) or 0
        local c = i + 2 <= len and data:byte(i + 2) or 0
        out[#out + 1] = b64enc[bit.rshift(a, 2)]
        out[#out + 1] = b64enc[bit.bor(bit.lshift(bit.band(a, 3), 4), bit.rshift(b, 4))]
        out[#out + 1] = (i + 1 <= len) and b64enc[bit.bor(bit.lshift(bit.band(b, 15), 2), bit.rshift(c, 6))] or "="
        out[#out + 1] = (i + 2 <= len) and b64enc[bit.band(c, 63)] or "="
    end
    return table.concat(out)
end

local function B64Decode(data)
    local out = {}
    data = data:gsub("[^A-Za-z0-9+/=]", "")
    for i = 1, #data, 4 do
        local a = b64dec[data:byte(i)] or 0
        local b = b64dec[data:byte(i + 1)] or 0
        local c = b64dec[data:byte(i + 2)] or 0
        local d = b64dec[data:byte(i + 3)] or 0
        out[#out + 1] = string.char(bit.bor(bit.lshift(a, 2), bit.rshift(b, 4)))
        if data:sub(i + 2, i + 2) ~= "=" then
            out[#out + 1] = string.char(bit.band(bit.bor(bit.lshift(b, 4), bit.rshift(c, 2)), 0xFF))
        end
        if data:sub(i + 3, i + 3) ~= "=" then
            out[#out + 1] = string.char(bit.band(bit.bor(bit.lshift(c, 6), d), 0xFF))
        end
    end
    return table.concat(out)
end

local eiDialog -- export/import dialog frame (created once)

-- URL-encode a string (percent-encode non-alphanumeric except - _ . ~)
local function UrlEncode(str)
    if not str then return nil end
    return str:gsub("([^%w%-_%.~])", function(c)
        return string.format("%%%02X", string.byte(c))
    end)
end

-- URL-decode a percent-encoded string
local function UrlDecode(str)
    if not str then return nil end
    return str:gsub("%%(%x%x)", function(hex)
        return string.char(tonumber(hex, 16))
    end)
end

local function SerializeVendorList(useDecorID)
    if not HDG.Store then return "" end
    local activeList = HDG.Store:GetActiveShoppingList()
    local lines = {}
    -- Header line with source metadata
    local meta = activeList.meta
    if meta and meta.source then
        local header = "source=" .. meta.source
        if meta.url then header = header .. ",url=" .. meta.url end
        if meta.desc then header = header .. ",desc=" .. UrlEncode(meta.desc) end
        if meta.date then header = header .. ",date=" .. meta.date end
        if useDecorID then header = header .. ",idtype=decor" end
        lines[#lines + 1] = header
    else
        local playerName = UnitName("player") or "Unknown"
        local header = "source=player,desc=" .. UrlEncode(activeList.name or "Shopping List")
        header = header .. ",date=" .. tostring(activeList.createdAt or time())
        header = header .. ",author=" .. playerName
        if useDecorID then header = header .. ",idtype=decor" end
        lines[#lines + 1] = header
    end
    local i2d
    if useDecorID then
        HDG.BuildDecorLookups()
        i2d = HDG._itemToDecor
    end
    for _, entry in ipairs(activeList.items) do
        if entry.itemID then
            local q = entry.qty or 1
            local outID = (i2d and i2d[entry.itemID]) or entry.itemID
            lines[#lines + 1] = outID .. "," .. (entry.npcID or 0) .. "," .. q
        end
    end
    return "HDGVL:1:" .. B64Encode(table.concat(lines, "\n"))
end

-- Returns: entries, err, meta
-- meta = { source = "wowhead", url = "https://..." } or nil
local MAX_IMPORT_ENTRIES = 500

local function ParseItemLine(line)
    local itemID, npcID, qty = strsplit(",", line)
    itemID = tonumber(itemID)
    npcID = tonumber(npcID)
    qty = tonumber(qty) or 1
    if npcID == 0 then npcID = nil end
    if itemID then return { itemID = itemID, npcID = npcID, qty = qty } end
end

local function DeserializeVendorList(encoded)
    if not encoded or encoded == "" then return nil, "Empty string" end
    local prefix, ver, payload = encoded:match("^(HDGVL):(%d+):(.+)$")
    if not prefix then return nil, "Invalid format (expected HDGVL:1:...)" end
    if ver ~= "1" then return nil, "Unknown version: " .. ver end
    local decoded = B64Decode(payload)
    if not decoded or decoded == "" then return nil, "Failed to decode payload" end
    local entries = {}
    local meta = nil
    local firstLine = true
    for line in decoded:gmatch("[^\n]+") do
        if firstLine then
            firstLine = false
            local firstField = line:match("^([^,]+)")
            if firstField and firstField:match("^%a") then
                meta = {}
                for kv in line:gmatch("[^,]+") do
                    local k, v = kv:match("^(%w+)=(.+)$")
                    if k then meta[k] = v end
                end
                if meta.desc then meta.desc = UrlDecode(meta.desc) end
            else
                local entry = ParseItemLine(line)
                if entry then entries[#entries + 1] = entry end
            end
        else
            if #entries >= MAX_IMPORT_ENTRIES then
                return entries, nil, meta -- silently cap
            end
            local entry = ParseItemLine(line)
            if entry then entries[#entries + 1] = entry end
        end
    end
    -- Resolve decorIDs to itemIDs if needed
    local stats = { total = #entries, skippedDecor = 0, skippedDecorIDs = {} }
    if #entries > 0 then
        local isDecor = meta and meta.idtype == "decor"
        if not isDecor then
            local firstID = entries[1] and entries[1].itemID
            if firstID and firstID < 100000 then isDecor = true end
        end
        if isDecor then
            HDG.BuildDecorLookups()
            local d2i = HDG._decorToItem
            if d2i then
                local resolved_entries = {}
                for _, e in ipairs(entries) do
                    local resolved = d2i[e.itemID]
                    if resolved then
                        e.itemID = resolved
                        resolved_entries[#resolved_entries + 1] = e
                    else
                        stats.skippedDecor = stats.skippedDecor + 1
                        stats.skippedDecorIDs[#stats.skippedDecorIDs + 1] = e.itemID
                    end
                end
                entries = resolved_entries
            end
        end
    end

    return entries, nil, meta, stats
end


local function CreateExportImportDialog()
    if eiDialog then return end

    local ctrl = HDG_UIHelpers.CreateExportDialog({
        frameName = "HDG_ShoppingListEIFrame",
        width = 420, height = 300, bottomPad = 80,
    })
    local f = ctrl.frame
    local scheme = HDG_UIHelpers.GetScheme()

    -- Replace factory buttons with EI-specific layout
    ctrl.selectAllBtn:Hide()
    ctrl.closeBtn:Hide()
    local closeBtn = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    closeBtn:SetPoint("TOPRIGHT", -2, -2)
    closeBtn:SetSize(20, 20)

    f.editBox = ctrl.editBox
    f.title = ctrl.titleFS

    -- Action button (Export: select all / Import: apply)
    f.actionBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    f.actionBtn:SetSize(120, 24)
    f.actionBtn:SetPoint("BOTTOMRIGHT", -8, 8)

    -- "Create as new list" checkbox (for import mode) -- same row as action button
    local newListCB = CreateFrame("CheckButton", nil, f, "UICheckButtonTemplate")
    newListCB:SetSize(22, 22)
    newListCB:SetPoint("LEFT", 6, 0)
    newListCB:SetPoint("BOTTOM", f.actionBtn, "BOTTOM", 0, 0)
    local newListLabel = newListCB:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    newListLabel:SetPoint("LEFT", newListCB, "RIGHT", 2, 0)
    newListLabel:SetText(L["IMPORT_CREATE_NEW_LIST"])
    if scheme then ApplyFont(newListLabel, scheme, "small") end
    newListCB:Hide()
    f.newListCB = newListCB

    -- "Use Decor IDs (Wowhead)" checkbox (for export mode) -- same row as action button
    local decorCB = CreateFrame("CheckButton", nil, f, "UICheckButtonTemplate")
    decorCB:SetSize(22, 22)
    decorCB:SetPoint("LEFT", 6, 0)
    decorCB:SetPoint("BOTTOM", f.actionBtn, "BOTTOM", 0, 0)
    local decorLabel = decorCB:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    decorLabel:SetPoint("LEFT", decorCB, "RIGHT", 2, 0)
    decorLabel:SetText(L["EXPORT_USE_DECOR_IDS"])
    if scheme then ApplyFont(decorLabel, scheme, "small") end
    decorCB:Hide()
    f.decorCB = decorCB

    -- Status text (above action row, grows upward)
    f.status = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    f.status:SetPoint("BOTTOMLEFT", 8, 36)
    f.status:SetPoint("RIGHT", -8, 0)
    f.status:SetJustifyH("LEFT")

    table.insert(UISpecialFrames, "HDG_ShoppingListEIFrame")
    eiDialog = f
end

function HDG_VendorShoppingList.ShowExportImport(mode)
    CreateExportImportDialog()
    local eb = eiDialog.editBox
    eiDialog.status:SetText("")

    if mode == "export" then
        eiDialog.title:SetText("|cFFe2e8f0" .. L["EXPORT_SHOPPING_LIST_TITLE"] .. "|r")
        local encoded = SerializeVendorList()
        eb:SetText(encoded)
        eb:HighlightText()
        eb:SetFocus()
        eiDialog.actionBtn:SetText(L["BTN_SELECT_ALL"])
        eiDialog.actionBtn:SetScript("OnClick", function()
            eb:HighlightText()
            eb:SetFocus()
        end)
        local activeList = HDG.Store:GetActiveShoppingList()
        eiDialog.status:SetText(HDG.Theme.C_Dim() .. #activeList.items .. " entries|r")
        eiDialog.newListCB:Hide()
        -- Auto-check decor IDs for wowhead-sourced lists
        local isWowhead = activeList.meta and activeList.meta.source == "wowhead"
        eiDialog.decorCB:SetChecked(isWowhead)
        if isWowhead then
            local decorEncoded = SerializeVendorList(true)
            eb:SetText(decorEncoded)
            eb:HighlightText()
            eb:SetFocus()
        end
        eiDialog.decorCB:Show()
        eiDialog.decorCB:SetScript("OnClick", function(self)
            local useDecor = self:GetChecked()
            local reEncoded = SerializeVendorList(useDecor)
            eb:SetText(reEncoded)
            eb:HighlightText()
            eb:SetFocus()
        end)
    else
        eiDialog.title:SetText("|cFFe2e8f0" .. L["IMPORT_SHOPPING_LIST_TITLE"] .. "|r")
        eb:SetText("")
        eb:SetFocus()
        eiDialog.decorCB:Hide()
        eiDialog.newListCB:Show()
        eiDialog.newListCB:SetChecked(true)
        eiDialog.actionBtn:SetText(L["BTN_IMPORT"])
        eiDialog.actionBtn:SetScript("OnClick", function()
            local text = eb:GetText():match("^%s*(.-)%s*$") -- trim
            if not text or text == "" then
                eiDialog.status:SetText("|cFFFF4444Paste an export string first.|r")
                return
            end
            local entries, err, meta, stats = DeserializeVendorList(text)
            if not entries then
                eiDialog.status:SetText("|cFFFF4444" .. (err or "Invalid data") .. "|r")
                return
            end

            -- Create new list if checkbox is checked
            local createNew = eiDialog.newListCB:GetChecked()
            if createNew and HDG.Store then
                local listName = "Imported List"
                if meta and meta.desc and meta.desc ~= "" then
                    listName = meta.desc
                elseif meta and meta.source and meta.source ~= "hdg" then
                    listName = meta.source:sub(1, 1):upper() .. meta.source:sub(2) .. " Import"
                end
                local newId = HDG.Store:CreateShoppingList(listName)
                HDG.Store:SetActiveShoppingList(newId)
            end

            local added = 0
            for _, ve in ipairs(entries) do
                local npc = ve.npcID
                local srcType = 0
                if HDG_AllDecorDB then
                    local rec = HDG_AllDecorDB[ve.itemID]
                    srcType = rec and rec[2] or 0
                end
                if srcType == 5 then
                    -- Vendor item: auto-resolve npcID from HDG_VendorByItem if missing
                    if not npc and _G.HDG_VendorByItem then
                        local list = _G.HDG_VendorByItem[ve.itemID]
                        if list and list[1] then npc = list[1] end
                    end
                else
                    -- Non-vendor item: force to wish list regardless of imported npcID
                    npc = nil
                end
                if HDG.Store:AddToVendorShoppingList(ve.itemID, npc, ve.qty) then
                    added = added + 1
                end
            end
            if meta then
                HDG.Store:SetVendorShoppingListMeta(meta)
            end

            -- Build import summary
            local totalInput = stats and stats.total or added
            local skippedDecor = stats and stats.skippedDecor or 0
            local skippedAdd = #entries - added -- entries that failed AddToVendorShoppingList
            local parts = {}
            parts[#parts + 1] = string.format("Input: %d items", totalInput)
            parts[#parts + 1] = HDG.Theme.C_Success() .. string.format("Imported: %d", added) .. "|r"
            if skippedDecor > 0 then
                parts[#parts + 1] = HDG.Theme.C_Warning() .. string.format("Skipped: %d (unknown decor ID)", skippedDecor) .. "|r"
            end
            if skippedAdd > 0 then
                parts[#parts + 1] = HDG.Theme.C_Warning() .. string.format("Skipped: %d (duplicate)", skippedAdd) .. "|r"
            end
            if meta and meta.source and meta.source ~= "hdg" then
                local srcLine = "Source: " .. meta.source
                if meta.desc and meta.desc ~= "" then srcLine = srcLine .. " -- " .. meta.desc end
                if meta.date and meta.date ~= "" then srcLine = srcLine .. " (" .. meta.date .. ")" end
                parts[#parts + 1] = HDG.Theme.C_Dim() .. srcLine .. "|r"
            end
            if createNew then
                parts[#parts + 1] = HDG.Theme.C_Dim() .. "Created as new list|r"
            end
            local statusMsg = table.concat(parts, " | ")
            HDG_VendorShoppingList.Refresh()
            eiDialog:Hide()
            local plain = statusMsg:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
            print("|cFFfcd34d[Housing Decor Guide]|r " .. plain)
            if stats and stats.skippedDecorIDs and #stats.skippedDecorIDs > 0 then
                print("|cFFfcd34d[HDG]|r Skipped decor IDs: " .. table.concat(stats.skippedDecorIDs, ", "))
            end
        end)
        eiDialog.status:SetText(HDG.Theme.C_Dim() .. "Paste an HDG, Wowhead or housing.wowdb.com string|r")
    end

    eiDialog:Show()
    eb:SetFocus() -- must be after Show() so the frame is visible
end

-- ============================================================================
-- PUBLIC API
-- ============================================================================

function HDG_VendorShoppingList:Init(configRef)
    CONFIG = configRef
end

function HDG_VendorShoppingList.Toggle()
    if not CONFIG then CONFIG = privateDB.CONFIG end
    if not created then CreatePanel() end

    if widget.frame:IsShown() then
        widget.frame:Hide()
    else
        widget.frame:Show()
        HDG_VendorShoppingList.Refresh()
    end
end

-- Pre-create panel (hidden) so Show() doesn't stall on first open
function HDG_VendorShoppingList.EnsureCreated()
    if not CONFIG then CONFIG = privateDB.CONFIG end
    if not created then CreatePanel() end
end

function HDG_VendorShoppingList.Show()
    HDG_VendorShoppingList.EnsureCreated()
    widget.frame:Show()
    HDG_VendorShoppingList.Refresh()
end

function HDG_VendorShoppingList.Hide()
    if widget and widget.frame then widget.frame:Hide() end
end

function HDG_VendorShoppingList.IsShown()
    return widget and widget.frame and widget.frame:IsShown()
end

function HDG_VendorShoppingList.GetCount()
    if not HDG.Store then return 0 end
    return HDG.Store:GetVendorShoppingListCount()
end

-- Refresh on collection changes (HOUSING_STORAGE_* → UpdateSingleEntry / RunArithmeticSweep
-- → HDG_COLLECTION_READY). Debounced inside Refresh via its own IsShown guard so closed
-- panels don't churn. Collected items drop off the list automatically on next paint.
do
    local HDG = _G.HousingDecorGuide
    if HDG and HDG.EventBus then
        HDG.EventBus:Register("HDG_COLLECTION_READY", function()
            if HDG_VendorShoppingList.IsShown and HDG_VendorShoppingList.IsShown() then
                HDG_VendorShoppingList.Refresh()
            end
        end)
    end
end

function HDG_VendorShoppingList.Refresh()
    if not widget or not scrollContent or not widget.frame:IsShown() then return end

    -- Update title summary (item count + total gold + per-currency totals).
    -- Aggregates the same ie[5] cost-tuple list the per-zone walk uses,
    -- summed across the entire active list.
    if titleSummary and HDG.Store then
        local activeList = HDG.Store:GetActiveShoppingList()
        local count = #activeList.items
        local totalGold = 0
        local totalCurrencies = {}  -- [currencyID] = aggregate qty
        for _, entry in ipairs(activeList.items) do
            if entry.npcID then
                local ve = HDG_VendorDB and HDG_VendorDB[entry.npcID]
                local ie = ve and ve.items and ve.items[entry.itemID]
                if ie then
                    local qty = entry.qty or 1
                    totalGold = totalGold + (ie[1] or 0) * qty
                    if ie[5] then
                        for _, ct in ipairs(ie[5]) do
                            if ct[1] == "c" then
                                local cid = ct[2]
                                totalCurrencies[cid] = (totalCurrencies[cid] or 0) + (ct[3] or 0) * qty
                            end
                        end
                    end
                end
            end
        end
        local parts = {}
        parts[#parts + 1] = HDG.Theme.C_Dim() .. count .. " items|r"
        if totalGold > 0 then parts[#parts + 1] = FormatGoldCompact(totalGold) end
        for cid, amt in pairs(totalCurrencies) do
            local ci = GetCachedCurrencyInfo(cid)
            local iconStr = ci and ci.iconFileID and ("|T" .. ci.iconFileID .. ":12:12:0:0|t") or ""
            parts[#parts + 1] = iconStr .. HDG.Theme.C_Accent() .. amt .. "|r"
        end
        titleSummary:SetText(table.concat(parts, " "))
    end

    -- Update attribution banner (conditional on import source)
    if attrBanner and HDG.Store then
        local meta = HDG.Store:GetActiveShoppingList().meta
        if meta and meta.source and meta.source ~= "hdg" then
            local hasIcon = (meta.source == "wowhead")
            local label
            if hasIcon then
                -- Icon speaks for itself; just show desc + date
                local parts = {}
                if meta.desc and meta.desc ~= "" then parts[#parts + 1] = meta.desc end
                if meta.date and meta.date ~= "" then parts[#parts + 1] = HDG.Theme.C_Dim() .. meta.date .. "|r" end
                label = table.concat(parts, "  ")
                if label == "" then label = "Wowhead" end -- fallback if no desc/date
            else
                local sourceName = meta.source:sub(1, 1):upper() .. meta.source:sub(2)
                label = "Imported from " .. sourceName
                if meta.desc and meta.desc ~= "" and meta.desc:lower() ~= meta.source:lower() then
                    label = label .. ": " .. meta.desc
                end
                if meta.date and meta.date ~= "" then label = label .. "  " .. HDG.Theme.C_Dim() .. meta.date .. "|r" end
            end
            attrText:SetText(HDG.Theme.C_Dim() .. label .. "|r")
            if hasIcon then
                attrIcon:SetTexture("Interface\\AddOns\\HousingDecorGuide\\textures\\wowhead_logo")
                attrIcon:Show()
            else
                attrIcon:Hide()
            end
            if attrUrlBtn then
                if meta.url and meta.url ~= "" then
                    local u = meta.url
                    if u:find("wowhead%.com") and not u:find("utm_source") then
                        u = u .. (u:find("%?") and "&" or "?") .. "utm_source=hdg"
                    end
                    attrUrlBtn.url = u
                    attrUrlBtn:Show()
                else
                    attrUrlBtn:Hide()
                end
            end
            attrBanner:Show()
        else
            attrBanner:Hide()
            if attrUrlBtn then attrUrlBtn:Hide() end
        end
    end

    -- Update list selector dropdown text
    UpdateListDropdown()

    -- Update dynamic layout (attribution visibility may have changed)
    if widget._updateLayout then widget._updateLayout() end

    RefreshListMode()
end

-- ============================================================================
-- EVENT WIRING
-- Called once from HousingDecorGuide.lua's ADDON_LOADED handler. Keeps
-- shopping-list-specific Blizzard hooks colocated with the module they
-- mutate, instead of leaking into the addon's main event router.
-- ============================================================================
function HDG_VendorShoppingList.RegisterEvents()
    -- Auto-decrement on vendor purchase: when the player buys an item that's
    -- on the active shopping list, remove one copy. Gated by the
    -- shopListAutoDecrement config flag so users who want the list to
    -- persist as a planning tool can turn it off.
    hooksecurefunc("BuyMerchantItem", function(merchantIndex, qty)
        local store = HDG and HDG.Store
        local state = store and store:GetState()
        if not state or not state.config or not state.config.shopListAutoDecrement then return end
        local link = GetMerchantItemLink(merchantIndex)
        local itemID = link and tonumber(link:match("item:(%d+)"))
        if not itemID then return end
        local list = store:GetActiveShoppingList()
        if not list or not list.items then return end
        local removed = false
        for _ = 1, (qty or 1) do
            local found = false
            for _, entry in ipairs(list.items) do
                if entry.itemID == itemID then
                    store:RemoveFromVendorShoppingList(itemID, entry.npcID)
                    found = true; removed = true
                    break
                end
            end
            if not found then break end
        end
        if removed and HDG_VendorShoppingList.IsShown and HDG_VendorShoppingList.IsShown() then
            HDG_VendorShoppingList.Refresh()
        end
    end)
end
