-- Housing Decor Guide - Acquisition Tab
-- Panel layout, lifecycle, detail panels, refresh orchestration.
-- Data/filtering in HDG_AcqData.lua, row factories in HDG_AcqRows.lua.

local addonName, addon = ...

-- Module namespace
HDG_AcquisitionTab = HDG_AcquisitionTab or {}

-- ============================================================================
-- THEME HELPERS (delegating to ThemeEngine canonical helpers)
-- ============================================================================

local HDG = _G.HousingDecorGuide
local L = HDG_L or {}
local function GetScheme() return HDG.Theme.GetCurrentScheme() end
local function RegisterWidget(w, t) HDG.Theme.RegisterWidget(w, t) end
local ApplyFont = function(fs, s, ft) HDG.Theme.ApplyFont(fs, s, ft) end


-- Local references (set during init)
local CONFIG
local mainFrame

-- Module frame references
local acquisitionPanel = nil
local selectedItemID = nil
local selectedNpcID = nil
local selectedVendor = nil
local galleryVendorNPC = nil -- npcID currently shown in "Also sells" gallery
local navigableItems = {}   -- {itemID, npcID} pairs in display order (for arrow key nav)

-- Forward declarations
local RefreshGuideText
local ShowNotesExportWindow

-- Zone/expansion data from HDG_ExpansionData (SSoT)
local ZONE_MAP_IDS = HDG_ExpansionData.ZONE_MAP_IDS
local GetExpansionForMapID = HDG_ExpansionData.GetExpansionForMapID

-- View mode state
local viewMode = "vendor"
local expandedVendors = {}
local reusableItemList = {} -- reused per vendor expand to avoid table alloc

-- Source options per view mode
local VENDOR_SOURCES = {"Vendor", "Achievement", "Quest", "Reputation", "Endeavor"}
local ALL_SOURCES    = {"Vendor", "Achievement", "Quest", "Drop", "Crafted", "Treasure", "Promotion", "Reputation", "Endeavor"}

-- Hoisted onSelect callback (avoids closure allocation per refresh)
local function onSelectItem(itemID, npcID) HDG_AcquisitionTab:SelectItem(itemID, npcID) end

-- Hoisted sort comparator for vendor sub-item lists
local function sortByItemName(a, b)
    return (HDG_Data.GetCachedItemName(a) or "") < (HDG_Data.GetCachedItemName(b) or "")
end

-- Search debounce timer
local searchDebounceTimer = nil

-- Virtual scroll state (By Item view with large result sets)
local virtualScrollActive = false
local virtualScrollFirstIdx = 0
local virtualScrollDebounce = nil

-- Check if coords are valid zone-percentage (0-100 range, not zero)
local function HasValidCoords(vendor)
    if not vendor or not vendor.mapID or vendor.mapID == 0 then return false end
    if not vendor.x or not vendor.y or (vendor.x == 0 and vendor.y == 0) then return false end
    if vendor.x < -5 or vendor.x > 105 or vendor.y < -5 or vendor.y > 105 then return false end
    return true
end

-- ============================================================================
-- PANEL CREATION
-- ============================================================================

function HDG_AcquisitionTab:CreatePanel()
    if acquisitionPanel then return acquisitionPanel end

    local ACQ = CONFIG.ACQUISITION_TAB
    local scheme = GetScheme()

    -- Use factory for main panel (no header/scroll - custom layout)
    local layout = HDG.UI:TabPanel(mainFrame, {
        hasHeaderBar = false,
        hasScroll = false,
    })

    acquisitionPanel = layout.frame
    acquisitionPanel:Hide()

    -- ========================================================================
    -- HEADER BAR
    -- ========================================================================

    local headerBg = CreateFrame("Frame", nil, acquisitionPanel, "BackdropTemplate")
    headerBg:SetPoint("TOPLEFT", 1, -1)
    headerBg:SetPoint("TOPRIGHT", -1, -1)
    headerBg:SetHeight(22)
    headerBg:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", tile = false })
    RegisterWidget(headerBg, "HeaderBar")

    local title = headerBg:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("CENTER", 0, 0)
    if scheme then
        ApplyFont(title, scheme, "header")
        title:SetTextColor(scheme.text_header.r, scheme.text_header.g, scheme.text_header.b, scheme.text_header.a)
    end
    title:SetText(L["ACQ_TITLE"])
    title.isHeader = true
    HDG.Theme.StyleHeaderTitle(title, headerBg)
    acquisitionPanel.headerTitle = title

    -- Help button — toggles guide panel
    local helpBtn = HDG.UI:Button(headerBg, CreateAtlasMarkup("QuestTurnin", 14, 14) .. L["ACQ_HOW_TO_USE"], 100, 18, function()
        if acquisitionPanel.placeholder:IsShown() then
            acquisitionPanel.placeholder:Hide()
            if selectedItemID then
                acquisitionPanel.headerPanel:Show()
                acquisitionPanel.detailPanel:Show()
            end
        else
            selectedItemID = nil
            selectedVendor = nil
            galleryVendorNPC = nil
            acquisitionPanel.headerPanel:Hide()
            acquisitionPanel.detailPanel:Hide()
            acquisitionPanel.galleryPanel:Hide()
            acquisitionPanel.infoPanel:Hide()
            acquisitionPanel.waypointBtn:Hide()
            acquisitionPanel.mapBtn:Hide()
            acquisitionPanel.placeholder:Show()
            RefreshGuideText()
            HDG_AcqRows.UpdateRowHighlights(nil)
        end
    end)
    helpBtn:SetPoint("RIGHT", headerBg, "RIGHT", -4, 1)
    acquisitionPanel.helpBtn = helpBtn

    -- Hide Unreleased checkbox in title bar (default on — hides items not in AllDecorDB)
    local releasedCheck = HDG.UI:Checkbox(headerBg, 14, L["ACQ_HIDE_UNRELEASED"])
    releasedCheck:SetPoint("LEFT", headerBg, "LEFT", 14, 1)
    releasedCheck:SetChecked(true)
    releasedCheck.onChanged = function(checked)
        HDG_AcqData.filters.releasedOnly = checked
        HDG_AcquisitionTab:RefreshList()
    end
    acquisitionPanel.releasedCheck = releasedCheck

    -- Export notes button in title bar
    local exportNotesBtn = HDG.UI:Button(headerBg, L["ACQ_EXPORT_NOTES"], 85, 18, function()
        ShowNotesExportWindow()
    end)
    exportNotesBtn:SetPoint("RIGHT", helpBtn, "LEFT", -4, 0)
    HDG_UIHelpers.SetupTooltip(exportNotesBtn, L["ACQ_EXPORT_NOTES"], "Export all user notes to a text window for copy/paste.", "ANCHOR_BOTTOM")
    acquisitionPanel.exportNotesBtn = exportNotesBtn

    -- ========================================================================
    -- LEFT PANEL (Item List) - 40% width
    -- ========================================================================

    local panelWidth = acquisitionPanel:GetWidth()
    if panelWidth == 0 then panelWidth = CONFIG.MAIN_FRAME_WIDTH - 8 end
    local leftWidth = math.floor(panelWidth * ACQ.LEFT_PANEL_PCT)
    local rightWidth = panelWidth - leftWidth - 4

    local leftPanel = HDG.UI:Panel(acquisitionPanel, { width = leftWidth })
    leftPanel:SetPoint("TOPLEFT", 1, -24)
    leftPanel:SetPoint("BOTTOMLEFT", 1, 1)
    leftPanel:SetWidth(leftWidth)

    -- ========================================================================
    -- ROW 1: VIEW TOGGLE + SEARCH + MAP ALL + CLEAR
    -- ========================================================================

    local viewRowY = -4

    local function SyncSourceDropdown()
        local items = (viewMode == "vendor") and VENDOR_SOURCES or ALL_SOURCES
        if acquisitionPanel.sourceDropdown then
            acquisitionPanel.sourceDropdown:SetItems(items)
            acquisitionPanel.sourceDropdown:SetValue("All")
            HDG_AcqData.filters.source = "All"
        end
    end

    local byVendorBtn = HDG.UI:TabButton(leftPanel, L["ACQ_BY_VENDOR"], true)
    byVendorBtn:SetSize(70, ACQ.VIEW_TOGGLE_HEIGHT)
    byVendorBtn:SetPoint("TOPLEFT", 6, viewRowY)
    byVendorBtn:SetScript("OnClick", function()
        viewMode = "vendor"
        byVendorBtn:SetActive(true)
        acquisitionPanel.byItemBtn:SetActive(false)
        SyncSourceDropdown()
        HDG_AcquisitionTab:RefreshList()
    end)
    acquisitionPanel.byVendorBtn = byVendorBtn

    local byItemBtn = HDG.UI:TabButton(leftPanel, L["ACQ_BY_ITEM"], false)
    byItemBtn:SetSize(60, ACQ.VIEW_TOGGLE_HEIGHT)
    byItemBtn:SetPoint("LEFT", byVendorBtn, "RIGHT", 2, 0)
    byItemBtn:SetScript("OnClick", function()
        viewMode = "item"
        byItemBtn:SetActive(true)
        acquisitionPanel.byVendorBtn:SetActive(false)
        SyncSourceDropdown()
        HDG_AcquisitionTab:RefreshList()
    end)
    acquisitionPanel.byItemBtn = byItemBtn

    -- Clear Pins (compact X button, rightmost)
    local clearPinsBtn = HDG.UI:Button(leftPanel, "X", 22, ACQ.VIEW_TOGGLE_HEIGHT, function()
        HDG.Waypoints:Clear()
        print("|cFF88BBDD[HDG]|r " .. L["ACQ_PINS_CLEARED"])
    end)
    clearPinsBtn:SetPoint("TOPRIGHT", leftPanel, "TOPRIGHT", -6, viewRowY)
    HDG_UIHelpers.SetupTooltip(clearPinsBtn, L["ACQ_CLEAR_PINS"], "Remove all HDG waypoints and map pins.", "ANCHOR_BOTTOM")

    -- Map All button
    local mapAllBtn = HDG.UI:Button(leftPanel, L["ACQ_MAP_ALL"], 52, ACQ.VIEW_TOGGLE_HEIGHT, function()
        local seen = {}
        local waypoints = {}
        local filters = HDG_AcqData.filters
        for _, itemID in ipairs(HDG_AcqData.filteredItems) do
            local npcIDs = HDG_VendorByItem and HDG_VendorByItem[itemID]
            if npcIDs then
                for _, npcID in ipairs(npcIDs) do
                    if not seen[npcID] then
                        local ve = HDG_VendorDB and HDG_VendorDB[npcID]
                        if ve and ve[4] and ve[5] and not (ve[4] == 0 and ve[5] == 0)
                            and ve[4] >= -5 and ve[4] <= 105 and ve[5] >= -5 and ve[5] <= 105 then
                            if (filters.expansion == "All" or HDG_AcqData.vendorExpansion[npcID] == filters.expansion)
                                and (filters.zone == "All" or HDG_AcqData.GetVendorZone(npcID) == filters.zone) then
                                seen[npcID] = true
                                waypoints[#waypoints + 1] = { ve[3] or 0, ve[4], ve[5], ve[1] or ("NPC #" .. npcID) }
                            end
                        end
                    end
                end
            end
        end
        if #waypoints == 0 then
            print("|cFFFFAA00[HDG]|r " .. L["ACQ_NO_VENDORS_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)
    mapAllBtn:SetPoint("RIGHT", clearPinsBtn, "LEFT", -2, 0)

    -- Search box (fills space between tabs and Map All)
    local searchBox = CreateFrame("EditBox", nil, leftPanel, "SearchBoxTemplate")
    searchBox:SetPoint("LEFT", byItemBtn, "RIGHT", 6, 0)
    searchBox:SetPoint("RIGHT", mapAllBtn, "LEFT", -4, 0)
    searchBox:SetHeight(ACQ.VIEW_TOGGLE_HEIGHT)
    searchBox:SetAutoFocus(false)
    searchBox:SetScript("OnTextChanged", function(self)
        SearchBoxTemplate_OnTextChanged(self)
        HDG_AcqData.filters.search = self:GetText():lower()
        if searchDebounceTimer then searchDebounceTimer:Cancel() end
        searchDebounceTimer = C_Timer.NewTimer(ACQ.SEARCH_DEBOUNCE, function()
            HDG_AcquisitionTab:RefreshList()
        end)
    end)
    RegisterWidget(searchBox, "SearchBox")

    -- ========================================================================
    -- ROW 2: EXPANSION / ZONE / REPUTATION DROPDOWNS (no labels)
    -- ========================================================================

    local row2Y = viewRowY - ACQ.VIEW_TOGGLE_HEIGHT - 4

    local function activeFilterColor(val)
        if val == "All" then return nil end
        local s = GetScheme()
        return s and { s.accent.r, s.accent.g, s.accent.b }
    end

    -- Distribute 3 dropdowns evenly across available width
    local ddGap = 3
    local ddWidth = math.floor((leftWidth - 12 - ddGap * 2) / 3)

    local expansionDropdown = HDG.UI:Dropdown(leftPanel, {
        items = HDG_AcqData.uniqueExpansions,
        currentValue = "All",
        allLabel = "All Expansions",
        width = ddWidth,
        colorFn = function(val)
            if val == "All" then return nil end
            return HDG_ExpansionData.GetColor(val)
        end,
        onChange = function(val)
            HDG_AcqData.filters.expansion = val
            HDG_AcquisitionTab:RefreshList()
        end,
    })
    expansionDropdown:SetPoint("TOPLEFT", 6, row2Y)
    acquisitionPanel.expansionDropdown = expansionDropdown

    local zoneDropdown = HDG.UI:Dropdown(leftPanel, {
        items = HDG_AcqData.uniqueZones,
        currentValue = "All",
        allLabel = "All Zones",
        width = ddWidth,
        colorFn = activeFilterColor,
        onChange = function(val)
            HDG_AcqData.filters.zone = val
            HDG_AcquisitionTab:RefreshList()
        end,
    })
    zoneDropdown:SetPoint("LEFT", expansionDropdown, "RIGHT", ddGap, 0)
    acquisitionPanel.zoneDropdown = zoneDropdown

    local factionDropdown = HDG.UI:Dropdown(leftPanel, {
        items = HDG_AcqData.uniqueFactions,
        currentValue = "All",
        allLabel = "All Reps",
        width = ddWidth,
        colorFn = activeFilterColor,
        onChange = function(val)
            HDG_AcqData.filters.faction = val
            HDG_AcquisitionTab:RefreshList()
        end,
    })
    factionDropdown:SetPoint("LEFT", zoneDropdown, "RIGHT", ddGap, 0)
    acquisitionPanel.factionDropdown = factionDropdown

    -- ========================================================================
    -- ROW 3: SOURCE DROPDOWN + CHECKBOXES
    -- ========================================================================

    local row3Y = row2Y - 24

    local sourceDropdown = HDG.UI:Dropdown(leftPanel, {
        items = VENDOR_SOURCES,
        currentValue = "All",
        allLabel = "All Sources",
        width = 90,
        colorFn = activeFilterColor,
        onChange = function(val)
            HDG_AcqData.filters.source = val
            HDG_AcquisitionTab:RefreshList()
        end,
    })
    sourceDropdown:SetPoint("TOPLEFT", 6, row3Y)
    acquisitionPanel.sourceDropdown = sourceDropdown

    local uncollectedCheck = HDG.UI:Checkbox(leftPanel, 14, "Uncollected")
    uncollectedCheck:SetPoint("LEFT", sourceDropdown, "RIGHT", 8, 0)
    uncollectedCheck.onChanged = function(checked)
        HDG_AcqData.filters.uncollectedOnly = checked
        HDG_AcquisitionTab:RefreshList()
    end

    local dyeableCheck = HDG.UI:Checkbox(leftPanel, 14, "Dye")
    dyeableCheck:SetPoint("LEFT", uncollectedCheck, "LEFT", 96, 0)
    dyeableCheck.onChanged = function(checked)
        HDG_AcqData.filters.dyeableOnly = checked
        HDG_AcquisitionTab:RefreshList()
    end

    -- Reset Filters button
    local resetBtn = HDG.UI:Button(leftPanel, L["ACQ_RESET"], 42, 20, function()
        HDG_AcqData.filters.expansion = "All"
        HDG_AcqData.filters.zone = "All"
        HDG_AcqData.filters.faction = "All"
        HDG_AcqData.filters.source = "All"
        HDG_AcqData.filters.uncollectedOnly = false
        HDG_AcqData.filters.dyeableOnly = false
        HDG_AcqData.filters.search = ""
        expansionDropdown:SetValue("All")
        zoneDropdown:SetValue("All")
        factionDropdown:SetValue("All")
        sourceDropdown:SetValue("All")
        uncollectedCheck:SetChecked(false)
        dyeableCheck:SetChecked(false)
        searchBox:SetText("")
        HDG_AcquisitionTab:RefreshList()
    end)
    resetBtn:SetPoint("TOPRIGHT", leftPanel, "TOPRIGHT", -6, row3Y)
    HDG_UIHelpers.SetupTooltip(resetBtn, L["ACQ_RESET"], "Reset all filters to defaults.", "ANCHOR_BOTTOM")

    -- Item list scroll frame
    local listScrollFrame = CreateFrame("ScrollFrame", nil, leftPanel, "UIPanelScrollFrameTemplate")
    listScrollFrame:SetPoint("TOPLEFT", 4, row3Y - 24)
    listScrollFrame:SetPoint("BOTTOMRIGHT", -20, 24)
    HDG_UIHelpers.StyleScrollBar(listScrollFrame)

    local listContent = CreateFrame("Frame", nil, listScrollFrame)
    listContent:SetWidth(leftWidth - 28)
    listContent:SetHeight(1)
    listScrollFrame:SetScrollChild(listContent)
    acquisitionPanel.listContent = listContent
    acquisitionPanel.listScrollFrame = listScrollFrame
    RegisterWidget(listScrollFrame, "ScrollFrame")

    -- Virtual scroll hook — fires on all scroll events, checks flag before acting
    listScrollFrame:HookScript("OnVerticalScroll", function(_, offset)
        if virtualScrollActive then
            if virtualScrollDebounce then virtualScrollDebounce:Cancel() end
            virtualScrollDebounce = C_Timer.NewTimer(0.02, function()
                HDG_AcquisitionTab:UpdateVisibleItemRows(offset)
            end)
        end
    end)

    -- Arrow key navigation
    leftPanel:EnableKeyboard(true)
    leftPanel:SetScript("OnKeyDown", function(self, key)
        if (key ~= "UP" and key ~= "DOWN") or #navigableItems == 0 then
            self:SetPropagateKeyboardInput(true)
            return
        end
        self:SetPropagateKeyboardInput(false)

        local curIdx = 0
        if selectedItemID then
            for i, entry in ipairs(navigableItems) do
                if entry.itemID == selectedItemID and (not entry.npcID or entry.npcID == selectedNpcID) then
                    curIdx = i; break
                end
            end
            if curIdx == 0 then -- fallback: match by itemID only
                for i, entry in ipairs(navigableItems) do
                    if entry.itemID == selectedItemID then curIdx = i; break end
                end
            end
        end

        local newIdx
        if key == "DOWN" then
            newIdx = curIdx < #navigableItems and curIdx + 1 or 1
        else
            newIdx = curIdx > 1 and curIdx - 1 or #navigableItems
        end

        local entry = navigableItems[newIdx]
        HDG_AcquisitionTab:SelectItem(entry.itemID, entry.npcID)

        -- Auto-scroll to keep selection visible
        local rowH = CONFIG.ACQUISITION_TAB.ITEM_ROW_SPACING
        local rowTop
        if viewMode == "vendor" then
            -- In vendor mode, compute offset accounting for vendor headers + item positions
            local yAcc = 0
            for _, npcID in ipairs(HDG_AcqData.filteredVendors) do
                yAcc = yAcc + CONFIG.ACQUISITION_TAB.VENDOR_ROW_HEIGHT
                if expandedVendors[npcID] then
                    local cachedItems = HDG_AcqData.vendorFilteredItems[npcID] or {}
                    for _, itemID in ipairs(cachedItems) do
                        if itemID == entry.itemID and npcID == entry.npcID then
                            rowTop = yAcc
                        end
                        yAcc = yAcc + rowH
                    end
                end
            end
            rowTop = rowTop or (newIdx - 1) * rowH
        else
            rowTop = (newIdx - 1) * rowH
        end
        local sf = acquisitionPanel.listScrollFrame
        local viewH = sf:GetHeight()
        local curScroll = sf:GetVerticalScroll()
        if rowTop < curScroll then
            sf:SetVerticalScroll(rowTop)
        elseif rowTop + rowH > curScroll + viewH then
            sf:SetVerticalScroll(rowTop + rowH - viewH)
        end
    end)

    -- Status bar at bottom of left panel
    local statusBar = leftPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    statusBar:SetPoint("BOTTOMLEFT", 6, 6)
    statusBar:SetPoint("BOTTOMRIGHT", -6, 6)
    statusBar:SetJustifyH("LEFT")
    if scheme then
        ApplyFont(statusBar, scheme, "small")
        statusBar:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, scheme.text_dim.a)
    end
    acquisitionPanel.statusBar = statusBar

    -- ========================================================================
    -- RIGHT PANEL — Unified 3-section layout
    -- ========================================================================

    local rightPanel = HDG.UI:Panel(acquisitionPanel, { width = rightWidth })
    rightPanel:SetPoint("TOPLEFT", leftPanel, "TOPRIGHT", 2, 0)
    rightPanel:SetPoint("BOTTOMRIGHT", -1, 1)
    acquisitionPanel.rightPanel = rightPanel

    -- ---- Top: headerPanel (fixed height) ----
    local headerPanel = HDG.UI:Panel(rightPanel)
    headerPanel:SetPoint("TOPLEFT", 4, -4)
    headerPanel:SetPoint("TOPRIGHT", -4, -4)
    headerPanel:SetHeight(ACQ.HEADER_PANEL_HEIGHT)
    headerPanel:Hide()
    acquisitionPanel.headerPanel = headerPanel

    headerPanel.itemName = headerPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    headerPanel.itemName:SetPoint("TOPLEFT", 8, -6)
    headerPanel.itemName:SetPoint("RIGHT", headerPanel, "RIGHT", -110, 0)
    headerPanel.itemName:SetWordWrap(true)
    headerPanel.itemName:SetJustifyH("LEFT")
    if scheme then
        ApplyFont(headerPanel.itemName, scheme, "header")
        headerPanel.itemName:SetTextColor(scheme.text_header.r, scheme.text_header.g, scheme.text_header.b, scheme.text_header.a)
    end

    headerPanel.subtitle = headerPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    headerPanel.subtitle:SetPoint("TOPLEFT", headerPanel.itemName, "BOTTOMLEFT", 0, -2)
    headerPanel.subtitle:SetPoint("RIGHT", headerPanel, "RIGHT", -110, 0)
    headerPanel.subtitle:SetJustifyH("LEFT")
    if scheme then
        ApplyFont(headerPanel.subtitle, scheme, "small")
    end
    headerPanel.subtitle:Hide()

    headerPanel.collectedBadge = headerPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    headerPanel.collectedBadge:SetPoint("RIGHT", headerPanel, "RIGHT", -8, 0)
    headerPanel.collectedBadge:SetJustifyH("RIGHT")
    if scheme then
        ApplyFont(headerPanel.collectedBadge, scheme, "small")
    end

    -- Vendor note pencil icon + text (anchored at bottom of header)
    local vendorNoteBtnH = CreateFrame("Button", nil, headerPanel)
    vendorNoteBtnH:SetSize(12, 12)
    vendorNoteBtnH:SetPoint("BOTTOMLEFT", 8, 4)
    vendorNoteBtnH:SetNormalAtlas("communities-icon-notification")
    vendorNoteBtnH:SetHighlightAtlas("communities-icon-notification")
    vendorNoteBtnH:SetScript("OnClick", function()
        if selectedVendor and selectedVendor.npcID then
            local vName = selectedVendor.name or ("NPC #" .. selectedVendor.npcID)
            StaticPopup_Show("HDG_EDIT_VENDOR_NOTE", vName, nil, selectedVendor.npcID)
        end
    end)
    HDG_UIHelpers.SetupTooltip(vendorNoteBtnH, "Vendor Note", "Click to add or edit a note for this vendor.", "ANCHOR_RIGHT")
    headerPanel.vendorNoteBtn = vendorNoteBtnH

    headerPanel.vendorNoteText = headerPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    headerPanel.vendorNoteText:SetPoint("LEFT", vendorNoteBtnH, "RIGHT", 4, 0)
    headerPanel.vendorNoteText:SetPoint("RIGHT", headerPanel, "RIGHT", -60, 0)
    headerPanel.vendorNoteText:SetWordWrap(false)
    headerPanel.vendorNoteText:SetJustifyH("LEFT")
    if scheme then ApplyFont(headerPanel.vendorNoteText, scheme, "small") end
    headerPanel.vendorNoteText:SetText(HDG.Theme.C_Dim() .. "Add vendor note...|r")

    -- Make vendor note text clickable
    local vendorNoteClickZone = CreateFrame("Button", nil, headerPanel)
    vendorNoteClickZone:SetPoint("TOPLEFT", headerPanel.vendorNoteText, "TOPLEFT", 0, 2)
    vendorNoteClickZone:SetPoint("BOTTOMRIGHT", headerPanel.vendorNoteText, "BOTTOMRIGHT", 0, -2)
    vendorNoteClickZone:SetScript("OnClick", function()
        if selectedVendor and selectedVendor.npcID then
            local vName = selectedVendor.name or ("NPC #" .. selectedVendor.npcID)
            StaticPopup_Show("HDG_EDIT_VENDOR_NOTE", vName, nil, selectedVendor.npcID)
        end
    end)
    headerPanel.vendorNoteClickZone = vendorNoteClickZone

    -- ---- Middle: detailPanel (flexible height) ----
    local detailPanel = HDG.UI:Panel(rightPanel)
    detailPanel:SetPoint("TOPLEFT", headerPanel, "BOTTOMLEFT", 0, -2)
    detailPanel:SetPoint("RIGHT", rightPanel, "RIGHT", -4, 0)
    detailPanel:SetPoint("BOTTOM", rightPanel, "BOTTOM", 0, ACQ.GALLERY_HEIGHT + 24)
    detailPanel:Hide()
    acquisitionPanel.detailPanel = detailPanel

    -- ---- Preview frame (left, Blizzard HousingModelPreview replica) ----
    local previewFrame = HDG_UIHelpers.CreateDecorPreview(detailPanel, { squareAspect = true })
    previewFrame:SetPoint("TOPLEFT", 4, -4)
    previewFrame:SetPoint("BOTTOMLEFT", 4, 4)
    previewFrame:SetWidth(ACQ.SELECTED_PREVIEW_SIZE)
    detailPanel.previewFrame = previewFrame

    -- ---- Info frame (right sub-frame, text lines + other vendors) ----
    local infoFrame = CreateFrame("Frame", nil, detailPanel)
    infoFrame:SetPoint("TOPLEFT", previewFrame, "TOPRIGHT", 4, 0)
    infoFrame:SetPoint("BOTTOMRIGHT", -4, 4)
    detailPanel.infoFrame = infoFrame

    -- Cart buttons [+][-] for shopping list — bottom-right of info frame
    detailPanel.cartBtns = HDG_UIHelpers.CreateCartButtons(infoFrame, 36)
    detailPanel.cartBtns:SetPoint("BOTTOMRIGHT", infoFrame, "BOTTOMRIGHT", -4, 12)
    detailPanel.cartBtns:Hide()

    -- "N in cart" label beneath the cart buttons
    detailPanel.cartLabel = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    detailPanel.cartLabel:SetPoint("TOPRIGHT", detailPanel.cartBtns, "BOTTOMRIGHT", 0, -2)
    detailPanel.cartLabel:SetJustifyH("RIGHT")
    if scheme then ApplyFont(detailPanel.cartLabel, scheme, "small") end
    detailPanel.cartLabel:Hide()

    local function MakeDetailLine(parent, anchorTo, anchorPoint, yOff)
        local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        if anchorPoint == "TOPLEFT" then
            fs:SetPoint("TOPLEFT", anchorTo, "BOTTOMLEFT", 0, yOff or -2)
        else
            fs:SetPoint("TOPLEFT", anchorTo, "TOPRIGHT", 8, yOff or -2)
        end
        fs:SetPoint("RIGHT", parent, "RIGHT", -8, 0)
        fs:SetWordWrap(true)
        fs:SetJustifyH("LEFT")
        if scheme then ApplyFont(fs, scheme, "small") end
        return fs
    end

    detailPanel.line1 = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    detailPanel.line1:SetPoint("TOPLEFT", 8, -6)
    detailPanel.line1:SetPoint("RIGHT", infoFrame, "RIGHT", -8, 0)
    detailPanel.line1:SetWordWrap(true)
    detailPanel.line1:SetJustifyH("LEFT")
    if scheme then ApplyFont(detailPanel.line1, scheme, "small") end

    detailPanel.line2 = MakeDetailLine(infoFrame, detailPanel.line1, "TOPLEFT")
    detailPanel.line3 = MakeDetailLine(infoFrame, detailPanel.line2, "TOPLEFT")
    detailPanel.line4 = MakeDetailLine(infoFrame, detailPanel.line3, "TOPLEFT")
    detailPanel.line5 = MakeDetailLine(infoFrame, detailPanel.line4, "TOPLEFT")
    detailPanel.line6 = MakeDetailLine(infoFrame, detailPanel.line5, "TOPLEFT")
    detailPanel.line7 = MakeDetailLine(infoFrame, detailPanel.line6, "TOPLEFT")
    detailPanel.line8 = MakeDetailLine(infoFrame, detailPanel.line7, "TOPLEFT")
    detailPanel.line9 = MakeDetailLine(infoFrame, detailPanel.line8, "TOPLEFT")

    -- Other-vendors label + clickable button pool
    detailPanel.otherVendorsLabel = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    detailPanel.otherVendorsLabel:SetPoint("TOPLEFT", detailPanel.line9, "BOTTOMLEFT", 0, -6)
    detailPanel.otherVendorsLabel:SetPoint("RIGHT", infoFrame, "RIGHT", -8, 0)
    detailPanel.otherVendorsLabel:SetWordWrap(true)
    detailPanel.otherVendorsLabel:SetJustifyH("LEFT")
    if scheme then
        ApplyFont(detailPanel.otherVendorsLabel, scheme, "small")
        detailPanel.otherVendorsLabel:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, scheme.text_dim.a)
    end

    -- Item note display (pencil icon + text below other vendors)
    local itemNoteBtn = CreateFrame("Button", nil, infoFrame)
    itemNoteBtn:SetSize(12, 12)
    itemNoteBtn:SetPoint("TOPLEFT", detailPanel.otherVendorsLabel, "BOTTOMLEFT", 0, -6)
    itemNoteBtn:SetNormalAtlas("communities-icon-notification")
    itemNoteBtn:SetHighlightAtlas("communities-icon-notification")
    itemNoteBtn:SetScript("OnClick", function()
        if selectedItemID then
            local iName = HDG_Data.GetCachedItemName(selectedItemID) or ("Item #" .. selectedItemID)
            StaticPopup_Show("HDG_EDIT_USER_NOTE", iName, nil, selectedItemID)
        end
    end)
    HDG_UIHelpers.SetupTooltip(itemNoteBtn, "Item Note", "Click to add or edit a personal note for this item.", "ANCHOR_RIGHT")
    detailPanel.itemNoteBtn = itemNoteBtn

    detailPanel.itemNoteText = infoFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    detailPanel.itemNoteText:SetPoint("LEFT", itemNoteBtn, "RIGHT", 4, 0)
    detailPanel.itemNoteText:SetPoint("RIGHT", infoFrame, "RIGHT", -8, 0)
    detailPanel.itemNoteText:SetWordWrap(true)
    detailPanel.itemNoteText:SetJustifyH("LEFT")
    if scheme then ApplyFont(detailPanel.itemNoteText, scheme, "small") end

    -- Waypoint / Map buttons
    local waypointBtn = HDG.UI:Button(rightPanel, L["ACQ_NAVIGATE"], ACQ.WAYPOINT_BTN_WIDTH, ACQ.WAYPOINT_BTN_HEIGHT, function()
        if not selectedVendor then return end
        if selectedVendor.mapID == 0 or (selectedVendor.x == 0 and selectedVendor.y == 0) then
            print("|cFFFFAA00[HDG]|r " .. L["ACQ_NO_COORDS"])
            return
        end
        local ok, provider = HDG.Waypoints:Set(selectedVendor.mapID, selectedVendor.x, selectedVendor.y, selectedVendor.name)
        HDG.Waypoints:PrintResult(ok, provider, selectedVendor.name, nil, selectedVendor.mapID, selectedVendor.vendorFaction)
    end)
    waypointBtn:SetPoint("TOPRIGHT", headerPanel, "TOPRIGHT", -6, -6)
    waypointBtn:Hide()
    acquisitionPanel.waypointBtn = waypointBtn

    local mapBtn = HDG.UI:Button(rightPanel, L["ACQ_SHOW_ON_MAP"], ACQ.WAYPOINT_BTN_WIDTH, ACQ.WAYPOINT_BTN_HEIGHT, function()
        if not selectedVendor then return end
        if InCombatLockdown() then
            print("|cFFFF0000[HDG]|r " .. L["ACQ_COMBAT_MAP_ERROR"])
            return
        end
        if selectedVendor.mapID == 0 or (selectedVendor.x == 0 and selectedVendor.y == 0) then
            print("|cFFFFAA00[HDG]|r " .. L["ACQ_NO_COORDS"])
            return
        end
        local ok, provider = HDG.Waypoints:Set(selectedVendor.mapID, selectedVendor.x, selectedVendor.y, selectedVendor.name)
        if not ok then
            print("|cFFFFAA00[HDG]|r " .. L["ACQ_MAP_PIN_ERROR"])
        end
        -- Always open the world map for "Show on Map" (Set() may skip it on TomTom path)
        local uiMapID = HDG_VendorResolver.ZonePctToMap(selectedVendor.mapID, selectedVendor.x, selectedVendor.y)
        if uiMapID and not InCombatLockdown() then pcall(OpenWorldMap, uiMapID) end
    end)
    mapBtn:SetPoint("TOPRIGHT", waypointBtn, "BOTTOMRIGHT", 0, -2)
    mapBtn:Hide()
    acquisitionPanel.mapBtn = mapBtn

    -- ---- Bottom (A): galleryPanel — vendor items gallery ----
    local galleryPanel = CreateFrame("Frame", nil, rightPanel)
    galleryPanel:SetPoint("BOTTOMLEFT", 0, 0)
    galleryPanel:SetPoint("BOTTOMRIGHT", 0, 0)
    galleryPanel:SetHeight(ACQ.GALLERY_HEIGHT + 22)
    galleryPanel:Hide()
    acquisitionPanel.galleryPanel = galleryPanel

    local galleryHeader = galleryPanel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    galleryHeader:SetPoint("TOPLEFT", galleryPanel, "TOPLEFT", 8, -2)
    if scheme then
        ApplyFont(galleryHeader, scheme, "body")
        galleryHeader:SetTextColor(scheme.text_header.r, scheme.text_header.g, scheme.text_header.b, scheme.text_header.a)
    end
    galleryHeader:SetText("")
    acquisitionPanel.galleryHeader = galleryHeader

    local costSummary = galleryPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    costSummary:SetPoint("LEFT", galleryHeader, "RIGHT", 8, 0)
    if scheme then
        ApplyFont(costSummary, scheme, "small")
        costSummary:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, scheme.text_dim.a)
    end
    costSummary:SetText("")
    acquisitionPanel.costSummary = costSummary

    local galleryScroll = CreateFrame("ScrollFrame", nil, galleryPanel, "UIPanelScrollFrameTemplate")
    galleryScroll:SetPoint("TOPLEFT", galleryPanel, "TOPLEFT", 4, -18)
    galleryScroll:SetPoint("BOTTOMRIGHT", galleryPanel, "BOTTOMRIGHT", -20, 4)
    HDG_UIHelpers.StyleScrollBar(galleryScroll)

    local galleryContent = CreateFrame("Frame", nil, galleryScroll)
    galleryContent:SetWidth(rightWidth - 28)
    galleryContent:SetHeight(1)
    galleryScroll:SetScrollChild(galleryContent)
    acquisitionPanel.galleryContent = galleryContent
    acquisitionPanel.galleryScroll = galleryScroll
    RegisterWidget(galleryScroll, "ScrollFrame")

    -- ---- Bottom (B): infoPanel — non-vendor item info ----
    local infoPanel = HDG.UI:Panel(rightPanel)
    infoPanel:SetPoint("BOTTOMLEFT", 0, 0)
    infoPanel:SetPoint("BOTTOMRIGHT", 0, 0)
    infoPanel:SetHeight(ACQ.GALLERY_HEIGHT + 22)
    infoPanel:Hide()
    acquisitionPanel.infoPanel = infoPanel

    infoPanel.sourceLine = infoPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    infoPanel.sourceLine:SetPoint("TOPLEFT", 8, -8)
    infoPanel.sourceLine:SetPoint("RIGHT", infoPanel, "RIGHT", -8, 0)
    infoPanel.sourceLine:SetWordWrap(true)
    infoPanel.sourceLine:SetJustifyH("LEFT")
    if scheme then ApplyFont(infoPanel.sourceLine, scheme, "small") end

    infoPanel.expansionLine = infoPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    infoPanel.expansionLine:SetPoint("TOPLEFT", infoPanel.sourceLine, "BOTTOMLEFT", 0, -4)
    infoPanel.expansionLine:SetPoint("RIGHT", infoPanel, "RIGHT", -8, 0)
    infoPanel.expansionLine:SetWordWrap(true)
    infoPanel.expansionLine:SetJustifyH("LEFT")
    if scheme then ApplyFont(infoPanel.expansionLine, scheme, "small") end

    infoPanel.extraLine = infoPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    infoPanel.extraLine:SetPoint("TOPLEFT", infoPanel.expansionLine, "BOTTOMLEFT", 0, -4)
    infoPanel.extraLine:SetPoint("RIGHT", infoPanel, "RIGHT", -8, 0)
    infoPanel.extraLine:SetWordWrap(true)
    infoPanel.extraLine:SetJustifyH("LEFT")
    if scheme then ApplyFont(infoPanel.extraLine, scheme, "small") end

    -- Placeholder guide panel (scrollable, shown before any item is selected)
    local placeholder = CreateFrame("Frame", nil, rightPanel)
    placeholder:SetAllPoints()

    local guideScroll = CreateFrame("ScrollFrame", nil, placeholder, "UIPanelScrollFrameTemplate")
    guideScroll:SetPoint("TOPLEFT", 0, -4)
    guideScroll:SetPoint("BOTTOMRIGHT", -20, 4)
    HDG_UIHelpers.StyleScrollBar(guideScroll)
    RegisterWidget(guideScroll, "ScrollFrame")

    local guideContent = CreateFrame("Frame", nil, guideScroll)
    guideContent:SetWidth(rightWidth - 28)
    guideContent:SetHeight(1)
    guideScroll:SetScrollChild(guideContent)

    local guideTitle = guideContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    guideTitle:SetPoint("TOPLEFT", 8, -4)
    guideTitle:SetPoint("RIGHT", guideContent, "RIGHT", -8, 0)
    guideTitle:SetJustifyH("LEFT")
    if scheme then
        ApplyFont(guideTitle, scheme, "header")
        guideTitle:SetTextColor(scheme.text_header.r, scheme.text_header.g, scheme.text_header.b, scheme.text_header.a)
    end
    guideTitle:SetText(L["ACQ_TITLE"])

    local guideBody = guideContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    guideBody:SetPoint("TOPLEFT", guideTitle, "BOTTOMLEFT", 0, -8)
    guideBody:SetPoint("RIGHT", guideContent, "RIGHT", -8, 0)
    guideBody:SetJustifyH("LEFT")
    guideBody:SetWordWrap(true)
    guideBody:SetSpacing(3)
    if scheme then
        ApplyFont(guideBody, scheme, "small")
        guideBody:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a)
    end

    -- Store refs for dynamic rebuild
    placeholder.guideTitle = guideTitle
    placeholder.guideBody = guideBody
    placeholder.guideContent = guideContent

    acquisitionPanel.placeholder = placeholder

    return acquisitionPanel
end

-- ============================================================================
-- REFRESH ITEM LIST (By Item view)
-- ============================================================================

-- Rebuild guide text with current theme colors (called each time guide is shown)
-- NOTE: Uses SetTextColor for default text; only accent |c codes for highlights.
-- WoW FontStrings have a limit on inline color codes — minimizing |c usage is critical.
RefreshGuideText = function()
    local ph = acquisitionPanel.placeholder
    if not ph or not ph.guideBody then return end
    local a = HDG.Theme.C_Accent()
    local ico = function(atlas) return CreateAtlasMarkup(atlas, 14, 14) end
    local chk = "|TInterface\\RAIDFRAME\\ReadyCheck-Ready:14:14|t"
    local achv = ico("UI-HUD-MicroMenu-Achievements-Mouseover")
    local anvil = string.format("|T%s:14:14|t", CONFIG.TEXTURES.CRAFTED_ICON)

    -- |r resets to the FontString's SetTextColor (text color), so no need for t prefix
    local guideText = table.concat({
        a .. "Getting Started|r",
        "1. Pick a view on the left: " .. a .. "By Vendor|r or " .. a .. "By Item|r.",
        "2. Use the filters to narrow down what you're looking for.",
        "3. Click any item to see its details here.",
        "",
        a .. "Common Use Cases|r",
        "1. Find what you're missing — Check " .. a .. "Uncollected|r, switch to " .. a .. "By Item|r, and use " .. a .. "Search|r to find items by name.",
        "2. Plan a shopping trip — Pick an " .. a .. "Expansion|r, check " .. a .. "Uncollected|r, then hit " .. a .. "Map All|r to set waypoints for every vendor you need.",
        "3. Check rep-gated items — Set " .. a .. "Source|r to " .. a .. "Reputation|r to see which items need rep.",
        "4. Queue crafted items — Set " .. a .. "Source|r to " .. a .. "Crafted|r, select an item, then click " .. a .. "Add to Crafting Queue|r to send it to the Recipes tab.",
        "5. Preview before you buy — Click any item to see its 3D model, then use " .. a .. "Set Waypoint|r or " .. a .. "Show on Map|r to navigate to the vendor.",
        "",
        a .. "Views|r",
        a .. "By Vendor|r — Browse vendors and expand them to see their stock. Each row shows how many items you've collected (e.g. 3/5).",
        a .. "By Item|r — A flat alphabetical list of every acquirable item. Best for searching across all sources at once.",
        "",
        a .. "Filters|r",
        a .. "Expansion|r — Show only items from a specific expansion.",
        a .. "Zone|r — Limit to vendors in a specific zone.",
        a .. "Reputation|r — Show items requiring a specific faction.",
        a .. "Source|r — Filter by acquisition method: Vendor, Quest, Achievement, Drop, Crafted, Treasure, or Promotion.",
        a .. "Uncollected|r — Hide items you already own.",
        "",
        a .. "Map All|r",
        "Drops a waypoint on every vendor matching your filters. Uses TomTom if installed, or Blizzard's native waypoints. Combine with " .. a .. "Expansion|r or " .. a .. "Zone|r filters to plan a focused shopping route.",
        "",
        a .. "Row Icons|r",
        chk .. " Collected — You already own this item.",
        ico("gficon-chest-evergreen-greatvault-collect") .. " Rep Met — You have enough reputation to buy this item.",
        ico("gficon-chest-evergreen-greatvault-incomplete") .. " Rep Not Met — You need more reputation with this faction.",
        achv .. " " .. a .. "Done|r (green) — Achievement completed, or item collected from a non-vendor source.",
        achv .. " " .. a .. "Incomplete|r (orange) — Achievement or non-vendor source not yet completed.",
        ico("communities-create-button-wow-alliance") .. " Alliance — This vendor is Alliance-only.",
        ico("communities-create-button-wow-horde") .. " Horde — This vendor is Horde-only.",
        "",
        a .. "Source Icons|r",
        achv .. " Achievement — Earned from an achievement.",
        ico("QuestNormal") .. " Quest / World Quest — Rewarded from a quest.",
        ico("Crosshair_lootall_32") .. " Drop — Looted from a creature or boss.",
        anvil .. " Crafted — Made via a profession recipe.",
        ico("VignetteLoot") .. " Treasure — Found in the world.",
        ico("Professions-Crafting-Orders-Icon") .. " Promotion — From a special event or promotion.",
    }, "\n")

    -- Apply current theme colors to fonts
    local scheme = HDG.Theme.currentScheme
    if scheme then
        ApplyFont(ph.guideTitle, scheme, "header")
        ph.guideTitle:SetTextColor(scheme.text_header.r, scheme.text_header.g, scheme.text_header.b, 1)
        ApplyFont(ph.guideBody, scheme, "small")
        ph.guideBody:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, 1)
    end

    ph.guideBody:SetText(guideText)
    ph.guideContent:SetHeight(ph.guideTitle:GetStringHeight() + 8 + ph.guideBody:GetStringHeight() + 16)
end

-- Virtual scroll threshold — above this many items, only render visible rows
local VIRTUAL_SCROLL_THRESHOLD = 200

function HDG_AcquisitionTab:RefreshItemList()
    local content = acquisitionPanel.listContent
    local listWidth = content:GetWidth()
    if listWidth == 0 then listWidth = 200 end

    local ACQ = CONFIG.ACQUISITION_TAB
    local totalItems = #HDG_AcqData.filteredItems

    local uncollectedCount = HDG_AcqData.filteredUncollectedCount

    -- Build navigable items list for arrow key nav
    wipe(navigableItems)
    for i, itemID in ipairs(HDG_AcqData.filteredItems) do
        navigableItems[i] = { itemID = itemID }
    end

    if totalItems > VIRTUAL_SCROLL_THRESHOLD then
        -- Virtual scroll: only create rows for visible viewport
        virtualScrollActive = true
        virtualScrollFirstIdx = 0 -- force first render

        local scrollFrame = acquisitionPanel.listScrollFrame
        local visibleHeight = scrollFrame:GetHeight()
        if visibleHeight == 0 then visibleHeight = 400 end

        -- Set total content height (for scrollbar range)
        content:SetHeight(totalItems * ACQ.ITEM_ROW_SPACING + 10)

        -- Render initial visible set
        self:UpdateVisibleItemRows(0)
    else
        -- Small list: create all rows directly (existing behavior)
        virtualScrollActive = false
        local yOffset = 0

        for _, itemID in ipairs(HDG_AcqData.filteredItems) do
            HDG_AcqRows.CreateItemRow(content, itemID, yOffset, listWidth, nil, selectedItemID, onSelectItem, CONFIG)
            yOffset = yOffset - ACQ.ITEM_ROW_SPACING
        end
        content:SetHeight(math.abs(yOffset) + 10)
    end

    return uncollectedCount
end

-- Update visible rows for virtual scrolling (called on scroll and initial render)
function HDG_AcquisitionTab:UpdateVisibleItemRows(scrollOffset)
    local ACQ = CONFIG.ACQUISITION_TAB
    local content = acquisitionPanel.listContent
    local listWidth = content:GetWidth()
    if listWidth == 0 then listWidth = 200 end

    local scrollFrame = acquisitionPanel.listScrollFrame
    local visibleHeight = scrollFrame:GetHeight()
    if visibleHeight == 0 then visibleHeight = 400 end
    local maxVisible = math.ceil(visibleHeight / ACQ.ITEM_ROW_SPACING) + 4

    local newFirst = math.max(1, math.floor(scrollOffset / ACQ.ITEM_ROW_SPACING) + 1)
    if newFirst == virtualScrollFirstIdx then return end -- no change
    virtualScrollFirstIdx = newFirst

    -- Release existing item rows and recreate for new viewport
    HDG_AcqRows.ReleaseAllRows()

    local items = HDG_AcqData.filteredItems
    local lastIdx = math.min(newFirst + maxVisible - 1, #items)

    for i = newFirst, lastIdx do
        local itemID = items[i]
        local yOffset = -(i - 1) * ACQ.ITEM_ROW_SPACING
        HDG_AcqRows.CreateItemRow(content, itemID, yOffset, listWidth, nil, selectedItemID, onSelectItem, CONFIG)
    end
end

-- ============================================================================
-- REFRESH VENDOR LIST (By Vendor view)
-- ============================================================================

-- Hoisted toggle handler (captures module-scope expandedVendors/selectedVendor)
local function onVendorToggle(npcID)
    expandedVendors[npcID] = not expandedVendors[npcID]
    local ve = HDG_VendorDB and HDG_VendorDB[npcID]
    if ve then
        selectedVendor = {
            npcID = npcID,
            name = ve[1] or ("NPC #" .. npcID),
            zone = ve[2] or "Unknown",
            mapID = ve[3] or 0,
            x = ve[4] or 0,
            y = ve[5] or 0,
            vendorFaction = ve[6] or "N",
        }
    end
    HDG_AcquisitionTab:RefreshList(true)
    if expandedVendors[npcID] then
        local cachedItems = HDG_AcqData.vendorFilteredItems[npcID]
        if cachedItems and cachedItems[1] then
            HDG_AcquisitionTab:SelectItem(cachedItems[1], npcID)
        end
    end
end

function HDG_AcquisitionTab:RefreshVendorList()
    local content = acquisitionPanel.listContent
    local listWidth = content:GetWidth()
    if listWidth == 0 then listWidth = 200 end

    local yOffset = 0
    local uncollectedCount = 0
    local ACQ = CONFIG.ACQUISITION_TAB

    HDG_AcqData.RebuildFilteredVendors(CONFIG)
    wipe(navigableItems)

    for _, npcID in ipairs(HDG_AcqData.filteredVendors) do
        HDG_AcqRows.CreateVendorHeaderRow(content, npcID, yOffset, listWidth, expandedVendors[npcID], onVendorToggle, CONFIG)
        yOffset = yOffset - ACQ.VENDOR_ROW_HEIGHT

        if expandedVendors[npcID] then
            -- Use cached filtered items from RebuildFilteredVendors (avoids re-calling ItemPassesFilters)
            wipe(reusableItemList)
            local cachedItems = HDG_AcqData.vendorFilteredItems[npcID] or {}
            for _, itemID in ipairs(cachedItems) do
                table.insert(reusableItemList, itemID)
            end
            table.sort(reusableItemList, sortByItemName)

            for _, itemID in ipairs(reusableItemList) do
                if not HDG_Data.IsItemCollected(itemID) then
                    uncollectedCount = uncollectedCount + 1
                end
                navigableItems[#navigableItems + 1] = { itemID = itemID, npcID = npcID }
                local iRow = HDG_AcqRows.CreateItemRow(content, itemID, yOffset, listWidth - ACQ.VENDOR_INDENT, ACQ.VENDOR_INDENT, selectedItemID, onSelectItem, CONFIG)
                if iRow then iRow.parentNpcID = npcID end
                yOffset = yOffset - ACQ.ITEM_ROW_SPACING
            end
        end
    end

    content:SetHeight(math.abs(yOffset) + 10)
    return uncollectedCount
end

-- ============================================================================
-- REFRESH LIST (dispatches by view mode)
-- ============================================================================

function HDG_AcquisitionTab:RefreshList(keepScroll)
    if not acquisitionPanel then return end

    virtualScrollActive = false -- disable until RefreshItemList re-enables if needed
    HDG_AcqRows.ReleaseAllRows()
    HDG_AcqData.RebuildFilteredItems(CONFIG)

    -- Pre-cache dyeable status for all filtered items (batch API calls)
    if HDG_DyeData and HDG_DyeData.PreCacheDyeableItems then
        HDG_DyeData.PreCacheDyeableItems(HDG_AcqData.filteredItems)
    end

    local uncollectedCount
    if viewMode == "vendor" then
        uncollectedCount = self:RefreshVendorList()
    else
        uncollectedCount = self:RefreshItemList()
    end

    -- Reset scroll position after content is rebuilt (skip when expanding/collapsing vendors)
    if not keepScroll and acquisitionPanel.listScrollFrame then
        acquisitionPanel.listScrollFrame:SetVerticalScroll(0)
    end

    -- Update status bar
    if acquisitionPanel.statusBar then
        if viewMode == "vendor" then
            acquisitionPanel.statusBar:SetText(string.format(
                L["ACQ_STATUS_VENDORS"], #HDG_AcqData.filteredVendors, #HDG_AcqData.filteredItems
            ))
        else
            local statusText = string.format(
                L["ACQ_STATUS_ITEMS"], #HDG_AcqData.filteredItems, uncollectedCount or 0
            )
            -- Append achievement collection stat when Achievement source filter active
            if HDG_AcqData.filters.source == "Achievement" and HDG_AcqData.achievementItemCount then
                statusText = statusText .. string.format(
                    "  |  %d/%d achievement decor collected",
                    HDG_AcqData.achievementCollectedCount or 0, HDG_AcqData.achievementItemCount
                )
            end
            acquisitionPanel.statusBar:SetText(statusText)
        end
    end

    -- Update header title with counts
    if acquisitionPanel.headerTitle then
        acquisitionPanel.headerTitle:SetText(string.format(
            "%sAcquisition Guide|r  %s(%d items)|r",
            HDG.Theme.C_Text(), HDG.Theme.C_Dim(), #HDG_AcqData.filteredItems
        ))
    end

    -- Update vendor shopping list badge on main title bar button
    HDG_UI.UpdateShopListBadge()
end

-- ============================================================================
-- SELECT ITEM (updates all right panel sections)
-- ============================================================================

function HDG_AcquisitionTab:SelectItem(itemID, npcID)
    if not acquisitionPanel or not itemID then return end
    selectedItemID = itemID
    selectedNpcID = npcID

    if acquisitionPanel.placeholder then acquisitionPanel.placeholder:Hide() end

    acquisitionPanel.headerPanel:Show()
    acquisitionPanel.detailPanel:Show()

    local vendors = HDG_VendorResolver.GetVendorsForItem(itemID)
    local hasVendors = vendors and #vendors > 0
    local isCrafted = HDG_AcqData.craftableByItem[itemID] ~= nil

    if isCrafted and not hasVendors then
        selectedVendor = nil
        galleryVendorNPC = nil
        acquisitionPanel.waypointBtn:Hide()
        acquisitionPanel.mapBtn:Hide()
        HDG_AcqRows.ReleaseAllCells()
        acquisitionPanel.detailPanel:ClearAllPoints()
        acquisitionPanel.detailPanel:SetPoint("TOPLEFT", acquisitionPanel.headerPanel, "BOTTOMLEFT", 0, -2)
        acquisitionPanel.detailPanel:SetPoint("RIGHT", acquisitionPanel.rightPanel, "RIGHT", -4, 0)
        acquisitionPanel.detailPanel:SetPoint("BOTTOM", acquisitionPanel.rightPanel, "BOTTOM", 0, CONFIG.ACQUISITION_TAB.GALLERY_HEIGHT + 24)
        acquisitionPanel.galleryPanel:Hide()
        acquisitionPanel.infoPanel:Show()
        self:UpdateHeaderPanel(itemID)
        self:UpdateDetailPanel_Crafted(itemID)
        self:UpdateInfoPanel_Crafted(itemID)
    elseif hasVendors then
        local newVendor = vendors[1]
        local preferNPC = npcID or (selectedVendor and selectedVendor.npcID)
        if preferNPC then
            for _, v in ipairs(vendors) do
                if v.npcID == preferNPC then
                    newVendor = v
                    break
                end
            end
        end
        local vendorChanged = galleryVendorNPC ~= newVendor.npcID
            or not acquisitionPanel.galleryPanel:IsShown()
        selectedVendor = newVendor
        if vendorChanged then
            galleryVendorNPC = newVendor.npcID
            self:UpdateVendorInventory(selectedVendor)
        else
            HDG_AcqRows.UpdateGalleryHighlights(selectedItemID)
        end
        acquisitionPanel.detailPanel:ClearAllPoints()
        acquisitionPanel.detailPanel:SetPoint("TOPLEFT", acquisitionPanel.headerPanel, "BOTTOMLEFT", 0, -2)
        acquisitionPanel.detailPanel:SetPoint("RIGHT", acquisitionPanel.rightPanel, "RIGHT", -4, 0)
        acquisitionPanel.detailPanel:SetPoint("BOTTOM", acquisitionPanel.rightPanel, "BOTTOM", 0, CONFIG.ACQUISITION_TAB.GALLERY_HEIGHT + 24)
        acquisitionPanel.galleryPanel:Show()
        acquisitionPanel.infoPanel:Hide()
        self:UpdateHeaderPanel(itemID)
        self:UpdateDetailPanel_Vendor(itemID, vendors)
    else
        selectedVendor = nil
        galleryVendorNPC = nil
        acquisitionPanel.waypointBtn:Hide()
        acquisitionPanel.mapBtn:Hide()
        HDG_AcqRows.ReleaseAllCells()
        acquisitionPanel.detailPanel:ClearAllPoints()
        acquisitionPanel.detailPanel:SetPoint("TOPLEFT", acquisitionPanel.headerPanel, "BOTTOMLEFT", 0, -2)
        acquisitionPanel.detailPanel:SetPoint("RIGHT", acquisitionPanel.rightPanel, "RIGHT", -4, 0)
        acquisitionPanel.detailPanel:SetPoint("BOTTOM", acquisitionPanel.rightPanel, "BOTTOM", 0, CONFIG.ACQUISITION_TAB.GALLERY_HEIGHT + 24)
        acquisitionPanel.galleryPanel:Hide()
        acquisitionPanel.infoPanel:Show()
        self:UpdateHeaderPanel(itemID)
        local entry = HDG_AcqData.GetSourceInfo(itemID)
        local si = entry and { type = entry[2], name = entry[3], detail = entry[4] } or nil
        self:UpdateDetailPanel_NonVendor(itemID, si)
        self:UpdateInfoPanel(itemID, si)
    end

    self:UpdateNoteDisplay(itemID)
    if selectedVendor then
        self:UpdateVendorNoteDisplay(selectedVendor.npcID)
        if acquisitionPanel.headerPanel.vendorNoteBtn then acquisitionPanel.headerPanel.vendorNoteBtn:Show() end
        if acquisitionPanel.headerPanel.vendorNoteText then acquisitionPanel.headerPanel.vendorNoteText:Show() end
    else
        if acquisitionPanel.headerPanel.vendorNoteBtn then acquisitionPanel.headerPanel.vendorNoteBtn:Hide() end
        if acquisitionPanel.headerPanel.vendorNoteText then acquisitionPanel.headerPanel.vendorNoteText:Hide() end
    end

    -- Cart buttons — set item for add/remove from shopping list
    local dp = acquisitionPanel.detailPanel
    if dp.cartBtns then
        local npcID = selectedVendor and selectedVendor.npcID or nil
        dp.cartBtns:SetItem(itemID, npcID)
        dp.cartBtns:Show()

        -- "N in cart" label
        local function UpdateCartLabel()
            local qty = 0
            if HDG.Store then
                for _, e in ipairs(HDG.Store:GetActiveShoppingList().items) do
                    local m = npcID and (e.itemID == itemID and e.npcID == npcID)
                                     or (e.itemID == itemID and not e.npcID)
                    if m then qty = e.qty or 1; break end
                end
            end
            if qty > 0 then
                dp.cartLabel:SetText(HDG.Theme.C_Text() .. qty .. " in cart|r")
                dp.cartLabel:Show()
            else
                dp.cartLabel:Hide()
            end
        end
        dp.cartBtns.onPostClick = UpdateCartLabel
        UpdateCartLabel()
    end

    self:UpdatePreviewModel(itemID)
    HDG_AcqRows.UpdateRowHighlights(selectedItemID, selectedNpcID)
end

-- ============================================================================
-- UPDATE HEADER PANEL
-- ============================================================================

function HDG_AcquisitionTab:UpdateHeaderPanel(itemID)
    if not acquisitionPanel or not itemID then return end
    local hp = acquisitionPanel.headerPanel

    if selectedVendor then
        hp.itemName:SetText(selectedVendor.name or "Unknown Vendor")
        local coordStr = ""
        if HasValidCoords(selectedVendor) then
            coordStr = string.format(" (%.1f, %.1f)", selectedVendor.x, selectedVendor.y)
        end
        hp.subtitle:SetText((selectedVendor.zone or "") .. coordStr)
        hp.subtitle:Show()
        hp.collectedBadge:SetText("")
    else
        hp.itemName:SetText(HDG_Data.GetCachedItemName(itemID) or ("Item #" .. itemID))
        hp.subtitle:SetText("")
        hp.subtitle:Hide()
        local isCollected = HDG_Data.IsItemCollected(itemID)
        if isCollected then
            hp.collectedBadge:SetText(HDG.Theme.C_Success() .. "Collected|r")
        else
            hp.collectedBadge:SetText(HDG.Theme.C_Warning() .. "Not Collected|r")
        end
    end
end

-- ============================================================================
-- UPDATE PREVIEW MODEL
-- ============================================================================

function HDG_AcquisitionTab:UpdatePreviewModel(itemID)
    if not acquisitionPanel or not itemID then return end
    local dp = acquisitionPanel.detailPanel
    local pf = dp.previewFrame
    if not pf then return end
    dp._currentItemID = itemID

    local _, decorInfo = pf:LoadItem(itemID)
    local ov = pf.overlays
    if not ov then return end

    -- Overlay: item name + collected badge
    local itemName = HDG_Data.GetCachedItemName(itemID) or ("Item #" .. itemID)
    local isCollected = HDG_Data.IsItemCollected(itemID)
    ov.name:SetText(itemName)
    ov.collected:SetShown(isCollected)
    ov.cost:SetText("")
    ov.owned:SetText("")

    if decorInfo then
        -- Placement cost (top-right)
        if decorInfo.placementCost and decorInfo.placementCost > 0 then
            local costStr = HOUSING_DECOR_PLACEMENT_COST_FORMAT and HOUSING_DECOR_PLACEMENT_COST_FORMAT:format(decorInfo.placementCost) or tostring(decorInfo.placementCost)
            ov.cost:SetText(costStr)
        end
        -- Placed / Storage
        if decorInfo.showQuantity then
            local placed = decorInfo.numPlaced or 0
            local storage = decorInfo.quantity or 0
            ov.owned:SetText("Placed: " .. placed .. "  Storage: " .. storage)
        end
    end
end

-- ============================================================================
-- CATALOG INFO HELPER — shared across all detail panel modes
-- ============================================================================

local SIZE_LABELS = { [0] = "Small", [1] = "Medium", [2] = "Large", [3] = "Extra Large" }

local function ApplyCatalogInfo(dp, itemID, startLine, currentVendorName)
    for i = startLine, 9 do
        if dp["line" .. i] then dp["line" .. i]:SetText("") end
    end
    if not C_HousingCatalog or not C_HousingCatalog.GetCatalogEntryInfoByItem then return end
    local info = C_HousingCatalog.GetCatalogEntryInfoByItem(itemID, true)
    if not info then return end

    local line = startLine
    local a = HDG.Theme.C_Accent()
    local d = HDG.Theme.C_Dim()

    -- Size
    local sizeLabel = SIZE_LABELS[info.size]
    if sizeLabel and line <= 9 then
        dp["line" .. line]:SetText(a .. "Size:|r " .. sizeLabel)
        line = line + 1
    end

    -- Placement rules
    local placement = {}
    if info.isAllowedIndoors then table.insert(placement, "Indoor") end
    if info.isAllowedOutdoors then table.insert(placement, "Outdoor") end
    if info.isPrefab then table.insert(placement, "Prefab") end
    if info.isUniqueTrophy then table.insert(placement, "Unique Trophy") end
    if #placement > 0 and line <= 9 then
        dp["line" .. line]:SetText(a .. "Placement:|r " .. table.concat(placement, ", "))
        line = line + 1
    end

    -- Dyeable
    if info.canCustomize and info.dyeIDs and #info.dyeIDs > 0 and line <= 9 then
        dp["line" .. line]:SetText(a .. "Dyeable:|r " .. HDG.Theme.C_Success() .. "Yes|r " .. d .. "(" .. #info.dyeIDs .. " slots)|r")
        line = line + 1
    end

    -- Collection status (placed / storage)
    local placed = info.numPlaced or 0
    local stored = info.quantity or 0
    local redeemable = info.remainingRedeemable or 0
    local total = placed + stored + redeemable
    if total > 0 and line <= 9 then
        local parts = {}
        if placed > 0 then table.insert(parts, HDG.Theme.C_Success() .. placed .. " placed|r") end
        if stored > 0 then table.insert(parts, HDG.Theme.C_Text() .. stored .. " in storage|r") end
        if redeemable > 0 then table.insert(parts, d .. redeemable .. " redeemable|r") end
        dp["line" .. line]:SetText(a .. "Owned:|r " .. table.concat(parts, d .. ", |r"))
        line = line + 1
    end

    -- Source text
    if info.sourceText and info.sourceText ~= "" and line <= 9 then
        local text = info.sourceText
        if currentVendorName then
            local normalized = text:gsub("|n", "\n")
            local filtered, skip = {}, false
            for seg in normalized:gmatch("[^\n]+") do
                local raw = seg:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
                if raw:match("^%s*Vendor:") then
                    skip = raw:match("^%s*Vendor:%s*(.+)") == currentVendorName
                end
                if not skip and not raw:match("^%s*Cost:") and not raw:match("^%s*Faction:") then
                    table.insert(filtered, seg)
                end
            end
            text = table.concat(filtered, "\n")
        end
        if text ~= "" then
            dp["line" .. line]:SetText(d .. text .. "|r")
            line = line + 1
        end
    end
end

-- ============================================================================
-- UPDATE DETAIL PANEL — Vendor mode
-- ============================================================================

function HDG_AcquisitionTab:UpdateDetailPanel_Vendor(itemID, vendors)
    if not acquisitionPanel or not itemID then return end
    local dp = acquisitionPanel.detailPanel
    local vendor = selectedVendor
    if not vendor then return end

    -- (Name + collected badge are now on the preview overlay)
    local line = 1
    local collected = HDG_Data.IsItemCollected(itemID) -- account-wide; overrides per-char gate checks
    local raceStr = HDG_VendorResolver.FormatRaceRequirement(vendor)
    if raceStr then
        local met = collected or HDG_VendorResolver.MeetsRaceRequirement(vendor)
        local raceColor = met and HDG.Theme.C_Success() or HDG.Theme.C_Warning()
        dp["line" .. line]:SetText(HDG.Theme.C_Accent() .. "Race:|r " .. raceColor .. raceStr .. "|r")
        line = line + 1
    end

    local repStr = HDG_VendorResolver.FormatRepRequirement(vendor)
    if repStr then
        local met = collected or HDG_VendorResolver.MeetsRepRequirement(vendor)
        local repColor = met and HDG.Theme.C_Success() or HDG.Theme.C_Warning()
        dp["line" .. line]:SetText(HDG.Theme.C_Accent() .. "Reputation:|r " .. repColor .. repStr .. "|r")
        line = line + 1
    end

    local questStr = HDG_VendorResolver.FormatQuestRequirement(vendor)
    if questStr then
        local met = collected or HDG_VendorResolver.MeetsQuestRequirement(vendor)
        local qColor = met and HDG.Theme.C_Success() or HDG.Theme.C_Warning()
        dp["line" .. line]:SetText(HDG.Theme.C_Accent() .. "Quest:|r " .. qColor .. questStr .. "|r")
        line = line + 1
    end

    local achieveStr = HDG_VendorResolver.FormatAchievementRequirement(vendor)
    if achieveStr then
        local met = collected or HDG_VendorResolver.MeetsAchievementRequirement(vendor)
        local aColor = met and HDG.Theme.C_Success() or HDG.Theme.C_Warning()
        dp["line" .. line]:SetText(HDG.Theme.C_Accent() .. "Achievement:|r " .. aColor .. achieveStr .. "|r")
        line = line + 1
    end

    local goldCost = vendor.goldCost or 0
    local priceStr = HDG_VendorResolver.FormatGoldCost(goldCost)
    local currencies = HDG_VendorResolver.GetCurrencyCost(itemID)
    if priceStr and currencies then
        local iconSize = CONFIG.ACQUISITION_TAB.CURRENCY_ICON_SIZE
        local parts = { priceStr }
        for _, c in ipairs(currencies) do
            local iconStr = c.icon and c.icon ~= 0 and string.format("|T%s:%d:%d|t", c.icon, iconSize, iconSize) or ""
            table.insert(parts, string.format("%s %d %s", iconStr, c.amount, c.name or ""))
        end
        dp["line" .. line]:SetText(HDG.Theme.C_Accent() .. "Cost:|r " .. table.concat(parts, "  "))
        line = line + 1
    elseif priceStr then
        dp["line" .. line]:SetText(HDG.Theme.C_Accent() .. "Cost:|r " .. priceStr)
        line = line + 1
    elseif currencies then
        local iconSize = CONFIG.ACQUISITION_TAB.CURRENCY_ICON_SIZE
        local parts = {}
        for _, c in ipairs(currencies) do
            local iconStr = c.icon and c.icon ~= 0 and string.format("|T%s:%d:%d|t", c.icon, iconSize, iconSize) or ""
            table.insert(parts, string.format("%s %d %s", iconStr, c.amount, c.name or ""))
        end
        dp["line" .. line]:SetText(HDG.Theme.C_Accent() .. "Cost:|r " .. table.concat(parts, "  "))
        line = line + 1
    else
        dp["line" .. line]:SetText(HDG.Theme.C_Dim() .. "Visit vendor to see cost|r")
        line = line + 1
    end

    -- Dyeable status
    if HDG_DyeData and HDG_DyeData.IsDecorDyeable(itemID) then
        dp["line" .. line]:SetText(HDG.Theme.C_Accent() .. "Dyeable:|r " .. HDG.Theme.C_Success() .. "Yes|r")
        line = line + 1
    end

    ApplyCatalogInfo(dp, itemID, line, vendor.name) -- filter out selected vendor + cost from sourceText

    local hasValidCoords = HasValidCoords(vendor)
    if hasValidCoords then
        acquisitionPanel.waypointBtn:Show()
        if ZONE_MAP_IDS[vendor.zone] then
            acquisitionPanel.mapBtn:Show()
        else
            acquisitionPanel.mapBtn:Hide()
        end
    else
        acquisitionPanel.waypointBtn:Hide()
        acquisitionPanel.mapBtn:Hide()
    end

    dp.otherVendorsLabel:SetText("")
    dp.otherVendorsLabel:Hide()

    -- Vendor note
    self:UpdateVendorNoteDisplay(vendor.npcID)
end

-- ============================================================================
-- UPDATE DETAIL PANEL — Crafted mode
-- ============================================================================

function HDG_AcquisitionTab:UpdateDetailPanel_Crafted(itemID)
    if not acquisitionPanel then return end
    local dp = acquisitionPanel.detailPanel

    acquisitionPanel.waypointBtn:Hide()
    acquisitionPanel.mapBtn:Hide()
    if dp.vendorBtnPool then
        for _, btn in ipairs(dp.vendorBtnPool) do btn:Hide() end
    end
    if dp.moreLabel then dp.moreLabel:Hide() end
    dp.otherVendorsLabel:SetText("")
    dp.otherVendorsLabel:Hide()
    if dp.itemNoteBtn then dp.itemNoteBtn:Hide() end
    if dp.itemNoteText then dp.itemNoteText:Hide() end

    local recipe = HDG_AcqData.recipeByItem[itemID]
    if not recipe then
        dp.line1:SetText(HDG.Theme.C_Dim() .. "Crafted item (recipe not found)|r")
        for i = 2, 9 do dp["line" .. i]:SetText("") end
        return
    end

    local profIcon = string.format("|T%s:14:14|t", CONFIG.TEXTURES.CRAFTED_ICON)
    dp.line1:SetText(profIcon .. " " .. HDG.Theme.C_Accent() .. "Profession:|r " .. (recipe.profession or "Unknown"))
    dp.line2:SetText(HDG.Theme.C_Accent() .. "Expansion:|r " .. (recipe.expansion or "Unknown"))

    -- Reagent list
    local reagentLines = {}
    if recipe.reagents then
        for reagentID, rData in pairs(recipe.reagents) do
            local rName = rData.name or ("Item #" .. reagentID)
            table.insert(reagentLines, string.format("  %dx %s", rData.qty or 1, rName))
        end
        table.sort(reagentLines)
    end
    if #reagentLines > 0 then
        dp.line3:SetText(HDG.Theme.C_Accent() .. "Reagents:|r")
        dp.line4:SetText(table.concat(reagentLines, "\n"))
    else
        dp.line3:SetText(HDG.Theme.C_Dim() .. "No reagents listed|r")
        dp.line4:SetText("")
    end

    ApplyCatalogInfo(dp, itemID, 5)
end

-- ============================================================================
-- UPDATE DETAIL PANEL — Non-vendor mode
-- ============================================================================

function HDG_AcquisitionTab:UpdateDetailPanel_NonVendor(itemID, si)
    if not acquisitionPanel then return end
    local dp = acquisitionPanel.detailPanel

    acquisitionPanel.waypointBtn:Hide()
    acquisitionPanel.mapBtn:Hide()
    if dp.vendorBtnPool then
        for _, btn in ipairs(dp.vendorBtnPool) do btn:Hide() end
    end
    if dp.moreLabel then dp.moreLabel:Hide() end
    dp.otherVendorsLabel:SetText("")
    dp.otherVendorsLabel:Hide()
    if dp.itemNoteBtn then dp.itemNoteBtn:Hide() end
    if dp.itemNoteText then dp.itemNoteText:Hide() end

    if not si then
        dp.line1:SetText(HDG.Theme.C_Dim() .. "Unknown source|r")
        for i = 2, 9 do dp["line" .. i]:SetText("") end
        return
    end

    local typeLabel = HDG_AcqData.GetSourceTypeLabel(si.type, CONFIG)
    local sourceName = si.name or ""

    if si.type == CONFIG.SOURCE_TYPE.PROMOTION then
        local detail = (si.detail and si.detail ~= "" and si.detail ~= "Unknown") and si.detail or ""
        dp.line1:SetText(HDG.Theme.C_Accent() .. typeLabel .. ":|r " .. detail)
    else
        dp.line1:SetText(HDG.Theme.C_Accent() .. typeLabel .. ":|r " .. sourceName)
    end

    if si.type ~= CONFIG.SOURCE_TYPE.PROMOTION and si.detail and si.detail ~= "" then
        dp.line2:SetText(HDG.Theme.C_Dim() .. si.detail .. "|r")
    else
        dp.line2:SetText("")
    end

    local mapID = ZONE_MAP_IDS[si.detail]
    if mapID then
        dp.line3:SetText(HDG.Theme.C_Dim() .. "Expansion: " .. GetExpansionForMapID(mapID) .. "|r")
    else
        dp.line3:SetText("")
    end

    ApplyCatalogInfo(dp, itemID, 4)
end

-- ============================================================================
-- UPDATE INFO PANEL — Non-vendor bottom section
-- ============================================================================

function HDG_AcquisitionTab:UpdateInfoPanel(itemID, si)
    if not acquisitionPanel then return end
    local ip = acquisitionPanel.infoPanel

    -- Hide crafted action buttons when showing non-crafted items
    if ip.craftQueueBtn then ip.craftQueueBtn:Hide() end
    if ip.viewRecipesBtn then ip.viewRecipesBtn:Hide() end

    if not si then
        ip.sourceLine:SetText(HDG.Theme.C_Dim() .. "Source Unknown|r")
        ip.expansionLine:SetText("")
        ip.extraLine:SetText("")
        return
    end

    local ST = CONFIG.SOURCE_TYPE
    local typeLabel = HDG_AcqData.GetSourceTypeLabel(si.type, CONFIG)

    if si.type == ST.PROMOTION then
        local detail = (si.detail and si.detail ~= "" and si.detail ~= "Unknown") and si.detail or ""
        ip.sourceLine:SetText(HDG.Theme.C_Accent() .. typeLabel .. ":|r " .. detail)
    elseif si.type == ST.ACHIEVEMENT and si.detail and si.detail ~= "" then
        ip.sourceLine:SetText(HDG.Theme.C_Accent() .. typeLabel .. ":|r " .. (si.name or "") .. " (" .. si.detail .. ")")
    else
        ip.sourceLine:SetText(HDG.Theme.C_Accent() .. typeLabel .. ":|r " .. (si.name or ""))
    end

    local mapID = ZONE_MAP_IDS[si.detail]
    if mapID then
        ip.expansionLine:SetText(HDG.Theme.C_Dim() .. "Expansion: " .. GetExpansionForMapID(mapID) .. "|r")
    else
        ip.expansionLine:SetText("")
    end

    if si.type == ST.QUEST or si.type == ST.WORLD_QUEST or si.type == ST.DROP then
        if si.detail and si.detail ~= "" then
            ip.extraLine:SetText(HDG.Theme.C_Dim() .. "Zone: " .. si.detail .. "|r")
        else
            ip.extraLine:SetText("")
        end
    else
        ip.extraLine:SetText("")
    end
end

-- ============================================================================
-- UPDATE INFO PANEL — Crafted bottom section
-- ============================================================================

function HDG_AcquisitionTab:UpdateInfoPanel_Crafted(itemID)
    if not acquisitionPanel then return end
    local ip = acquisitionPanel.infoPanel

    local recipe = HDG_AcqData.recipeByItem[itemID]
    local profName = recipe and recipe.profession or "Unknown"

    ip.sourceLine:SetText(HDG.Theme.C_Accent() .. "Crafted|r — " .. profName)
    ip.expansionLine:SetText("")
    ip.extraLine:SetText("")

    -- Create or reuse action buttons
    if not ip.craftQueueBtn then
        ip.craftQueueBtn = HDG.UI:Button(ip, L["ACQ_ADD_TO_QUEUE"], 160, 22, function()
            local selRecipe = HDG_AcqData.recipeByItem[selectedItemID]
            if selRecipe then
                local queueItem = {
                    id = selRecipe.itemID,
                    name = selRecipe.name,
                    spell = selRecipe.spellID,
                    reagents = selRecipe.reagents,
                    expansion = HDG_ExpansionData.NormalizeToDisplayName(selRecipe.expansion),
                    profession = selRecipe.profession,
                }
                HDG_UI.AddToCraftingQueue(queueItem)
                print("|cFF00FF00[HDG]|r Added " .. (selRecipe.name or "item") .. " to crafting queue.")
            end
        end)
        ip.craftQueueBtn:SetPoint("TOPLEFT", ip.sourceLine, "BOTTOMLEFT", 0, -10)
    end

    ip.craftQueueBtn:Show()
    if ip.viewRecipesBtn then ip.viewRecipesBtn:Hide() end
end

-- ============================================================================
-- UPDATE VENDOR INVENTORY (3D Gallery)
-- ============================================================================

function HDG_AcquisitionTab:UpdateVendorInventory(vendor)
    if not acquisitionPanel or not vendor then return end

    HDG_AcqRows.ReleaseAllCells()

    local npcID = vendor.npcID
    local rawItems = HDG_AcqData.vendorItems[npcID]
    if not rawItems or #rawItems == 0 then
        acquisitionPanel.galleryHeader:SetText("No items in inventory")
        acquisitionPanel.costSummary:SetText("")
        return
    end

    -- Filter out unreleased items when "Hide Unreleased" is checked
    local items = rawItems
    if HDG_AcqData.filters.releasedOnly then
        items = {}
        for _, id in ipairs(rawItems) do
            local itemName = HDG_Data.GetCachedItemName(id)
            if itemName == "Unreleased" or itemName == "Loading..." then
                -- skip: unreleased or still loading (will refresh when resolved)
            elseif HDG_AllDecorDB and not HDG_AllDecorDB[id] then
                -- skip: not in AllDecorDB
            elseif HDG_AllDecorDB and HDG_AllDecorDB[id] and HDG_AllDecorDB[id][5] and HDG_AllDecorDB[id][5] > time() then
                -- skip: future release date
            else
                table.insert(items, id)
            end
        end
    end

    -- Pre-compute collected status in one pass (reused for count + sort comparator)
    local collectedMap = {}
    local collectedCount = 0
    local uncollectedCost = 0
    local vEntry = HDG_VendorDB and HDG_VendorDB[npcID]
    local vItems = vEntry and vEntry.items
    for _, id in ipairs(items) do
        local c = HDG_Data.IsItemCollected(id)
        collectedMap[id] = c
        if c then
            collectedCount = collectedCount + 1
        else
            local ie = vItems and vItems[id]
            uncollectedCost = uncollectedCost + (ie and ie[1] or 0)
        end
    end

    acquisitionPanel.galleryHeader:SetText(string.format(
        "Also sells: (%d items, %d collected)", #items, collectedCount
    ))

    local uncollectedItemCount = #items - collectedCount
    if uncollectedItemCount > 0 and uncollectedCost > 0 then
        local goldStr = HDG_VendorResolver.FormatGoldCost(uncollectedCost)
        acquisitionPanel.costSummary:SetText(string.format(
            "%d uncollected — %s total", uncollectedItemCount, goldStr or "?"
        ))
    else
        acquisitionPanel.costSummary:SetText("")
    end

    -- Build grid
    local content = acquisitionPanel.galleryContent
    local ACQ = CONFIG.ACQUISITION_TAB
    local cellSize = ACQ.CELL_SIZE
    local spacing = ACQ.CELL_SPACING
    local contentWidth = content:GetWidth()
    if contentWidth == 0 then contentWidth = 400 end

    local cellsPerRow = math.max(1, math.floor(contentWidth / (cellSize + spacing)))
    local cellHeight = cellSize + spacing

    local xPos = 0
    local yPos = 0
    local col = 0

    local sortedItems = {}
    for _, id in ipairs(items) do table.insert(sortedItems, id) end
    table.sort(sortedItems, function(a, b)
        local ac, bc = collectedMap[a], collectedMap[b]
        if ac ~= bc then return not ac end
        return (HDG_Data.GetCachedItemName(a) or "") < (HDG_Data.GetCachedItemName(b) or "")
    end)

    for _, id in ipairs(sortedItems) do
        xPos = col * (cellSize + spacing)
        HDG_AcqRows.CreateGalleryCell(content, id, xPos, yPos, cellSize, selectedItemID, onSelectItem, CONFIG, npcID)

        col = col + 1
        if col >= cellsPerRow then
            col = 0
            yPos = yPos - cellHeight
        end
    end

    local totalRows = math.ceil(#sortedItems / cellsPerRow)
    content:SetHeight(totalRows * cellHeight + 10)
end

-- ============================================================================
-- LIFECYCLE
-- ============================================================================

function HDG_AcquisitionTab:Init(configRef, mainFrameRef, _dbRef)
    CONFIG = configRef
    mainFrame = mainFrameRef
end

function HDG_AcquisitionTab:Show()
    if not acquisitionPanel then
        HDG_AcqData.BuildCaches(CONFIG)
        self:CreatePanel()
    end
    acquisitionPanel:Show()
    RefreshGuideText()
    C_Timer.After(0, function()
        if acquisitionPanel and acquisitionPanel:IsShown() then
            HDG_AcquisitionTab:RefreshList()
        end
    end)
end

function HDG_AcquisitionTab:Hide()
    if acquisitionPanel then
        acquisitionPanel:Hide()
    end
end

function HDG_AcquisitionTab:IsShown()
    return acquisitionPanel and acquisitionPanel:IsShown()
end

function HDG_AcquisitionTab:Refresh()
    if not CONFIG then return end
    if not acquisitionPanel then
        HDG_AcqData.BuildCaches(CONFIG)
        self:CreatePanel()
    end
    self:RefreshList()
    if selectedItemID then
        self:SelectItem(selectedItemID, selectedNpcID)
    end
end

function HDG_AcquisitionTab:ShowByItem(itemID)
    if not acquisitionPanel or not itemID then return end
    -- Switch to By Item view programmatically
    if acquisitionPanel.byItemBtn then
        acquisitionPanel.byItemBtn:GetScript("OnClick")(acquisitionPanel.byItemBtn)
    end
    C_Timer.After(0, function()
        self:SelectItem(itemID)
    end)
end

-- ============================================================================
-- STATIC POPUP — User Note Editor
-- ============================================================================

StaticPopupDialogs["HDG_EDIT_USER_NOTE"] = {
    text = "Note for %s:",
    button1 = "Save",
    button2 = "Cancel",
    hasEditBox = true,
    editBoxWidth = 280,
    OnShow = function(self)
        local note = HDG.Store:GetUserNote(self.data)
        self.EditBox:SetText(note and note.text or "")
        self.EditBox:SetMaxLetters(CONFIG and CONFIG.ACQUISITION_TAB.MAX_NOTE_LENGTH or 200)
        self.EditBox:HighlightText()
    end,
    OnAccept = function(self)
        local text = strtrim(self.EditBox:GetText())
        if text == "" then
            HDG.Store:RemoveUserNote(self.data)
        else
            HDG.Store:SetUserNote(self.data, text)
        end
        HDG_AcquisitionTab:UpdateNoteDisplay(self.data)
        HDG_AcquisitionTab:RefreshList()
    end,
    EditBoxOnEnterPressed = function(self)
        local parent = self:GetParent()
        local text = strtrim(self:GetText())
        if text == "" then
            HDG.Store:RemoveUserNote(parent.data)
        else
            HDG.Store:SetUserNote(parent.data, text)
        end
        HDG_AcquisitionTab:UpdateNoteDisplay(parent.data)
        HDG_AcquisitionTab:RefreshList()
        parent:Hide()
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

-- ============================================================================
-- STATIC POPUP — Vendor Note Editor
-- ============================================================================

StaticPopupDialogs["HDG_EDIT_VENDOR_NOTE"] = {
    text = "Vendor note for %s:",
    button1 = "Save",
    button2 = "Cancel",
    hasEditBox = true,
    editBoxWidth = 280,
    OnShow = function(self)
        local note = HDG.Store:GetVendorNote(self.data)
        self.EditBox:SetText(note and note.text or "")
        self.EditBox:SetMaxLetters(CONFIG and CONFIG.ACQUISITION_TAB.MAX_NOTE_LENGTH or 200)
        self.EditBox:HighlightText()
    end,
    OnAccept = function(self)
        local text = strtrim(self.EditBox:GetText())
        if text == "" then
            HDG.Store:RemoveVendorNote(self.data)
        else
            HDG.Store:SetVendorNote(self.data, text)
        end
        HDG_AcquisitionTab:UpdateVendorNoteDisplay(self.data)
        HDG_AcquisitionTab:RefreshList()
    end,
    EditBoxOnEnterPressed = function(self)
        local parent = self:GetParent()
        local text = strtrim(self:GetText())
        if text == "" then
            HDG.Store:RemoveVendorNote(parent.data)
        else
            HDG.Store:SetVendorNote(parent.data, text)
        end
        HDG_AcquisitionTab:UpdateVendorNoteDisplay(parent.data)
        HDG_AcquisitionTab:RefreshList()
        parent:Hide()
    end,
    timeout = 0,
    whileDead = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

-- ============================================================================
-- NOTE DISPLAY HELPERS
-- ============================================================================

function HDG_AcquisitionTab:UpdateNoteDisplay(itemID)
    if not acquisitionPanel or not acquisitionPanel.detailPanel then return end
    local dp = acquisitionPanel.detailPanel
    if not dp.itemNoteText then return end

    local note = HDG.Store:GetUserNote(itemID)
    if note and note.text ~= "" then
        dp.itemNoteText:SetText(HDG.Theme.C_Accent() .. "Note:|r " .. HDG.Theme.C_Text() .. note.text .. "|r")
    elseif selectedVendor and selectedVendor.note then
        dp.itemNoteText:SetText(HDG.Theme.C_Dim() .. selectedVendor.note .. "|r")
    else
        dp.itemNoteText:SetText(HDG.Theme.C_Dim() .. "Add item note...|r")
    end
    dp.itemNoteBtn:Show()
    dp.itemNoteText:Show()
end

function HDG_AcquisitionTab:UpdateVendorNoteDisplay(npcID)
    if not acquisitionPanel or not acquisitionPanel.headerPanel then return end
    local hp = acquisitionPanel.headerPanel
    if not hp.vendorNoteText then return end

    local note = npcID and HDG.Store:GetVendorNote(npcID)
    if note and note.text ~= "" then
        hp.vendorNoteText:SetText(HDG.Theme.C_Text() .. note.text .. "|r")
    else
        hp.vendorNoteText:SetText(HDG.Theme.C_Dim() .. "Add vendor note...|r")
    end
end

-- ============================================================================
-- EXPORT NOTES WINDOW
-- ============================================================================

ShowNotesExportWindow = function()
    local exportText = HDG.Store:ExportUserNotes()
    if not HDG_NotesExportCtrl then
        HDG_NotesExportCtrl = HDG_UIHelpers.CreateExportDialog()
    end
    HDG_NotesExportCtrl:Show(exportText, HDG.Theme.C_Accent() .. L["ACQ_EXPORT_TITLE"] .. "|r")
end

-- Expose to privateDB for other modules
addon.HDG_AcquisitionTab = HDG_AcquisitionTab
