-- Housing Decor Guide - Acquisition Data & Filtering
-- Data caches, filter state, and filtering logic. No UI frames.

local addonName, addon = ...

HDG_AcqData = HDG_AcqData or {}

local _clientInterfaceVersion = select(4, (type(Build) == "table" and Build.GetBuildInfo or GetBuildInfo)())

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

-- ============================================================================
-- DIRECT DB ACCESSORS (replace redundant cache tables)
-- ============================================================================

-- sourceInfo replacement: reads HDG_AllDecorDB directly
-- AllDecorDB: [itemID] = {[1]=decorID, [2]=sourceType, [3]=sourceName, [4]=sourceDetail}
local function GetSourceInfo(itemID)
    return HDG_AllDecorDB and HDG_AllDecorDB[itemID]
end

-- vendorInfo replacement: reads HDG_VendorDB directly
-- VendorDB: [npcID] = {[1]=name, [2]=zone, [3]=mapID, [4]=x, [5]=y, [6]=faction}
local function GetVendorName(npcID)
    local v = HDG_VendorDB and HDG_VendorDB[npcID]
    return v and v[1] or ("NPC #" .. npcID)
end

local function GetVendorZone(npcID)
    local v = HDG_VendorDB and HDG_VendorDB[npcID]
    return v and v[2] or "Unknown"
end

local function GetVendorMapID(npcID)
    local v = HDG_VendorDB and HDG_VendorDB[npcID]
    return v and v[3] or 0
end

-- Expose accessors for other modules (AcqRows, AcquisitionTab)
HDG_AcqData.GetSourceInfo = GetSourceInfo
HDG_AcqData.GetVendorName = GetVendorName
HDG_AcqData.GetVendorZone = GetVendorZone
HDG_AcqData.GetVendorMapID = GetVendorMapID

-- ============================================================================
-- FILTER STATE (single table — SSoT for all filter values)
-- ============================================================================

-- Filter state and sortMode live on the consumer (HDG_AcquisitionTab). The
-- data-layer functions (RebuildFilteredItems, RebuildFilteredVendors,
-- ItemPassesFilters) take filters as a parameter so this module stays pure.

-- ============================================================================
-- DATA CACHES (built once per Show, wiped on rebuild)
-- ============================================================================

HDG_AcqData.vendorItems = {}        -- [npcID] = {itemID, ...}
HDG_AcqData.vendorRecipes = {}      -- [npcID] = {craftedItemID, ...} (recipe sales)
HDG_AcqData.vendorExpansion = {}    -- [npcID] = expansion string (computed from mapID)
HDG_AcqData.itemExpansion = {}      -- [itemID] = expansion string (for non-vendor items)
HDG_AcqData.allItems = {}           -- merged deduped acquirable itemIDs
HDG_AcqData.craftableByItem = {}    -- [itemID] = spellID
HDG_AcqData.recipeByItem = {}       -- [itemID] = DecorDB recipe table
HDG_AcqData.vendorZoneByItem = {}   -- [itemID] = first vendor's zone (for By Item "Group by zone" sort)
HDG_AcqData.uniqueZones = {}
HDG_AcqData.uniqueFactions = {}
HDG_AcqData.uniqueExpansions = {}

-- Filtered results (rebuilt per filter change)
HDG_AcqData.filteredItems = {}      -- itemIDs passing filters (By Item view)
HDG_AcqData.filteredItemCount = 0   -- distinct vendor-reachable items in vendor mode (set by RebuildFilteredVendors)
HDG_AcqData.filteredUncollectedCount = 0 -- uncollected count (computed during rebuild)
HDG_AcqData.filteredVendors = {}    -- npcIDs with matching items (By Vendor view)
HDG_AcqData.vendorCounts = {}       -- [npcID] = {total=N, collected=N}
HDG_AcqData.vendorFilteredItems = {} -- [npcID] = {itemIDs passing filters} (cached per rebuild)
HDG_AcqData.vendorFilteredRecipes = {} -- [npcID] = {craftedItemIDs passing filters}

-- Per-item static facts table populated by BuildCaches; ItemPassesFilters reads
-- from this instead of recomputing source/vendor/release flags per call.
-- See BuildItemFacts at the bottom of BuildCaches for the schema.
HDG_AcqData.itemFacts = {}

-- Per-refresh memoization for IsItemCollected. Volatile so can't live in
-- itemFacts; cleared at the start of each RebuildFilteredItems / Vendors call
-- to keep the snapshot consistent across the rebuild + downstream row binds
-- and status text in the same refresh cycle.
local collectedCache = {}
local function IsCollected(itemID)
    local c = collectedCache[itemID]
    if c == nil then
        c = HDG_Data.IsItemCollected(itemID) and true or false
        collectedCache[itemID] = c
    end
    return c
end
function HDG_AcqData.IsCollectedCached(itemID) return IsCollected(itemID) end
function HDG_AcqData.ResetCollectedCache() wipe(collectedCache) end

-- Internal: acquirable source type set (built from CONFIG)
local acquirableTypeSet = {}

-- ============================================================================
-- SOURCE TYPE HELPERS (deduplicated from 2 locations)
-- ============================================================================

-- All three lookups now read from CONFIG.SOURCE_TYPES (single source of truth
-- in HDG_Config.lua). No local copies — adding a new type is one-line.

function HDG_AcqData.GetSourceTypeLabel(srcType, CONFIG)
    local info = CONFIG and CONFIG.SOURCE_TYPES and CONFIG.SOURCE_TYPES[srcType]
    return (info and info.display) or "Source"
end

function HDG_AcqData.GetSourceTypeAtlas(srcType, CONFIG)
    local info = CONFIG and CONFIG.SOURCE_TYPES and CONFIG.SOURCE_TYPES[srcType]
    return info and info.atlas
end

function HDG_AcqData.IsAcquirableType(srcType)
    return acquirableTypeSet[srcType] or false
end

-- ============================================================================
-- BUILD CACHES (called once per Show)
-- ============================================================================

function HDG_AcqData.BuildCaches(CONFIG)
    wipe(HDG_AcqData.vendorItems)
    wipe(HDG_AcqData.vendorRecipes)
    wipe(HDG_AcqData.vendorExpansion)
    wipe(HDG_AcqData.itemExpansion)
    wipe(HDG_AcqData.craftableByItem)
    wipe(HDG_AcqData.recipeByItem)
    wipe(HDG_AcqData.vendorZoneByItem)
    wipe(HDG_AcqData.allItems)
    wipe(HDG_AcqData.uniqueZones)
    wipe(HDG_AcqData.uniqueFactions)
    wipe(HDG_AcqData.uniqueExpansions)
    wipe(HDG_AcqData.vendorCounts)

    -- Build craftableByItem and recipeByItem from DecorDB. HDG_DecorDB is
    -- keyed by a legacy decor record id, NOT the recipe spellID — read the
    -- spellID from `recipe.spellID`. The downstream `craftableByItem` cache
    -- is documented as `[itemID] = spellID`.
    if HDG_DecorDB then
        for dbKey, recipe in pairs(HDG_DecorDB) do
            if recipe.itemID and recipe.reagents and next(recipe.reagents) then
                HDG_AcqData.craftableByItem[recipe.itemID] = recipe.spellID or dbKey
                HDG_AcqData.recipeByItem[recipe.itemID] = recipe
            end
        end
    end

    -- Build vendor data from VendorDB (vendor-keyed format)
    local zoneSet = {}
    local factionSet = {}
    local expansionSet = {}
    local itemSet = {} -- [itemID] = true (dedup)
    local vendorOnlyItems = {}

    if HDG_VendorDB then
        for npcID, vEntry in pairs(HDG_VendorDB) do
            if vEntry.items or vEntry.recipes then
                local zone = vEntry[2] or "Unknown"
                local mapID = vEntry[3] or 0
                local exp = vEntry.exp or GetExpansionForMapID(mapID)
                HDG_AcqData.vendorExpansion[npcID] = exp

                if zone ~= "" and not zoneSet[zone] then
                    zoneSet[zone] = true
                    table.insert(HDG_AcqData.uniqueZones, zone)
                end

                if exp and not expansionSet[exp] then
                    expansionSet[exp] = true
                    table.insert(HDG_AcqData.uniqueExpansions, exp)
                end

                if vEntry.items then
                    HDG_AcqData.vendorItems[npcID] = {}
                    for itemID, itemEntry in pairs(vEntry.items) do
                        table.insert(HDG_AcqData.vendorItems[npcID], itemID)

                        -- First-vendor-wins zone lookup (for By Item zone-grouped sort)
                        if not HDG_AcqData.vendorZoneByItem[itemID] then
                            HDG_AcqData.vendorZoneByItem[itemID] = zone
                        end

                        if not HDG_AcqData.craftableByItem[itemID] and not itemSet[itemID] then
                            itemSet[itemID] = true
                            table.insert(vendorOnlyItems, itemID)
                        end

                        local factionName = itemEntry[4] or ""
                        if factionName ~= "" and not factionSet[factionName] then
                            factionSet[factionName] = true
                            table.insert(HDG_AcqData.uniqueFactions, factionName)
                        end
                    end
                end

                if vEntry.recipes then
                    HDG_AcqData.vendorRecipes[npcID] = {}
                    for craftedItemID, _ in pairs(vEntry.recipes) do
                        table.insert(HDG_AcqData.vendorRecipes[npcID], craftedItemID)
                    end
                end
            end
        end
    end

    table.sort(HDG_AcqData.uniqueZones)
    table.sort(HDG_AcqData.uniqueFactions)

    -- Build acquirable type set from config
    wipe(acquirableTypeSet)
    if CONFIG.ACQUIRABLE_SOURCE_TYPES then
        for _, t in ipairs(CONFIG.ACQUIRABLE_SOURCE_TYPES) do
            acquirableTypeSet[t] = true
        end
    end

    -- Merge AllDecorDB: add non-vendor acquirable items (read AllDecorDB directly, no sourceInfo copy)
    local sourceOnlyItems = {}
    local sourceOnlySet = {}

    if HDG_AllDecorDB then
        for aItemID, entry in pairs(HDG_AllDecorDB) do
            local srcType = entry[2]
            if acquirableTypeSet[srcType] and not itemSet[aItemID] then
                table.insert(sourceOnlyItems, aItemID)
                sourceOnlySet[aItemID] = true
            end
        end
    end

    -- Add craftable items not already covered by vendor or AllDecorDB paths
    local craftOnlyItems = {}
    for cItemID, _ in pairs(HDG_AcqData.craftableByItem) do
        if not itemSet[cItemID] and not sourceOnlySet[cItemID] then
            table.insert(craftOnlyItems, cItemID)
        end
    end

    -- Build itemExpansion for non-vendor items (craftable + AllDecorDB)
    local function resolveItemExpansion(itemID)
        -- Live catalog tags first (Blizzard's source of truth — handles
        -- mistagged AllDecorDB entries like the Vanquisher Trophies).
        local catalogRow = HDG_Data and HDG_Data.GetCatalogByItem and HDG_Data.GetCatalogByItem(itemID)
        if catalogRow and catalogRow.tags and HDG_ExpansionData
           and HDG_ExpansionData.GetExpansionFromCatalogTags then
            local tagExp = HDG_ExpansionData.GetExpansionFromCatalogTags(catalogRow.tags)
            if tagExp then return tagExp end
        end
        -- Craftable: use recipe expansion (normalized from "Classic Alchemy" → "Classic")
        local recipe = HDG_AcqData.recipeByItem[itemID]
        if recipe and recipe.expansion then
            local exp = NormalizeToDisplayName(recipe.expansion)
            if exp and exp ~= "Unknown" then return exp end
        end
        -- AllDecorDB: try exp field first, then zone lookup from sourceDetail
        local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
        if entry then
            if entry.exp then return entry.exp end
            local detail = entry[4]
            if detail then
                local detailMapID = ZONE_MAP_IDS[detail]
                if detailMapID then
                    local exp = GetExpansionForMapID(detailMapID)
                    if exp and exp ~= "Other" then return exp end
                end
            end
        end
        return nil
    end

    for _, itemID in ipairs(sourceOnlyItems) do
        local exp = resolveItemExpansion(itemID)
        if exp then
            HDG_AcqData.itemExpansion[itemID] = exp
            if not expansionSet[exp] then
                expansionSet[exp] = true
                table.insert(HDG_AcqData.uniqueExpansions, exp)
            end
        end
    end
    for _, itemID in ipairs(craftOnlyItems) do
        local exp = resolveItemExpansion(itemID)
        if exp then
            HDG_AcqData.itemExpansion[itemID] = exp
            if not expansionSet[exp] then
                expansionSet[exp] = true
                table.insert(HDG_AcqData.uniqueExpansions, exp)
            end
        end
    end

    -- Re-sort expansions after adding non-vendor entries
    table.sort(HDG_AcqData.uniqueExpansions, function(a, b)
        return HDG_ExpansionData.GetSortOrder(a) < HDG_ExpansionData.GetSortOrder(b)
    end)

    -- Build merged list: vendor items + source-only items + craft-only items
    -- Pre-filter DNT/AUTOGEN items so ItemPassesFilters doesn't need to check per call
    wipe(HDG_AcqData.allItems)
    local function addIfValid(id)
        local name = HDG_Data.GetCachedItemName(id) or ""
        if name:find("[DNT]", 1, true) or name:find("[AUTOGEN]", 1, true) then return end
        table.insert(HDG_AcqData.allItems, id)
    end
    for _, id in ipairs(vendorOnlyItems) do addIfValid(id) end
    for _, id in ipairs(sourceOnlyItems) do addIfValid(id) end
    for _, id in ipairs(craftOnlyItems) do addIfValid(id) end

    -- Count achievement-source items and how many are collected
    local achItemCount, achCollectedCount = 0, 0
    local ST = CONFIG.SOURCE_TYPE
    if HDG_AllDecorDB and ST then
        for itemID, entry in pairs(HDG_AllDecorDB) do
            if entry[2] == ST.ACHIEVEMENT then
                achItemCount = achItemCount + 1
                if HDG_Data and HDG_Data.IsItemCollected and HDG_Data.IsItemCollected(itemID) then
                    achCollectedCount = achCollectedCount + 1
                end
            end
        end
    end
    HDG_AcqData.achievementItemCount = achItemCount
    HDG_AcqData.achievementCollectedCount = achCollectedCount

    HDG_AcqData.BuildItemFacts(CONFIG)
end

-- Per-item facts that don't change during a session (vendor list, source type,
-- release status, source-category membership). Lets ItemPassesFilters and the
-- source matchers do indexed reads instead of HDG_AllDecorDB lookups + npcID
-- walks per call.
function HDG_AcqData.BuildItemFacts(CONFIG)
    wipe(HDG_AcqData.itemFacts)

    local ST = CONFIG.SOURCE_TYPE
    local ENDEAVOR_CURR = CONFIG.ACQUISITION_TAB.ENDEAVOR_CURRENCY_ID
    local goldDominated = {[ST.ACHIEVEMENT]=1,[ST.QUEST]=1,[ST.WORLD_QUEST]=1,[ST.PROMOTION]=1}

    for _, itemID in ipairs(HDG_AcqData.allItems) do
        local entry = HDG_AllDecorDB and HDG_AllDecorDB[itemID]
        local npcIDs = HDG_VendorByItem and HDG_VendorByItem[itemID]
        local hasVendor = npcIDs and #npcIDs > 0
        local srcType = entry and entry[2] or 0
        local altSrcType = entry and entry.alt and entry.alt[1] or 0
        -- Consider BOTH primary and alt source types: a vendor-primary item with an
        -- achievement/quest ALT (e.g. the Prey effigies -- vendor Construct Ali'a sells
        -- them for an unlisted cost, but they're achievement-gated) DOES have a real
        -- source, so the zero-cost-vendor reject below must not flag it unreleased.
        local hasSource = (acquirableTypeSet[srcType] or acquirableTypeSet[altSrcType]) and true or false
        local isCrafted = HDG_AcqData.craftableByItem[itemID] ~= nil
        local isDyeable = entry and entry.canCustomize and true or false

        -- releasedOK: ver-authoritative when present, legacy heuristics otherwise.
        -- Names use GetCachedItemName as in the original; new releases happen on
        -- patch days so the BuildCaches snapshot is fine for a session.
        local releasedOK = true
        if entry and entry.ver then
            if entry.ver > _clientInterfaceVersion then releasedOK = false end
        else
            local itemName = HDG_Data.GetCachedItemName(itemID) or ""
            if itemName == "Unreleased" or itemName == "Loading..." then releasedOK = false
            elseif HDG_AllDecorDB and not entry then releasedOK = false
            elseif entry and entry[5] and entry[5] > time() then releasedOK = false
            end
        end

        -- All-zero-cost vendor reject (only meaningful when releasedOnly is on).
        -- A "released" item rejected by this rule has every vendor selling it for
        -- zero gold/rep/cost AND has no other source AND isn't crafted.
        if releasedOK and hasVendor then
            local allZeroCost = true
            for _, npc in ipairs(npcIDs) do
                local ve = HDG_VendorDB[npc]
                local ie = ve and ve.items and ve.items[itemID]
                if ie and (ie[1] ~= 0 or ie[2] ~= 0 or ie[5]) then
                    allZeroCost = false
                    break
                end
            end
            if allZeroCost and not hasSource and not isCrafted then
                releasedOK = false
            end
        end

        -- Source-category flags. Vendor-walking matchers (Reputation, Endeavor,
        -- Gold Only) precomputed here so the source filter is a single boolean
        -- read instead of an inner loop over npcIDs.
        local isReputation, isEndeavor, isGoldOnly = false, false, false
        if hasVendor then
            local goldDominatedByThisItem = goldDominated[srcType] or goldDominated[altSrcType]
            for _, npc in ipairs(npcIDs) do
                local vendor = HDG_VendorDB[npc]
                local ie = vendor and vendor.items and vendor.items[itemID]
                if ie then
                    if not isReputation and ie[2] and ie[2] > 0 then
                        isReputation = true
                    end
                    if not isEndeavor and ie[5] and type(ie[5]) == "table" then
                        for _, t in ipairs(ie[5]) do
                            if t[1] == "c" and t[2] == ENDEAVOR_CURR then
                                isEndeavor = true
                                break
                            end
                        end
                    end
                    if not isGoldOnly and not goldDominatedByThisItem and not vendor.race then
                        if ie[1] > 0 and (not ie[2] or ie[2] == 0) and not ie[5] then
                            isGoldOnly = true
                        end
                    end
                    if isReputation and isEndeavor and isGoldOnly then break end
                end
            end
        end

        local catalogRow = HDG_Data and HDG_Data.GetCatalogByItem and HDG_Data.GetCatalogByItem(itemID)

        HDG_AcqData.itemFacts[itemID] = {
            srcType        = srcType,
            altSrcType     = altSrcType,
            hasVendor      = hasVendor or false,
            hasSource      = hasSource,
            isCrafted      = isCrafted,
            isDyeable      = isDyeable,
            releasedOK     = releasedOK,
            placementCost  = catalogRow and catalogRow.placementCost or nil,
            isAchievement  = (srcType == ST.ACHIEVEMENT or altSrcType == ST.ACHIEVEMENT),
            isQuest        = (srcType == ST.QUEST or srcType == ST.WORLD_QUEST
                              or altSrcType == ST.QUEST or altSrcType == ST.WORLD_QUEST),
            isDrop         = (srcType == ST.DROP or altSrcType == ST.DROP),
            isTreasure     = (srcType == ST.TREASURE or altSrcType == ST.TREASURE),
            isPromotional  = (srcType == ST.PROMOTION or altSrcType == ST.PROMOTION),
            isShop         = (srcType == ST.SHOP or altSrcType == ST.SHOP),
            isReputation   = isReputation,
            isEndeavor     = isEndeavor,
            isGoldOnly     = isGoldOnly,
        }
    end
end

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

-- Source filter dispatch table (built lazily after CONFIG available)
local SOURCE_MATCHERS

local function BuildSourceMatchers()
    if SOURCE_MATCHERS then return end
    -- All matchers read precomputed flags from itemFacts (populated by
    -- BuildItemFacts at the bottom of BuildCaches). The vendor-walking matchers
    -- (Reputation, Endeavor, Gold Only) used to do an npcID loop per call.
    SOURCE_MATCHERS = {
        Vendor       = function(f) return f.hasVendor end,
        Achievement  = function(f) return f.isAchievement end,
        Quest        = function(f) return f.isQuest end,
        Drop         = function(f) return f.isDrop end,
        Treasure     = function(f) return f.isTreasure end,
        Promotional  = function(f) return f.isPromotional end,
        ["In-Game Shop"] = function(f) return f.isShop end,
        Crafted      = function(f) return f.isCrafted end,
        Reputation   = function(f) return f.isReputation end,
        Endeavor     = function(f) return f.isEndeavor end,
        ["Gold Only"] = function(f) return f.isGoldOnly end,
    }
end

-- Caller (HDG_AcquisitionTab) supplies the filters table; ItemPassesFilters is
-- pure data-layer logic now and doesn't read shared module state.
function HDG_AcqData.ItemPassesFilters(itemID, filters)
    local facts = HDG_AcqData.itemFacts[itemID]
    if not facts then return false end

    -- Uncollected filter (the only per-call dynamic check besides search/filter values)
    if filters.uncollectedOnly and IsCollected(itemID) then return false end

    -- Released-only filter: precomputed by BuildItemFacts (ver vs interface
    -- version, name heuristics, all-zero-cost rejection)
    if filters.releasedOnly and not facts.releasedOK then return false end

    -- Dyeable filter
    if filters.dyeableOnly and not facts.isDyeable then return false end

    -- Search filter (uses pre-lowercased cache; search is already lowered by EditBox handler)
    if filters.search ~= "" then
        local nameLower = HDG_Data.GetCachedItemNameLower(itemID)
        if not nameLower:find(filters.search, 1, true) then return false end
    end

    -- Source filter (matchers do indexed reads on facts)
    if filters.source ~= "All" then
        BuildSourceMatchers()
        local matcher = SOURCE_MATCHERS[filters.source]
        if matcher and not matcher(facts) then return false end
    end

    -- Must have vendor, acquirable source, or be craftable
    if not facts.hasVendor and not facts.hasSource and not facts.isCrafted then return false end

    -- Non-vendor items: no rep data, so exclude when rep filter is active
    if not facts.hasVendor then
        if filters.faction ~= "All" then return false end
        if filters.zone ~= "All" then return false end
        if filters.expansion ~= "All" then
            local exp = HDG_AcqData.itemExpansion[itemID]
            if not exp or exp ~= filters.expansion then return false end
        end
        return true
    end

    -- Vendor-specific expansion/zone/faction loop. Kept dynamic because the
    -- match is per-(filter-value, npc) and pre-indexing every combination
    -- would balloon the cache. Item still benefits from the early exits in
    -- the static checks above.
    local needExp = filters.expansion ~= "All"
    local needZone = filters.zone ~= "All"
    local needFaction = filters.faction ~= "All"
    if needExp or needZone or needFaction then
        local npcIDs = HDG_VendorByItem and HDG_VendorByItem[itemID]
        local expOk, zoneOk, factionOk = not needExp, not needZone, not needFaction
        for _, npc in ipairs(npcIDs) do
            local ve = HDG_VendorDB[npc]
            if not expOk and HDG_AcqData.vendorExpansion[npc] == filters.expansion then expOk = true end
            if not zoneOk and ve and (ve[2] or "Unknown") == filters.zone then zoneOk = true end
            if not factionOk then
                local ie = ve and ve.items and ve.items[itemID]
                if ie and (ie[4] or "") == filters.faction then factionOk = true end
            end
            if expOk and zoneOk and factionOk then break end
        end
        if not expOk or not zoneOk or not factionOk then return false end
    end

    return true
end

-- ============================================================================
-- REBUILD FILTERED LISTS
-- ============================================================================

-- Hoisted sort comparators (avoid closure allocation per sort call)
local function sortByItemName(a, b)
    return (HDG_Data.GetCachedItemName(a) or "") < (HDG_Data.GetCachedItemName(b) or "")
end

local function sortByVendorName(a, b)
    return GetVendorName(a) < GetVendorName(b)
end

-- Group-by-zone for the By Item view: primary key = first vendor's zone,
-- secondary = item name. Items with no vendor zone (craft-only / drop-only)
-- collapse into a single "" bucket which sorts to the front.
local function sortByItemZone(a, b)
    local zoneA = HDG_AcqData.vendorZoneByItem[a] or ""
    local zoneB = HDG_AcqData.vendorZoneByItem[b] or ""
    if zoneA ~= zoneB then return zoneA < zoneB end
    return (HDG_Data.GetCachedItemName(a) or "") < (HDG_Data.GetCachedItemName(b) or "")
end

function HDG_AcqData.RebuildFilteredItems(filters, sortMode)
    wipe(collectedCache)
    wipe(HDG_AcqData.filteredItems)
    local uncollected = 0
    for _, itemID in ipairs(HDG_AcqData.allItems) do
        if HDG_AcqData.ItemPassesFilters(itemID, filters) then
            table.insert(HDG_AcqData.filteredItems, itemID)
            if not IsCollected(itemID) then uncollected = uncollected + 1 end
        end
    end
    HDG_AcqData.filteredUncollectedCount = uncollected
    local comparator = (sortMode == "zone") and sortByItemZone or sortByItemName
    table.sort(HDG_AcqData.filteredItems, comparator)
end

-- Recipe rows: gated by expansion/zone/search/uncollected, NOT the source-type set.
-- 'collected' for a recipe = recipe-known on ANY character (GetCharactersWhoKnowRecipe), not decor-collected.
-- vendorNameMatched: when the vendor's name matched the search, show its recipes regardless.
function HDG_AcqData.RecipePassesFilters(craftedItemID, npcID, filters, vendorNameMatched)
    if filters.expansion ~= "All" and HDG_AcqData.vendorExpansion[npcID] ~= filters.expansion then return false end
    if filters.zone ~= "All" and GetVendorZone(npcID) ~= filters.zone then return false end
    if filters.uncollectedOnly then
        local ve = HDG_VendorDB[npcID]
        local rEntry = ve and ve.recipes and ve.recipes[craftedItemID]
        if rEntry and rEntry.recipeID and (#HDG_Data.GetCharactersWhoKnowRecipe(rEntry.recipeID) > 0) then return false end
    end
    if filters.search ~= "" and not vendorNameMatched then
        local nameLower = HDG_Data.GetCachedItemNameLower(craftedItemID)
        if not (nameLower and nameLower:find(filters.search, 1, true)) then return false end
    end
    return true
end

function HDG_AcqData.RebuildFilteredVendors(filters)
    wipe(collectedCache)
    wipe(HDG_AcqData.filteredVendors)
    wipe(HDG_AcqData.vendorCounts)
    wipe(HDG_AcqData.vendorFilteredItems)
    wipe(HDG_AcqData.vendorFilteredRecipes)

    -- Union of vendors that sell items OR recipes (recipe-only vendors have no items)
    local consider = {}
    for npcID in pairs(HDG_AcqData.vendorItems) do consider[npcID] = true end
    for npcID in pairs(HDG_AcqData.vendorRecipes) do consider[npcID] = true end

    for npcID in pairs(consider) do
        local items = HDG_AcqData.vendorItems[npcID] or {}
        local vExp = HDG_AcqData.vendorExpansion[npcID]
        local vZone = GetVendorZone(npcID)

        -- Expansion/zone filter: skip vendors not matching
        if filters.expansion ~= "All" and vExp ~= filters.expansion then
            -- skip
        elseif filters.zone ~= "All" and vZone ~= filters.zone then
            -- skip
        else
            -- Check vendor name match upfront (cheap string op)
            local vendorNameMatch = false
            if filters.search ~= "" then
                local vName = GetVendorName(npcID)
                vendorNameMatch = vName:lower():find(filters.search, 1, true) ~= nil
            end
            local nameMatchedSearch = vendorNameMatch   -- before item logic mutates it (for recipe search)

            -- Single pass: if vendor name matches search, filter without search and check search inline
            local total, collected = 0, 0
            local filteredList = {}

            if vendorNameMatch then
                local savedSearch = filters.search
                filters.search = ""
                local totalWithSearch, collectedWithSearch = 0, 0
                local searchList = {}
                for _, itemID in ipairs(items) do
                    if HDG_AcqData.ItemPassesFilters(itemID, filters) then
                        local ic = IsCollected(itemID)
                        total = total + 1
                        if ic then collected = collected + 1 end
                        table.insert(filteredList, itemID)
                        local nameLower = HDG_Data.GetCachedItemNameLower(itemID)
                        if nameLower:find(savedSearch, 1, true) then
                            totalWithSearch = totalWithSearch + 1
                            if ic then collectedWithSearch = collectedWithSearch + 1 end
                            table.insert(searchList, itemID)
                        end
                    end
                end
                filters.search = savedSearch
                if totalWithSearch > 0 then
                    -- Items matched search — use search-filtered counts/list
                    total, collected = totalWithSearch, collectedWithSearch
                    filteredList = searchList
                    vendorNameMatch = false
                end
            else
                for _, itemID in ipairs(items) do
                    if HDG_AcqData.ItemPassesFilters(itemID, filters) then
                        total = total + 1
                        if IsCollected(itemID) then collected = collected + 1 end
                        table.insert(filteredList, itemID)
                    end
                end
            end

            -- Recipe rows (By-Vendor only; gated by the Recipes filter)
            local recipeList = {}
            local recipeKnown = 0
            if filters.showRecipes then
                for _, craftedItemID in ipairs(HDG_AcqData.vendorRecipes[npcID] or {}) do
                    if HDG_AcqData.RecipePassesFilters(craftedItemID, npcID, filters, nameMatchedSearch) then
                        table.insert(recipeList, craftedItemID)
                        local ve = HDG_VendorDB[npcID]
                        local rEntry = ve and ve.recipes and ve.recipes[craftedItemID]
                        if rEntry and rEntry.recipeID and (#HDG_Data.GetCharactersWhoKnowRecipe(rEntry.recipeID) > 0) then
                            recipeKnown = recipeKnown + 1
                        end
                    end
                end
            end

            local hasMatch = (total > 0) or (#recipeList > 0)
            if hasMatch then
                table.insert(HDG_AcqData.filteredVendors, npcID)
                HDG_AcqData.vendorFilteredItems[npcID] = filteredList
                HDG_AcqData.vendorFilteredRecipes[npcID] = recipeList
            end

            -- Vendor header count. Normal view counts items only; under the Recipes
            -- filter (source == "Crafted") the recipes are counted in too.
            local dispTotal, dispCollected = total, collected
            if filters.source == "Crafted" then
                dispTotal = total + #recipeList
                dispCollected = collected + recipeKnown
            end
            HDG_AcqData.vendorCounts[npcID] = { total = dispTotal, collected = dispCollected,
                recipeTotal = #recipeList, recipeKnown = recipeKnown, nameMatch = vendorNameMatch }
        end
    end

    -- Sort by vendor name
    table.sort(HDG_AcqData.filteredVendors, sortByVendorName)

    -- Distinct count of vendor-reachable items passing filters (used by status bar / header)
    local seen, count = {}, 0
    for _, list in pairs(HDG_AcqData.vendorFilteredItems) do
        for _, id in ipairs(list) do
            if not seen[id] then
                seen[id] = true
                count = count + 1
            end
        end
    end
    HDG_AcqData.filteredItemCount = count
end
