-- 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

-- Wowhead link helpers
local WH_BASE = "https://www.wowhead.com"
local WH_UTM  = "?utm_source=hdg"
local function WowheadNpcUrl(npcID)  return WH_BASE .. "/npc=" .. npcID .. WH_UTM end
local function WowheadItemUrl(itemID) return WH_BASE .. "/item=" .. itemID .. WH_UTM end

local whUrlPopup
local function ShowWowheadUrl(anchor, url)
    if not whUrlPopup then
        local f = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
        f:SetSize(340, 28)
        f:SetFrameStrata("DIALOG")
        f:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
        f:SetBackdropColor(0.1, 0.1, 0.1, 0.95)
        f:SetBackdropBorderColor(0.3, 0.3, 0.3, 1)
        local eb = CreateFrame("EditBox", nil, f)
        eb:SetPoint("TOPLEFT", 4, -4)
        eb:SetPoint("BOTTOMRIGHT", -4, 4)
        eb:SetFontObject("GameFontNormalSmall")
        eb:SetAutoFocus(false)
        eb:SetScript("OnEscapePressed", function(s) s:ClearFocus(); f:Hide() end)
        eb:SetScript("OnEditFocusLost", function() f:Hide() end)
        f.eb = eb
        whUrlPopup = f
    end
    whUrlPopup:SetParent(anchor)
    whUrlPopup:ClearAllPoints()
    whUrlPopup:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -2)
    whUrlPopup.eb:SetText(url)
    whUrlPopup:Show()
    whUrlPopup.eb:HighlightText()
    whUrlPopup.eb:SetFocus()
end

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

-- Acquisition tab filter + sort state. Lives here, not in HDG_AcqData, because
-- it's UI state owned by the consumer; HDG_AcqData's RebuildFilteredItems /
-- RebuildFilteredVendors / ItemPassesFilters now take the filters table as a
-- parameter so the data layer is pure (no shared mutable globals). Session-only
-- (not persisted) -- matches pre-refactor behavior; if individual fields ever
-- want persistence, route them through HDG.Store config like Recipes does.
local acqFilters = {
    search = "",
    zone = "All",
    faction = "All",
    source = "All",
    expansion = "All",
    uncollectedOnly = false,
    dyeableOnly = false,
    releasedOnly = true,
}
local acqSortMode = "name"   -- "name" (default) | "zone"
local advancedFiltersOpen = false
local activePreset = nil

-- Hearthsteel prices for In-Game Shop items (manually maintained)
local HEARTHSTEEL_PRICES = {
    [250793] = 0,     -- Lush Garden Trellis (bundle only)
    [250794] = 10,    -- Colorful Shroomic Egg
    [250795] = 10,    -- Colorful Dotted Egg
    [250796] = 10,    -- Colorful Striped Egg
    [250797] = 100,   -- Spring Blossom Ceiling Light
    [250798] = 250,   -- Spring Blossom Shelf
    [252419] = 250,   -- Lush Garden Fungal Basin
    [253546] = 100,   -- Lush Garden Butterfly Sconce
    [253547] = 100,   -- Spring Blossom Wreath
    [254417] = 250,   -- Spring Blossom Hanging Chair
    [258294] = 50,    -- Lush Garden Gnome-Like Statue
    [258567] = 250,   -- Lush Garden Fungal Chair
    [258568] = 100,   -- Spring Blossom Window
    [258569] = 800,   -- Spring Blossom Gazebo
    [258888] = 500,   -- Lush Garden Fungal Fountain
    [260727] = 200,   -- Alliance Doormat
    [260728] = 200,   -- Horde Doormat
    [263052] = 100,   -- Beloved Lion Plushie
    [263053] = 100,   -- Beloved Wolf Plushie
    [263290] = 250,   -- Spring Blossom Tree (corrected from 750)
    [263291] = 0,     -- Spring Blossom Pond (price unknown)
}

-- Source options per view mode — derived from CONFIG.SOURCE_TYPES.
-- Each acquirable type carries a `views = { vendor = bool, item = bool }`
-- table that gates which dropdown it appears in. Single source of truth.
local function BuildSourceLists()
    local CONFIG = addon and addon.CONFIG
    local vendor, all = {}, {}
    if not (CONFIG and CONFIG.SOURCE_TYPES) then return vendor, all end
    -- Order: Vendor first (special), then Blizzard sourceType id ascending,
    -- then synthetic gates (id 100+) at the end.
    local ids = {}
    for id in pairs(CONFIG.SOURCE_TYPES) do
        if CONFIG.SOURCE_TYPES[id].acquirable then ids[#ids + 1] = id end
    end
    table.sort(ids, function(a, b)
        if a == 5 then return true end -- Vendor first
        if b == 5 then return false end
        return a < b
    end)
    for _, id in ipairs(ids) do
        local info = CONFIG.SOURCE_TYPES[id]
        if info.views and info.views.vendor then table.insert(vendor, info.display) end
        if info.views and info.views.item   then table.insert(all,    info.display) end
    end
    return vendor, all
end

local VENDOR_SOURCES, ALL_SOURCES = BuildSourceLists()

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

-- Forward-declare vendor toggle handler; defined after RefreshVendorList later in
-- the file. The ScrollBox initializer (in CreatePanel) captures this local so
-- binds resolve the real function once it's populated.
local onVendorToggle

-- 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

-- Shop-specific comparator: bundle-letter items grouped first (a, b, c, ...),
-- solo items after. Within each group, alphabetical by item name.
local function sortByShopBundle(a, b)
    local la = HDG_VendorResolver.GetShopBundleLetter and HDG_VendorResolver.GetShopBundleLetter(a)
    local lb = HDG_VendorResolver.GetShopBundleLetter and HDG_VendorResolver.GetShopBundleLetter(b)
    if la and lb then
        if la ~= lb then return la < lb end
    elseif la then
        return true
    elseif lb then
        return false
    end
    return (HDG_Data.GetCachedItemName(a) or "") < (HDG_Data.GetCachedItemName(b) or "")
end

-- Virtual vendor npcID for the In-Game Shop (mirrors HDG_ShopDB.vendor.npcID)
local SHOP_VENDOR_NPCID = -9001

-- Search debounce timer
local searchDebounceTimer = 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(acquisitionPanel.scrollBox, nil)
        end
    end)
    helpBtn:SetPoint("RIGHT", headerBg, "RIGHT", -4, 1)
    HDG.Theme.ApplyFont(helpBtn:GetFontString(), GetScheme(), "small")
    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)
        acqFilters.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.Theme.ApplyFont(exportNotesBtn:GetFontString(), GetScheme(), "small")
    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)

    -- ========================================================================
    -- LEFT FILTER CONTROLS
    -- ========================================================================

    local viewRowY = -4
    local presetRowY = viewRowY - ACQ.VIEW_TOGGLE_HEIGHT - 5
    local searchRowY = presetRowY - 30
    local advancedRowY = searchRowY - 31
    local chipRowY = advancedRowY - 24
    local filtersRowY = advancedRowY - 24
    local leftInset = 6

    local byVendorBtn, byItemBtn, mapAllBtn
    local searchBox, expansionDropdown, zoneDropdown, factionDropdown, sourceDropdown
    local uncollectedCheck, dyeableCheck, resetBtn
    local filtersFrame, advancedBtn
    local presetButtons = {}

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

    local function ResetFilterState()
        acqFilters.expansion = "All"
        acqFilters.zone = "All"
        acqFilters.faction = "All"
        acqFilters.source = "All"
        acqFilters.uncollectedOnly = false
        acqFilters.dyeableOnly = false
        acqSortMode = "name"
        acqFilters.search = ""
        activePreset = nil
        if expansionDropdown then expansionDropdown:SetValue("All") end
        if zoneDropdown then zoneDropdown:SetValue("All") end
        if factionDropdown then factionDropdown:SetValue("All") end
        if sourceDropdown then sourceDropdown:SetValue("All") end
        if uncollectedCheck then uncollectedCheck:SetChecked(false) end
        if dyeableCheck then dyeableCheck:SetChecked(false) end
        if searchBox then searchBox:SetText("") end
    end

    local function UpdatePresetButtons()
        for key, btn in pairs(presetButtons) do
            btn:SetActive(key == activePreset)
        end
    end

    local function UpdateAdvancedLayout()
        if not acquisitionPanel then return end
        local chipRow = acquisitionPanel.filterChipRow

        if advancedBtn then
            advancedBtn:ClearAllPoints()
            advancedBtn:SetPoint("TOPLEFT", leftPanel, "TOPLEFT", leftInset, advancedRowY)
            advancedBtn:SetText((advancedFiltersOpen and "- " or "+ ") .. (L["ACQ_ADVANCED_FILTERS"] or "Advanced Filters"))
        end
        if resetBtn then
            resetBtn:ClearAllPoints()
            resetBtn:SetPoint("LEFT", advancedBtn, "RIGHT", 4, 0)
        end
        if filtersFrame then
            filtersFrame:ClearAllPoints()
            filtersFrame:SetPoint("TOPLEFT", advancedBtn, "BOTTOMLEFT", 0, -4)
            filtersFrame:SetPoint("RIGHT", leftPanel, "RIGHT", -leftInset, 0)
            if advancedFiltersOpen then filtersFrame:Show() else filtersFrame:Hide() end
        end
        if chipRow then
            chipRow:ClearAllPoints()
            if advancedFiltersOpen and filtersFrame then
                chipRow:SetPoint("TOPLEFT", filtersFrame, "BOTTOMLEFT", 0, -4)
            else
                chipRow:SetPoint("TOPLEFT", advancedBtn, "BOTTOMLEFT", 0, -4)
            end
            chipRow:SetPoint("RIGHT", leftPanel, "RIGHT", -leftInset, 0)
        end
        if not acquisitionPanel.scrollBox then return end
        acquisitionPanel.scrollBox:ClearAllPoints()
        acquisitionPanel.scrollBox:SetPoint("TOPLEFT", chipRow or advancedBtn, "BOTTOMLEFT", 0, -4)
        acquisitionPanel.scrollBox:SetPoint("BOTTOMRIGHT", -20, 24)
    end

    local function ClearSingleFilter(key)
        activePreset = nil
        if key == "search" then
            acqFilters.search = ""
            if searchBox then searchBox:SetText("") end
        elseif key == "uncollected" then
            acqFilters.uncollectedOnly = false
            if uncollectedCheck then uncollectedCheck:SetChecked(false) end
        elseif key == "expansion" then
            acqFilters.expansion = "All"
            if expansionDropdown then expansionDropdown:SetValue("All") end
        elseif key == "zone" then
            acqFilters.zone = "All"
            if zoneDropdown then zoneDropdown:SetValue("All") end
        elseif key == "faction" then
            acqFilters.faction = "All"
            if factionDropdown then factionDropdown:SetValue("All") end
        elseif key == "source" then
            acqFilters.source = "All"
            if sourceDropdown then sourceDropdown:SetValue("All") end
        elseif key == "dyeable" then
            acqFilters.dyeableOnly = false
            if dyeableCheck then dyeableCheck:SetChecked(false) end
        end
        UpdatePresetButtons()
        HDG_AcquisitionTab:RefreshList()
    end

    local function UpdateFilterChips()
        if not acquisitionPanel or not acquisitionPanel.filterChips then return end
        for _, chip in ipairs(acquisitionPanel.filterChips) do chip:Hide() end

        local idx = 1
        local hasFilters = false
        local firstRowX = 78
        local xOffset = firstRowX
        local row = 0
        local rowHeight = 18
        local maxWidth = (acquisitionPanel.filterChipRow:GetWidth() or 0) - 8
        if maxWidth <= 0 then maxWidth = leftWidth - 20 end
        local function showChip(text, key)
            local chip = acquisitionPanel.filterChips[idx]
            if not chip then return end
            hasFilters = true
            chip._filterKey = key
            chip.text:SetText(text .. "  X")
            local width = math.min(math.max(chip.text:GetStringWidth() + 16, 58), 118)
            if xOffset + width > maxWidth then
                xOffset = 4
                row = row + 1
            end
            chip:SetWidth(width)
            chip:ClearAllPoints()
            chip:SetPoint("TOPLEFT", acquisitionPanel.filterChipRow, "TOPLEFT", xOffset, -3 - row * rowHeight)
            chip:Show()
            xOffset = xOffset + width + 4
            idx = idx + 1
        end

        if acqFilters.search ~= "" then showChip("Search: " .. acqFilters.search, "search") end
        if acqFilters.uncollectedOnly then showChip("Missing only", "uncollected") end
        if acqFilters.expansion ~= "All" then showChip(acqFilters.expansion, "expansion") end
        if acqFilters.zone ~= "All" then showChip(acqFilters.zone, "zone") end
        if acqFilters.faction ~= "All" then showChip(acqFilters.faction, "faction") end
        if acqFilters.source ~= "All" then showChip("Source: " .. acqFilters.source, "source") end
        if acqFilters.dyeableOnly then showChip("Dyeable", "dyeable") end

        if acquisitionPanel.filterChipHint then
            if hasFilters then acquisitionPanel.filterChipHint:Hide() else acquisitionPanel.filterChipHint:Show() end
        end

        local newHeight = 4 + (row + 1) * rowHeight
        if acquisitionPanel.filterChipRow:GetHeight() ~= newHeight then
            acquisitionPanel.filterChipRow:SetHeight(newHeight)
            UpdateAdvancedLayout()
        end
    end
    acquisitionPanel.UpdateFilterChips = UpdateFilterChips

    local function ApplyQuickPreset(key)
        ResetFilterState()
        activePreset = key
        if key == "missing" then
            acqFilters.uncollectedOnly = true
        elseif key == "rep" then
            acqFilters.source = "Reputation"
        elseif key == "crafted" then
            acqFilters.source = "Crafted"
        elseif key == "achieve" then
            acqFilters.source = "Achievement"
        elseif key == "quest" then
            acqFilters.source = "Quest"
        end

        if byVendorBtn then byVendorBtn:SetActive(viewMode == "vendor") end
        if byItemBtn then byItemBtn:SetActive(viewMode == "item") end
        SyncSourceDropdown(false)
        if sourceDropdown then sourceDropdown:SetValue(acqFilters.source) end
        if uncollectedCheck then uncollectedCheck:SetChecked(acqFilters.uncollectedOnly) end
        UpdatePresetButtons()
        HDG_AcquisitionTab:RefreshList()
    end

    local function RunPrimaryAction()
        if viewMode == "item" then
            ResetFilterState()
            UpdatePresetButtons()
            HDG_AcquisitionTab:RefreshList()
            return
        end
        -- Vendor mode skips the universe-wide filteredItems rebuild for perf, so refresh here
        -- to make sure we're mapping the current filter state regardless of view mode.
        HDG_AcqData.RebuildFilteredItems(acqFilters, acqSortMode)
        local seen = {}
        local waypoints = {}
        local filters = acqFilters
        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

    byVendorBtn = HDG.UI:TabButton(leftPanel, L["ACQ_SHOP_BY_VENDOR"] or L["ACQ_BY_VENDOR"], true)
    byVendorBtn:SetSize(112, ACQ.VIEW_TOGGLE_HEIGHT)
    byVendorBtn:SetPoint("TOPLEFT", leftInset, viewRowY)
    byVendorBtn:SetScript("OnClick", function()
        viewMode = "vendor"
        activePreset = nil
        byVendorBtn:SetActive(true)
        acquisitionPanel.byItemBtn:SetActive(false)
        SyncSourceDropdown()
        UpdatePresetButtons()
        HDG_AcquisitionTab:RefreshList()
    end)
    acquisitionPanel.byVendorBtn = byVendorBtn

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

    local function CreatePreset(key, label, anchor, xOffset)
        local btn = HDG.UI:TabButton(leftPanel, label, key == activePreset)
        btn:SetSize(58, 22)
        btn:SetPoint("TOPLEFT", anchor, "TOPLEFT", xOffset, presetRowY)
        btn:SetScript("OnClick", function() ApplyQuickPreset(key) end)
        presetButtons[key] = btn
        return btn
    end

    local presetAnchor = leftPanel
    CreatePreset("missing", "Missing", presetAnchor, leftInset)
    CreatePreset("achieve", "Achieve", presetAnchor, 67)
    CreatePreset("rep", "Rep", presetAnchor, 128)
    CreatePreset("crafted", "Crafted", presetAnchor, 189)
    CreatePreset("quest", "Quest", presetAnchor, 250)

    -- 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", -leftInset, searchRowY)
    HDG.Theme.ApplyFont(clearPinsBtn:GetFontString(), GetScheme(), "small")
    HDG_UIHelpers.SetupTooltip(clearPinsBtn, L["ACQ_CLEAR_PINS"], "Remove all HDG waypoints and map pins.", "ANCHOR_BOTTOM")

    -- Map All button
    mapAllBtn = HDG.UI:Button(leftPanel, L["ACQ_MAP_ALL"], 96, ACQ.VIEW_TOGGLE_HEIGHT, RunPrimaryAction)
    mapAllBtn:SetPoint("RIGHT", clearPinsBtn, "LEFT", -2, 0)
    HDG.Theme.ApplyFont(mapAllBtn:GetFontString(), GetScheme(), "small")
    acquisitionPanel.mapAllBtn = mapAllBtn

    -- Search box (fills space between tabs and Map All)
    searchBox = CreateFrame("EditBox", nil, leftPanel, "SearchBoxTemplate")
    searchBox:SetPoint("TOPLEFT", leftPanel, "TOPLEFT", leftInset + 2, searchRowY)
    searchBox:SetPoint("RIGHT", mapAllBtn, "LEFT", -4, 0)
    searchBox:SetHeight(ACQ.VIEW_TOGGLE_HEIGHT)
    searchBox:SetAutoFocus(false)
    searchBox:SetScript("OnTextChanged", function(self)
        SearchBoxTemplate_OnTextChanged(self)
        acqFilters.search = self:GetText():lower()
        activePreset = nil
        UpdatePresetButtons()
        if searchDebounceTimer then searchDebounceTimer:Cancel() end
        searchDebounceTimer = C_Timer.NewTimer(ACQ.SEARCH_DEBOUNCE, function()
            HDG_AcquisitionTab:RefreshList()
        end)
    end)
    RegisterWidget(searchBox, "SearchBox")
    acquisitionPanel.searchBox = searchBox

    local chipRow = CreateFrame("Frame", nil, leftPanel, "BackdropTemplate")
    chipRow:SetPoint("TOPLEFT", leftPanel, "TOPLEFT", leftInset, chipRowY)
    chipRow:SetPoint("RIGHT", leftPanel, "RIGHT", -leftInset, 0)
    chipRow:SetHeight(22)
    chipRow:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
    chipRow:SetBackdropColor(0.04, 0.05, 0.05, 0.92)
    chipRow:SetBackdropBorderColor(0.15, 0.17, 0.17, 1)
    local chipDivider = chipRow:CreateTexture(nil, "ARTWORK")
    chipDivider:SetHeight(1)
    chipDivider:SetPoint("TOPLEFT", 1, -1)
    chipDivider:SetPoint("TOPRIGHT", -1, -1)
    if scheme then
        chipDivider:SetColorTexture(scheme.border.r, scheme.border.g, scheme.border.b, 0.55)
    else
        chipDivider:SetColorTexture(0.24, 0.26, 0.26, 0.55)
    end
    local chipLabel = chipRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    chipLabel:SetPoint("TOPLEFT", 6, -4)
    chipLabel:SetText("Active filters")
    local chipHint = chipRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    chipHint:SetPoint("LEFT", chipLabel, "RIGHT", 8, 0)
    chipHint:SetText("No active filters")
    if scheme then
        ApplyFont(chipLabel, scheme, "xsmall")
        ApplyFont(chipHint, scheme, "xsmall")
        chipLabel:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, scheme.text_dim.a)
        chipHint:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, 0.75)
    end
    acquisitionPanel.filterChipHint = chipHint
    acquisitionPanel.filterChipRow = chipRow
    acquisitionPanel.filterChips = {}
    for i = 1, 7 do
        local chip = CreateFrame("Button", nil, chipRow, "BackdropTemplate")
        chip:SetSize(88, 16)
        chip:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
        chip:SetBackdropColor(0.20, 0.21, 0.21, 0.96)
        chip:SetBackdropBorderColor(0.32, 0.34, 0.34, 1)
        chip.text = chip:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        chip.text:SetPoint("CENTER", 0, 0)
        chip.text:SetJustifyH("CENTER")
        if scheme then
            ApplyFont(chip.text, scheme, "xsmall")
            chip.text:SetTextColor(scheme.text.r, scheme.text.g, scheme.text.b, scheme.text.a)
        end
        chip:SetScript("OnClick", function(self)
            ClearSingleFilter(self._filterKey)
        end)
        chip:Hide()
        acquisitionPanel.filterChips[i] = chip
    end

    advancedBtn = HDG.UI:Button(leftPanel, "+ " .. (L["ACQ_ADVANCED_FILTERS"] or "Advanced Filters"), 130, 20, function()
        advancedFiltersOpen = not advancedFiltersOpen
        UpdateAdvancedLayout()
    end)
    advancedBtn:SetPoint("TOPLEFT", leftPanel, "TOPLEFT", leftInset, advancedRowY)
    HDG.Theme.ApplyFont(advancedBtn:GetFontString(), GetScheme(), "small")
    acquisitionPanel.advancedBtn = advancedBtn

    resetBtn = HDG.UI:Button(leftPanel, L["ACQ_RESET"], 48, 20, function()
        ResetFilterState()
        UpdatePresetButtons()
        HDG_AcquisitionTab:RefreshList()
    end)
    resetBtn:SetPoint("LEFT", advancedBtn, "RIGHT", 4, 0)
    HDG.Theme.ApplyFont(resetBtn:GetFontString(), GetScheme(), "small")
    HDG_UIHelpers.SetupTooltip(resetBtn, L["ACQ_RESET"], "Reset all filters to defaults.", "ANCHOR_BOTTOM")

    filtersFrame = CreateFrame("Frame", nil, leftPanel)
    filtersFrame:SetPoint("TOPLEFT", leftPanel, "TOPLEFT", leftInset, filtersRowY)
    filtersFrame:SetPoint("RIGHT", leftPanel, "RIGHT", -leftInset, 0)
    filtersFrame:SetHeight(70)
    filtersFrame:Hide()
    acquisitionPanel.filtersFrame = filtersFrame

    -- ========================================================================
    -- ADVANCED FILTERS: EXPANSION / ZONE / REPUTATION DROPDOWNS
    -- ========================================================================

    local row2Y = -12

    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 function MakeFilterLabel(text, anchor, x, y)
        local fs = filtersFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        fs:SetPoint("TOPLEFT", anchor, "TOPLEFT", x, y)
        fs:SetText(text)
        if scheme then
            ApplyFont(fs, scheme, "xsmall")
            fs:SetTextColor(scheme.text_dim.r, scheme.text_dim.g, scheme.text_dim.b, scheme.text_dim.a)
        end
        return fs
    end

    MakeFilterLabel("Expansion", filtersFrame, 0, 0)
    MakeFilterLabel("Zone", filtersFrame, ddWidth + ddGap, 0)
    MakeFilterLabel("Reputation", filtersFrame, (ddWidth + ddGap) * 2, 0)

    expansionDropdown = HDG.UI:Dropdown(filtersFrame, {
        items = HDG_AcqData.uniqueExpansions,
        currentValue = "All",
        allLabel = L["FILTER_ALL_EXPANSIONS"],
        width = ddWidth,
        colorFn = function(val)
            if val == "All" then return nil end
            return HDG_ExpansionData.GetColor(val)
        end,
        onChange = function(val)
            acqFilters.expansion = val
            activePreset = nil
            UpdatePresetButtons()
            HDG_AcquisitionTab:RefreshList()
        end,
    })
    expansionDropdown:SetPoint("TOPLEFT", 0, row2Y)
    acquisitionPanel.expansionDropdown = expansionDropdown

    zoneDropdown = HDG.UI:Dropdown(filtersFrame, {
        items = HDG_AcqData.uniqueZones,
        currentValue = "All",
        allLabel = L["ACQ_ALL_ZONES"],
        width = ddWidth,
        colorFn = activeFilterColor,
        onChange = function(val)
            acqFilters.zone = val
            activePreset = nil
            UpdatePresetButtons()
            HDG_AcquisitionTab:RefreshList()
        end,
    })
    zoneDropdown:SetPoint("LEFT", expansionDropdown, "RIGHT", ddGap, 0)
    acquisitionPanel.zoneDropdown = zoneDropdown

    factionDropdown = HDG.UI:Dropdown(filtersFrame, {
        items = HDG_AcqData.uniqueFactions,
        currentValue = "All",
        allLabel = L["ACQ_ALL_REPS"],
        width = ddWidth,
        colorFn = activeFilterColor,
        onChange = function(val)
            acqFilters.faction = val
            activePreset = nil
            UpdatePresetButtons()
            HDG_AcquisitionTab:RefreshList()
        end,
    })
    factionDropdown:SetPoint("LEFT", zoneDropdown, "RIGHT", ddGap, 0)
    acquisitionPanel.factionDropdown = factionDropdown

    -- ========================================================================
    -- ADVANCED FILTERS: SOURCE DROPDOWN + CHECKBOXES
    -- ========================================================================

    local row3Y = -50
    MakeFilterLabel("Source", filtersFrame, 0, -38)

    sourceDropdown = HDG.UI:Dropdown(filtersFrame, {
        items = VENDOR_SOURCES,
        currentValue = "All",
        allLabel = L["ACQ_ALL_SOURCES"],
        width = 90,
        colorFn = activeFilterColor,
        onChange = function(val)
            acqFilters.source = val
            activePreset = nil
            UpdatePresetButtons()
            HDG_AcquisitionTab:RefreshList()
        end,
    })
    sourceDropdown:SetPoint("TOPLEFT", 0, row3Y)
    acquisitionPanel.sourceDropdown = sourceDropdown

    uncollectedCheck = HDG.UI:Checkbox(filtersFrame, 14, L["CHECK_UNCOLLECTED_ONLY"])
    uncollectedCheck:SetPoint("LEFT", sourceDropdown, "RIGHT", 8, 0)
    uncollectedCheck.onChanged = function(checked)
        acqFilters.uncollectedOnly = checked
        activePreset = nil
        UpdatePresetButtons()
        HDG_AcquisitionTab:RefreshList()
    end

    dyeableCheck = HDG.UI:Checkbox(filtersFrame, 14, L["ACQ_FILTER_DYE"])
    dyeableCheck:SetPoint("LEFT", uncollectedCheck, "LEFT", 96, 0)
    dyeableCheck.onChanged = function(checked)
        acqFilters.dyeableOnly = checked
        activePreset = nil
        UpdatePresetButtons()
        HDG_AcquisitionTab:RefreshList()
    end

    -- Item list: modern ScrollBox + DataProvider. Outer scroll hosts both
    -- By Item and By Vendor rows as a flat, variable-extent list. Virtualization
    -- is native (only viewport frames exist), so the old manual virtual-scroll
    -- path in UpdateVisibleItemRows + HookScript("OnVerticalScroll") is gone.
    local scrollBox = CreateFrame("Frame", nil, leftPanel, "WowScrollBoxList")
    scrollBox:SetPoint("TOPLEFT", advancedBtn, "BOTTOMLEFT", 0, -4)
    scrollBox:SetPoint("BOTTOMRIGHT", -20, 24)

    local scrollBar = CreateFrame("EventFrame", nil, leftPanel, "MinimalScrollBar")
    scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 2, 0)
    scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 2, 0)

    local view = CreateScrollBoxListLinearView(0, 0, 0, 0, 0)
    view:SetElementExtentCalculator(function(_, elementData)
        return HDG_AcqRows.GetElementExtent(elementData, CONFIG)
    end)

    view:SetElementInitializer("Button", function(frame, elementData)
        HDG_AcqRows.InitializeRow(frame, CONFIG)
        frame:SetWidth(scrollBox:GetWidth())
        HDG_AcqRows.BindRow(frame, elementData, CONFIG, {
            selectedItemID = selectedItemID,
            selectedNpcID = selectedNpcID,
            expandedVendors = expandedVendors,
            onSelectItem = onSelectItem,
            onVendorToggle = onVendorToggle,
        })
    end)

    ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)

    acquisitionPanel.scrollBox = scrollBox
    acquisitionPanel.scrollBar = scrollBar
    acquisitionPanel.scrollView = view
    UpdateAdvancedLayout()

    -- 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: ScrollBox handles viewport math via data-provider index
        -- (we record entry.dataIndex when populating the provider in Refresh*List).
        if entry.dataIndex and acquisitionPanel.scrollBox then
            acquisitionPanel.scrollBox:ScrollToElementDataIndex(entry.dataIndex, ScrollBoxConstants.AlignNearest, ScrollBoxConstants.NoScrollInterpolation)
        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

    -- Wowhead link (icon + clickable URL below subtitle)
    local whBtn = CreateFrame("Button", nil, headerPanel)
    whBtn:SetSize(12, 12)
    whBtn:SetPoint("TOPLEFT", headerPanel.subtitle, "BOTTOMLEFT", 0, -2)
    local whIcon = whBtn:CreateTexture(nil, "ARTWORK")
    whIcon:SetAllPoints()
    whIcon:SetTexture("Interface\\AddOns\\HousingDecorGuide\\textures\\wowhead_logo")
    headerPanel.whBtn = whBtn

    headerPanel.whText = headerPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    headerPanel.whText:SetPoint("LEFT", whBtn, "RIGHT", 4, 0)
    headerPanel.whText:SetPoint("RIGHT", headerPanel, "RIGHT", -110, 0)
    headerPanel.whText:SetJustifyH("LEFT")
    if scheme then ApplyFont(headerPanel.whText, scheme, "small") end

    local whClickZone = CreateFrame("Button", nil, headerPanel)
    whClickZone:SetPoint("TOPLEFT", whBtn, "TOPLEFT", 0, 2)
    whClickZone:SetPoint("BOTTOMRIGHT", headerPanel.whText, "BOTTOMRIGHT", 0, -2)
    whClickZone:SetScript("OnClick", function()
        if headerPanel._whUrl then ShowWowheadUrl(whBtn, headerPanel._whUrl) end
    end)
    HDG_UIHelpers.SetupTooltip(whClickZone, "Wowhead", "Click to copy URL", "ANCHOR_RIGHT")
    headerPanel.whClickZone = whClickZone
    whBtn:Hide(); headerPanel.whText:Hide(); whClickZone:Hide()

    -- Vendor note pencil icon + text (anchored at bottom of header)
    local vendorNoteBtnH = HDG_UIHelpers.CreateAtlasButton(headerPanel, "communities-icon-notification", 12,
        { pressed = false, highlight = true })
    vendorNoteBtnH:SetPoint("BOTTOMLEFT", 8, 4)
    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 = HDG_UIHelpers.CreateAtlasButton(infoFrame, "communities-icon-notification", 12,
        { pressed = false, highlight = true })
    itemNoteBtn:SetPoint("TOPLEFT", detailPanel.otherVendorsLabel, "BOTTOMLEFT", 0, -6)
    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 .. "Start Here|r",
        "The left side is your search and shopping board. Pick a mode, narrow the list, then click a vendor or item to show details here.",
        "",
        "1. Use " .. a .. "Shop by Vendor|r when you want a shopping route or want to inspect one vendor's stock.",
        "2. Use " .. a .. "Find by Item|r when you know the decor you want or want to search across every source.",
        "3. Use quick filters like " .. a .. "Missing|r, " .. a .. "Achieve|r, " .. a .. "Rep|r, " .. a .. "Crafted|r, and " .. a .. "Quest|r to focus the list without changing modes.",
        "4. Use " .. a .. "Advanced Filters|r for expansion, zone, reputation, source, missing-only, and dye filters.",
        "",
        a .. "Active Filters|r",
        "Filters you add appear in the dark chip row above the results. Click a chip's " .. a .. "X|r to remove just that filter, or click " .. a .. "Reset|r to clear everything.",
        "",
        a .. "Good First Searches|r",
        "Find missing vendor decor: choose " .. a .. "Shop by Vendor|r, click " .. a .. "Missing|r, then use " .. a .. "Map Vendors|r to place waypoints for matching vendors.",
        "Find one item: choose " .. a .. "Find by Item|r, type in Search, then click the result to see every known source.",
        "Check gated decor: use " .. a .. "Achieve|r, " .. a .. "Rep|r, or " .. a .. "Quest|r to surface items with account, faction, or quest requirements.",
        "Build a craft list: click " .. a .. "Crafted|r, select an item, then use " .. a .. "Add to Crafting Queue|r when the item details support it.",
        "",
        a .. "What the Buttons Do|r",
        a .. "Map Vendors|r places waypoints for every matching vendor in Shop by Vendor mode. It uses TomTom if installed, otherwise Blizzard waypoints.",
        a .. "Clear Filters|r appears in Find by Item mode because item search is usually about changing the filter set quickly.",
        a .. "Clear Pins|r removes HDG map pins and waypoints without changing your filters.",
        "",
        a .. "Reading Rows|r",
        "Vendor rows show collection progress for that vendor's visible stock. Expand a vendor to see individual items.",
        "Item rows show the source type and any known requirements. Select an item to preview it, check requirements, add notes, or navigate to a vendor.",
        "",
        a .. "Common 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 .. " Achievement - Green means complete; orange means incomplete.",
        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

-- Build a DataProvider of item elements for By Item mode. ScrollBox handles
-- virtualization natively, so the legacy VIRTUAL_SCROLL_THRESHOLD + manual
-- viewport rendering in UpdateVisibleItemRows is gone.
function HDG_AcquisitionTab:RefreshItemList()
    local dp = CreateDataProvider()
    wipe(navigableItems)
    for i, itemID in ipairs(HDG_AcqData.filteredItems) do
        dp:Insert({ kind = "item", itemID = itemID, _rowIndex = i })
        navigableItems[i] = { itemID = itemID, dataIndex = i }
    end
    return dp, HDG_AcqData.filteredUncollectedCount
end

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

-- Hoisted toggle handler (captures module-scope expandedVendors/selectedVendor);
-- forward-declared near the top of the file so the ScrollBox initializer sees it.
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

-- Build a DataProvider of vendor-header / bundle-header / indented-item
-- elements for By Vendor mode. Expanded vendors inline their items; Shop vendor
-- additionally groups items by bundle with bundle-header rows above each group.
function HDG_AcquisitionTab:RefreshVendorList()
    local dp = CreateDataProvider()
    local uncollectedCount = 0
    local ACQ = CONFIG.ACQUISITION_TAB

    local HDG_Debug = addon and addon.HDG_Debug
    local Timed = (HDG_Debug and HDG_Debug.Timed) or function(_, fn) return fn() end

    Timed("Acq.Rebuild.FilteredVendors", function()
        HDG_AcqData.RebuildFilteredVendors(acqFilters)
    end)
    wipe(navigableItems)

    local IsCollected = HDG_AcqData.IsCollectedCached
    local itemRowIndex = 0
    local function pushItem(itemID, npcID)
        if not IsCollected(itemID) then
            uncollectedCount = uncollectedCount + 1
        end
        itemRowIndex = itemRowIndex + 1
        dp:Insert({ kind = "item", itemID = itemID, npcID = npcID, indent = ACQ.VENDOR_INDENT, _rowIndex = itemRowIndex })
        navigableItems[#navigableItems + 1] = { itemID = itemID, npcID = npcID, dataIndex = dp:GetSize() }
    end

    for _, npcID in ipairs(HDG_AcqData.filteredVendors) do
        dp:Insert({ kind = "vendor", npcID = npcID })

        if expandedVendors[npcID] then
            wipe(reusableItemList)
            local cachedItems = HDG_AcqData.vendorFilteredItems[npcID] or {}
            for _, itemID in ipairs(cachedItems) do
                table.insert(reusableItemList, itemID)
            end

            local isShop = (npcID == SHOP_VENDOR_NPCID)
            if isShop then
                local bundleMembers = {}
                local soloItems = {}
                for _, itemID in ipairs(reusableItemList) do
                    local bkey = HDG_VendorResolver.GetShopBundleKey and HDG_VendorResolver.GetShopBundleKey(itemID)
                    if bkey then
                        bundleMembers[bkey] = bundleMembers[bkey] or {}
                        table.insert(bundleMembers[bkey], itemID)
                    else
                        table.insert(soloItems, itemID)
                    end
                end
                for _, list in pairs(bundleMembers) do
                    table.sort(list, sortByItemName)
                end
                table.sort(soloItems, sortByItemName)

                local legend = HDG_VendorResolver.GetShopBundleLegend and HDG_VendorResolver.GetShopBundleLegend() or {}
                for _, b in ipairs(legend) do
                    local members = bundleMembers[b.key]
                    if members and #members > 0 then
                        dp:Insert({ kind = "bundle", bundle = b, indent = ACQ.VENDOR_INDENT })
                        for _, itemID in ipairs(members) do
                            pushItem(itemID, npcID)
                        end
                    end
                end
                for _, itemID in ipairs(soloItems) do
                    pushItem(itemID, npcID)
                end
            else
                table.sort(reusableItemList, sortByItemName)
                for _, itemID in ipairs(reusableItemList) do
                    pushItem(itemID, npcID)
                end
            end
        end
    end

    return dp, uncollectedCount
end

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

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

    local HDG_Debug = addon and addon.HDG_Debug
    local Timed = (HDG_Debug and HDG_Debug.Timed) or function(_, fn) return fn() end

    HDG_AcqRows.InvalidateSchemeCache()

    -- Vendor mode derives its item count from vendorFilteredItems inside RebuildFilteredVendors,
    -- so the universe-wide RebuildFilteredItems pass is only needed for By Item mode.
    local dp, uncollectedCount
    if viewMode == "vendor" then
        Timed("Acq.RefreshList.RefreshVendorList", function()
            dp, uncollectedCount = self:RefreshVendorList()
        end)
    else
        Timed("Acq.RefreshList.RebuildFilteredItems", function()
            HDG_AcqData.RebuildFilteredItems(acqFilters, acqSortMode)
        end)
        Timed("Acq.RefreshList.RefreshItemList[" .. #HDG_AcqData.filteredItems .. "]", function()
            dp, uncollectedCount = self:RefreshItemList()
        end)
    end

    -- RetainScrollPosition unconditionally: filter/mode toggles shouldn't jump
    -- the list back to top. ScrollBox clamps to data bounds if the list shrinks.
    -- (keepScroll arg is now redundant but kept for call-site compatibility.)
    Timed("Acq.RefreshList.SetDataProvider[" .. (dp and dp:GetSize() or 0) .. "]", function()
        acquisitionPanel.scrollBox:SetDataProvider(dp, ScrollBoxConstants.RetainScrollPosition)
    end)

    local itemCount = (viewMode == "vendor") and HDG_AcqData.filteredItemCount or #HDG_AcqData.filteredItems
    if acquisitionPanel.UpdateFilterChips then acquisitionPanel.UpdateFilterChips() end

    if acquisitionPanel.mapAllBtn then
        if viewMode == "vendor" then
            acquisitionPanel.mapAllBtn:SetText(string.format("Map %d Vendors", #HDG_AcqData.filteredVendors))
        else
            acquisitionPanel.mapAllBtn:SetText(L["ACQ_CLEAR_FILTERS"] or "Clear Filters")
        end
    end

    -- Update status bar
    if acquisitionPanel.statusBar then
        if viewMode == "vendor" then
            acquisitionPanel.statusBar:SetText(string.format(
                L["ACQ_STATUS_VENDORS"], #HDG_AcqData.filteredVendors, itemCount
            ))
        else
            local statusText = string.format(
                L["ACQ_STATUS_ITEMS"], itemCount, uncollectedCount or 0
            )
            -- Append achievement collection stat when Achievement source filter active
            if acqFilters.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(), itemCount
        ))
    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:SelectVendorItem(itemID, vendors, preferredNpcID)
    local newVendor = vendors[1]
    local preferNPC = preferredNpcID 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.galleryPanel:Show()
    acquisitionPanel.infoPanel:Hide()
    self:UpdateDetailPanel_Vendor(itemID, vendors)
end

function HDG_AcquisitionTab:SelectCraftedItem(itemID)
    selectedVendor = nil
    galleryVendorNPC = nil
    acquisitionPanel.waypointBtn:Hide()
    acquisitionPanel.mapBtn:Hide()
    HDG_AcqRows.ReleaseAllCells()
    acquisitionPanel.galleryPanel:Hide()
    acquisitionPanel.infoPanel:Show()
    self:UpdateDetailPanel_Crafted(itemID)
    self:UpdateInfoPanel_Crafted(itemID)
end

function HDG_AcquisitionTab:SelectNonVendorItem(itemID)
    selectedVendor = nil
    galleryVendorNPC = nil
    acquisitionPanel.waypointBtn:Hide()
    acquisitionPanel.mapBtn:Hide()
    HDG_AcqRows.ReleaseAllCells()
    acquisitionPanel.galleryPanel:Hide()
    acquisitionPanel.infoPanel:Show()
    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

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()

    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)

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

    if hasVendors then
        self:SelectVendorItem(itemID, vendors, npcID)
    elseif isCrafted then
        self:SelectCraftedItem(itemID)
    else
        self:SelectNonVendorItem(itemID)
    end

    self:UpdateHeaderPanel(itemID)
    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
            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
            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(acquisitionPanel.scrollBox, 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 L["ACQ_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("")
        -- Wowhead NPC link
        if selectedVendor.npcID and selectedVendor.npcID > 0 and selectedVendor.npcID < 10000000 then
            hp.whBtn:ClearAllPoints()
            hp.whBtn:SetPoint("TOPLEFT", hp.subtitle, "BOTTOMLEFT", 0, -2)
            hp._whUrl = WowheadNpcUrl(selectedVendor.npcID)
            hp.whText:SetText(HDG.Theme.C_Dim() .. "View vendor on Wowhead|r")
            hp.whBtn:Show(); hp.whText:Show(); hp.whClickZone:Show()
        else
            hp._whUrl = nil
            hp.whBtn:Hide(); hp.whText:Hide(); hp.whClickZone:Hide()
        end
    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() .. L["STATUS_COLLECTED"] .. "|r")
        else
            hp.collectedBadge:SetText(HDG.Theme.C_Warning() .. L["STATUS_NOT_COLLECTED"] .. "|r")
        end
        -- Wowhead item link (anchor to itemName since subtitle hidden)
        hp.whBtn:ClearAllPoints()
        hp.whBtn:SetPoint("TOPLEFT", hp.itemName, "BOTTOMLEFT", 0, -2)
        hp._whUrl = WowheadItemUrl(itemID)
        hp.whText:SetText(HDG.Theme.C_Dim() .. "View item on Wowhead|r")
        hp.whBtn:Show(); hp.whText:Show(); hp.whClickZone:Show()
    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
    local info = HDG_Data and HDG_Data.GetCatalogByItem and HDG_Data.GetCatalogByItem(itemID)
    if not info then return end

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

    local t = HDG.Theme.C_Text()

    -- Size
    local sizeLabel = SIZE_LABELS[info.size]
    if sizeLabel and line <= 9 then
        dp["line" .. line]:SetText(a .. "Size:|r " .. t .. sizeLabel .. "|r")
        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 " .. t .. table.concat(placement, ", ") .. "|r")
        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 (skip for promo/shop — already shown in detail line1)
    local adEntry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
    local srcType = adEntry and adEntry[2] or 0
    if info.sourceText and info.sourceText ~= "" and line <= 9
       and srcType ~= CONFIG.SOURCE_TYPE.PROMOTION and srcType ~= CONFIG.SOURCE_TYPE.SHOP 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)
    local t = HDG.Theme.C_Text()
    if priceStr and currencies then
        local iconSize = CONFIG.ACQUISITION_TAB.CURRENCY_ICON_SIZE
        local parts = { t .. priceStr }
        for _, c in ipairs(currencies) do
            local iconStr = HDG_VendorResolver.FormatCurrencyIcon(c.icon, iconSize)
            table.insert(parts, string.format("%s%s %d %s", t, iconStr, c.amount, c.name or ""))
        end
        dp["line" .. line]:SetText(HDG.Theme.C_Accent() .. "Cost:|r " .. table.concat(parts, "  ") .. "|r")
        line = line + 1
    elseif priceStr then
        dp["line" .. line]:SetText(HDG.Theme.C_Accent() .. "Cost:|r " .. t .. priceStr .. "|r")
        line = line + 1
    elseif currencies then
        local iconSize = CONFIG.ACQUISITION_TAB.CURRENCY_ICON_SIZE
        local parts = {}
        for _, c in ipairs(currencies) do
            local iconStr = HDG_VendorResolver.FormatCurrencyIcon(c.icon, iconSize)
            table.insert(parts, string.format("%s%s %d %s", t, iconStr, c.amount, c.name or ""))
        end
        dp["line" .. line]:SetText(HDG.Theme.C_Accent() .. "Cost:|r " .. table.concat(parts, "  ") .. "|r")
        line = line + 1
    else
        dp["line" .. line]:SetText(HDG.Theme.C_Dim() .. "Visit vendor to see cost|r")
        line = line + 1
    end

    -- Dyeable status
    local StyleEngine = addon.HDG_StyleEngine
    if StyleEngine and StyleEngine:IsDyeable(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)
    local t = HDG.Theme.C_Text()
    dp.line1:SetText(profIcon .. " " .. HDG.Theme.C_Accent() .. "Profession:|r " .. t .. (recipe.profession or "Unknown") .. "|r")
    dp.line2:SetText(HDG.Theme.C_Accent() .. "Expansion:|r " .. t .. (recipe.expansion or "Unknown") .. "|r")

    -- 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 ""

    local t = HDG.Theme.C_Text()
    if si.type == CONFIG.SOURCE_TYPE.PROMOTION or si.type == CONFIG.SOURCE_TYPE.SHOP 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 " .. t .. detail .. "|r")
    else
        dp.line1:SetText(HDG.Theme.C_Accent() .. typeLabel .. ":|r " .. t .. sourceName .. "|r")
    end

    if si.type == CONFIG.SOURCE_TYPE.SHOP then
        local price = HEARTHSTEEL_PRICES[itemID]
        if price and price > 0 then
            dp.line2:SetText(HDG.Theme.C_Accent() .. "Cost:|r " .. t .. price .. " Hearthsteel|r")
        elseif price == 0 then
            dp.line2:SetText(HDG.Theme.C_Dim() .. "Bundle only|r")
        else
            dp.line2:SetText("")
        end
    elseif 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)
    local t = HDG.Theme.C_Text()

    if si.type == ST.PROMOTION or si.type == ST.SHOP 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 " .. t .. detail .. "|r")
    elseif si.type == ST.ACHIEVEMENT and si.detail and si.detail ~= "" then
        ip.sourceLine:SetText(HDG.Theme.C_Accent() .. typeLabel .. ":|r " .. t .. (si.name or "") .. " (" .. si.detail .. ")|r")
    else
        ip.sourceLine:SetText(HDG.Theme.C_Accent() .. typeLabel .. ":|r " .. t .. (si.name or "") .. "|r")
    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.SHOP then
        local price = HEARTHSTEEL_PRICES[itemID]
        if price and price > 0 then
            ip.extraLine:SetText(HDG.Theme.C_Accent() .. "Cost:|r " .. t .. price .. " Hearthsteel|r")
        elseif price == 0 then
            ip.extraLine:SetText(HDG.Theme.C_Dim() .. "Bundle only|r")
        else
            ip.extraLine:SetText("")
        end
    elseif 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
                HDG_UI.AddToCraftingQueue(selRecipe)
                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(L["ACQ_NO_ITEMS_INVENTORY"])
        acquisitionPanel.costSummary:SetText("")
        return
    end

    -- Filter out unreleased items when "Hide Unreleased" is checked
    local items = rawItems
    if acqFilters.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

    -- Cell atlas paints a 1-2 px border above its bounding box, so anchoring
    -- the first row at y=0 clips the top edge against the parent. Nudge the
    -- whole grid down by 2 px to give the atlas border breathing room.
    local TOP_INSET = 2
    local xPos = 0
    local yPos = -TOP_INSET
    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 + TOP_INSET)
end

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

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

function HDG_AcquisitionTab:Show()
    local HDG_Debug = addon and addon.HDG_Debug
    local Timed = (HDG_Debug and HDG_Debug.Timed) or function(_, fn) return fn() end
    Timed("Acq.Show.total", function()
        if not acquisitionPanel then
            Timed("Acq.Show.BuildCaches", function() HDG_AcqData.BuildCaches(CONFIG) end)
            Timed("Acq.Show.CreatePanel", function() self:CreatePanel() end)
        end
        Timed("Acq.Show.panel:Show", function() acquisitionPanel:Show() end)
        Timed("Acq.Show.RefreshGuideText", function() RefreshGuideText() end)
        Timed("Acq.Show.RefreshList", function() 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
    self:SelectItem(itemID)
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

-- Collection data ready — refresh if visible.
-- EventBus is loaded by TOC order (Core/HDG_EventBus.lua before this file).
do
    local HDG = _G.HousingDecorGuide
    if HDG and HDG.EventBus then
        HDG.EventBus:Register("HDG_COLLECTION_READY", function()
            if HDG_AcquisitionTab:IsShown() then
                HDG_AcquisitionTab:RefreshList()
                -- Catalog landed after the tab was rendered: the row list now
                -- has decorInfo, but the right-side preview model and the
                -- "Also sells" gallery were bound when GetCatalogByItem still
                -- returned nil and are stuck on "Preview unavailable". Force
                -- a re-select so UpdatePreviewModel and UpdateVendorInventory
                -- run again with live catalog data. galleryVendorNPC is cleared
                -- so SelectItem rebuilds the gallery cells unconditionally.
                if selectedItemID then
                    galleryVendorNPC = nil
                    HDG_AcquisitionTab:SelectItem(selectedItemID, selectedNpcID)
                end
            end
        end)
    end
end

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