-- Smoke: Acquisition tab + chrome tab strip.
-- Verifies the multi-tab architecture, master/detail selectors, and the
-- search/selection dispatch paths.

dofile("wow_mock.lua")
dofile("../Core/HDGR_SchemeConstants.lua")
dofile("../Core/HDGR_Constants.lua")
dofile("../Core/HDGR_Format.lua")
dofile("../Core/HDGR_Paths.lua")
dofile("../Core/HDGR_Log.lua")
dofile("../Core/HDGR_Store.lua")
-- LayoutConfig is needed by the chrome.activeTab selector (it consults
-- window.views[X] to confirm the saved view is real).
dofile("../UI/HDGR_LayoutConfig.lua")
dofile("../UI/LayoutConfig/HDGR_LayoutConfig_Decor.lua")
dofile("../UI/LayoutConfig/HDGR_LayoutConfig_Acquisition.lua")
dofile("../UI/LayoutConfig/HDGR_LayoutConfig_Recipes.lua")
dofile("../UI/LayoutConfig/HDGR_LayoutConfig_Alts.lua")
dofile("../UI/LayoutConfig/HDGR_LayoutConfig_Mogul.lua")
dofile("../UI/LayoutConfig/HDGR_LayoutConfig_Styles.lua")
dofile("../UI/LayoutConfig/HDGR_LayoutConfig_Trainers.lua")
dofile("../UI/LayoutConfig/HDGR_LayoutConfig_HouseTab.lua")
dofile("../UI/LayoutConfig/HDGR_LayoutConfig_Debug.lua")
dofile("../UI/LayoutConfig/HDGR_LayoutConfig_Config.lua")
-- Theme + Expansion: needed by acq.detailTitle / decor.selectedItem.*Label /
-- recipes.allRecipes selectors (HDG.Theme:GetTextStateColorToken,
-- HDG.Theme:ColorCode, HDG.Expansion.GetShort). Added after this test was
-- written; load post-Constants pre-selectors.
dofile("../UI/HDGR_Theme.lua")
dofile("../Core/HDGR_Expansion.lua")
dofile("../UI/HDGR_Selectors.lua")
dofile("../UI/HDGR_Selectors_Status.lua")
dofile("../UI/HDGR_Selectors_Decor.lua")
-- decor.allItems calls HDG.ItemNameResolver:ResolveIcon strictly when the
-- catalog row is cold. Cookbook 10: tests stub HDG.<Module> rather than
-- loading the full module + its deps. Real resolver is dofile'd in
-- test_observers_smoke (with the dep chain) when its own behaviour is under
-- test; here we just need the API surface present.
HDG.ItemNameResolver = HDG.ItemNameResolver or {
    ResolveIcon = function() return nil end,
    ResolveName = function() return nil end,
}

-- Phase 3b: acq.selectedItem now reads HousingCatalogObserver instead of
-- AllDecorDB. Stub the observer so that after the catalog is marked ready,
-- GetRow() synthesizes a minimal row from AllDecorDB for any known itemID.
-- This keeps the smoke test green without loading the full observer module
-- or duplicating its logic. byItemID is populated lazily at first call so
-- the stub can live here (before AllDecorDB is loaded) while still reading
-- the live data once the DB file is dofile'd below.
HDG.HousingCatalogObserver = HDG.HousingCatalogObserver or {}
local _obs = HDG.HousingCatalogObserver
_obs.byItemID     = _obs.byItemID     or {}
_obs.tagIDToGroup = _obs.tagIDToGroup or {}

function _obs:IsReady()
    local s = HDG.Store:GetState().session.catalog
    return s and s.status == "ready"
end
-- Canonical IsOwned helper stub (mirrors production formula).
function _obs:IsOwned(rowOrID)
    local row
    if type(rowOrID) == "table" then row = rowOrID
    elseif type(rowOrID) == "number" then row = _obs.byItemID[rowOrID] end
    if not row then return false end
    return ((row.quantity or 0) + (row.remainingRedeemable or 0) + (row.numPlaced or 0)) > 0
end

function _obs:GetRow(itemID)
    if not self:IsReady() then return nil end
    -- Synthesize a minimal row from AllDecorDB for smoke-test coverage.
    -- The real observer BuildRow is under test in test_observers_smoke;
    -- here we need enough fields for:
    --   acq.selectedItem (name, iconTexture, collected, vendors, etc.)
    --   acq.allVendors reps-set build (factionGate.factionName)
    local ad = _G.HDGR_AllDecorDB and _G.HDGR_AllDecorDB[itemID]
    if not ad then return nil end
    -- Phase 3c: derive factionGate from VendorDB item[4] so the reps set
    -- builds correctly without the real catalog sweep.
    local gate = nil
    local vendorDB = _G.HDGR_VendorDB
    if vendorDB then
        for _, vrow in pairs(vendorDB) do
            if type(vrow) == "table" and type(vrow.items) == "table" then
                local idata = vrow.items[itemID]
                if idata and type(idata[4]) == "string" and idata[4] ~= "" then
                    gate = { factionName = idata[4], standing = "" }
                    break
                end
            end
        end
    end
    return {
        itemID            = itemID,
        decorID           = ad[1],
        name              = ad.name or "?",
        iconTexture       = 0,
        collected         = false,
        isAllowedIndoors  = true,
        isAllowedOutdoors = true,
        placementCost     = 0,
        quality           = nil,
        quest             = nil,
        achievement       = nil,
        vendors           = {},
        dataTagsByID      = {},
        factionGate       = gate,
        category          = nil,
    }
end

function _obs:GetExpansionForItem(itemID)
    local ad = _G.HDGR_AllDecorDB and _G.HDGR_AllDecorDB[itemID]
    return ad and ad.exp
end

function _obs:IterateRows(fn)
    if not self:IsReady() then return end
    -- If byItemID is pre-populated (e.g. from a real sweep), use it;
    -- otherwise synthesize minimal rows from AllDecorDB so acq.allItems
    -- returns items in the smoke test without a real catalog sweep.
    if next(self.byItemID) then
        for id, row in pairs(self.byItemID) do fn(id, row) end
    elseif _G.HDGR_AllDecorDB then
        for itemID, ad in pairs(_G.HDGR_AllDecorDB) do
            fn(itemID, {
                itemID   = itemID,
                decorID  = ad[1],
                name     = ad.name or "?",
                vendors  = {},
                isOwned  = false,
            })
        end
    end
end

dofile("../UI/HDGR_Selectors_Acquisition.lua")
dofile("../UI/HDGR_Selectors_Recipes.lua")
dofile("../data/HDGR_DecorDB.lua")
-- AllDecorDB + VendorDB are retired (archived). Inline fixtures replace them.
-- AllDecorDB: 112 rows (>100 required by acq.allItems sanity), including
-- chairs for search, names for sort-check. VendorDB: 12 vendors (>10 required),
-- covering A/H/N factions + Stranglethorn zone + rep-gated items.
do
    local db = {}
    -- Synthetic rows 240001..240050 tagged "Classic" (used by vendor items below
    -- so GetExpansionForItem returns "Classic" for expansion-filter tests).
    for i = 1, 50 do
        db[240000 + i] = {
            i * 10, 5, "Classic Vendor", "Classic Zone", 1769904000,
            exp = "Classic", name = "Classic Decor " .. i, placementCost = 1, quality = 1,
        }
    end
    -- Synthetic rows 240051..240110 tagged "Midnight".
    for i = 51, 110 do
        db[240000 + i] = {
            i * 10, 5, "Midnight Vendor", "Midnight Zone", 1769904000,
            exp = "Midnight", name = "Midnight Decor " .. i, placementCost = 1, quality = 1,
        }
    end
    -- Specific rows referenced by name or predicate in later assertions.
    db[235523] = {494, 2, "Decor Treasure Hunt", "Founder's Point", 1769904000,
                  exp = "Midnight", name = "Sturdy Wooden Chair",
                  placementCost = 1, quality = 1, canCustomize = true}
    db[235633] = {498, 5, "\"High Tides\" Ren", "Razorwind Shores", 1769904000,
                  exp = "Midnight", name = "Circular Woolen Rug",
                  placementCost = 3, quality = 1, canCustomize = true}
    db[238860] = {676, 5, "Selfira Ambergrove", "Val'sharah", 1769904000,
                  exp = "Legion", name = "Kaldorei High-Back Chair",
                  placementCost = 3, quality = 2}
    db[238863] = {679, 5, "Hilseth Travelstride", "Val'sharah", 1769904000,
                  exp = "Legion", name = "Kaldorei Desk",
                  placementCost = 3, quality = 2}
    _G.HDGR_AllDecorDB = db
end
do
    -- 12 vendor entries (>10 distinct names after deduplication).
    -- Factions: mix of A/H/N. Stranglethorn zone for search test.
    -- Rep-gated items (factionName != "") for repMenu test.
    -- Vendor items use AllDecorDB IDs so GetExpansionForItem returns a real value.
    _G.HDGR_VendorDB = {
        [1247]  = {"Innkeeper Belm",       "Dun Morogh",             27,  54.4, 50.8, "A", exp="Classic",
            items = { [240001] = {8000000, 0, 0, ""}, [240002] = {8000000, 0, 0, ""} }},
        [1465]  = {"Drac Roughcut",         "Loch Modan",             48,  34.8, 49.2, "A", exp="Classic",
            items = { [240003] = {3000000, 0, 0, ""} }},
        [2140]  = {"Edwin Harly",           "Silverpine Forest",      21,  44,   39.8, "H", exp="Classic",
            items = { [240004] = {1500000, 0, 0, ""} }},
        [2483]  = {"Jaquilina Dramet",      "Northern Stranglethorn", 50,  43.6, 23.0, "N", exp="Classic",
            items = { [240005] = {4500000, 0, 0, ""} }},
        [3178]  = {"Stuart Fleming",        "Wetlands",               56,   6.2, 57.4, "A", exp="Classic",
            items = { [240006] = {2000000, 0, 0, ""} }},
        [13217] = {"Thanthaldis Snowgleam", "Hillsbrad Foothills",    25,  44.6, 46.4, "A", exp="Classic",
            items = { [240007] = {0, 0, 0, "", {{"i", 137642, 1}}} }},
        [14624] = {"Master Smith Burninate","Searing Gorge",          32,  38.4, 28.4, "N", exp="Classic",
            items = { [240008] = {1500000, 0, 0, "Thorium Brotherhood"},  -- rep-gated
                      [240009] = {7000000, 0, 0, "Thorium Brotherhood"} }}, -- rep-gated
        [16528] = {"Provisioner Vredigar",  "Ghostlands",             95,  47.4, 32.0, "H", exp="Classic",
            items = { [240010] = {50000000, 922, 8, "Tranquillien"},   -- rep-gated
                      [240011] = {50000000, 922, 8, "Tranquillien"} }}, -- rep-gated
        [25206] = {"Ahlurglgr",             "Borean Tundra",         114,  43,   13.8, "N", exp="Classic",
            items = { [240012] = {0, 1212, 8, "Kirin Tor"} }},          -- rep-gated
        [40011] = {"Supplier Xantiri",      "Dalaran (Legion)",      627,  49.6, 68.4, "N", exp="Classic",
            items = { [240013] = {0, 1168, 7, "Argussian Reach"} }},    -- rep-gated
        [52301] = {"Commander Oxheart",     "Zuldazar",              862,  47.2, 13.4, "H", exp="Classic",
            items = { [240014] = {0, 2400, 7, "Zandalari Empire"},   -- rep-gated
                      [240015] = {0, 2400, 8, "Zandalari Empire"} }}, -- rep-gated
        [60112] = {"Provisioner Anza",      "Oribos",               1669,  45.2, 59.6, "N", exp="Classic",
            items = { [240016] = {5000000, 0, 0, ""} }},
    }
end
-- StaticData facade — selectors call HDG.StaticData.<Domain>:<accessor>(...)
-- per ADR-003c. Must load AFTER the data files (it asserts they're present
-- at first access) and AFTER Selectors (Selectors registration order is
-- not affected, but consumers calling StaticData live here).
dofile("../Modules/HDGR_StaticData.lua")

-- Phase 3b: acq.selectedItem calls StaticData.VendorAugment + ItemAugment.
-- Phase 3c: acq.allVendors calls GetAllVendorNames / GetItemsByVendor on the observer.
-- These data files are not loaded in this smoke test (they are tested in
-- test_vendor_augment / test_item_augment). Override the accessors to be
-- no-ops so the selector resolves without crashing on missing data globals.
HDG.StaticData.VendorAugment.Get         = function() return nil end
HDG.StaticData.VendorAugment.ResolveName = function() return nil end
HDG.StaticData.ItemAugment.Get           = function() return nil end

-- Phase 3c: Stub GetAllVendorNames + GetItemsByVendor so acq.allVendors can
-- run without the real catalog sweep. Derived from VendorDB so the name
-- list and item counts mirror what the production observer would build.
-- Only active when observer IsReady() -- caller gates on that.
do
    local vendorDB = _G.HDGR_VendorDB
    local names, byVendor = {}, {}
    if type(vendorDB) == "table" then
        for npcID, row in pairs(vendorDB) do
            if type(row) == "table" and type(row[1]) == "string" then
                local name = row[1]
                if not byVendor[name] then
                    -- acq.allVendors reads catalogVendor.name off the entry (real
                    -- observer _indexVendor stamps it); stub must carry it too.
                    byVendor[name] = { name = name, items = {}, zone = row[2] or "", faction = "", standing = "" }
                    table.insert(names, name)
                end
                if type(row.items) == "table" then
                    for itemID in pairs(row.items) do
                        table.insert(byVendor[name].items, itemID)
                    end
                end
            end
        end
        table.sort(names)
    end
    -- Expose byVendor directly on _obs so acq.selected.items can index
    -- HDG.HousingCatalogObserver.byVendor[vendorName] without going
    -- through the GetItemsByVendor method call.
    _obs.byVendor = byVendor
    -- Seed byItemID from ALL AllDecorDB rows so that IterateRows (which
    -- uses byItemID when non-empty) returns the full fixture universe for
    -- acq.allItems. Vendor-specific rows are then overlaid below.
    local allDecorDB = _G.HDGR_AllDecorDB
    if type(allDecorDB) == "table" then
        for itemID, ad in pairs(allDecorDB) do
            if type(ad) == "table" then
                _obs.byItemID[itemID] = {
                    itemID            = itemID,
                    decorID           = ad[1] or 0,
                    name              = ad.name or ("Item " .. itemID),
                    iconTexture       = 0,
                    iconAtlas         = nil,
                    isOwned           = false,
                    quantity          = 0,
                    numPlaced         = 0,
                    isAllowedIndoors  = true,
                    isAllowedOutdoors = true,
                    placementCost     = ad.placementCost or 0,
                    vendors           = {},
                    dataTagsByID      = {},
                    factionGate       = nil,
                }
            end
        end
    end
    -- Also seed byItemID for vendor items that aren't in AllDecorDB.
    for _, entry in pairs(byVendor) do
        for _, itemID in ipairs(entry.items) do
            if not _obs.byItemID[itemID] then
                local ad = allDecorDB and allDecorDB[itemID]
                _obs.byItemID[itemID] = {
                    itemID            = itemID,
                    decorID           = ad and ad[1] or 0,
                    name              = (ad and ad.name) or ("Item " .. itemID),
                    iconTexture       = 0,
                    iconAtlas         = nil,
                    isOwned           = false,
                    quantity          = 0,
                    numPlaced         = 0,
                    isAllowedIndoors  = true,
                    isAllowedOutdoors = true,
                    placementCost     = 0,
                    vendors           = {},
                    dataTagsByID      = {},
                    factionGate       = nil,
                }
            end
        end
    end
    function _obs:GetAllVendorNames()
        if not self:IsReady() then return {} end
        return names
    end
    function _obs:GetItemsByVendor(vendorName)
        if not self:IsReady() then return nil end
        return byVendor[vendorName]
    end
end

local function assertEq(actual, expected, label)
    if actual ~= expected then
        error(label .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual), 2)
    end
end
local function assertTrue(v, label) if not v then error(label, 2) end end
local function assertNil(v, label) if v ~= nil then error(label .. ": expected nil, got " .. tostring(v), 2) end end

HDG_DB = {}
HDG.Store:LoadFromSavedVariables()
local state = HDG.Store:GetState()

-- ===== Tab system ============================================================

-- Default tab is "decor" per Store NewAccountUI.
assertEq(state.account.ui.view, "decor", "default view is decor")
assertEq(HDG.Selectors:Call("chrome.activeTab", state, {}), "decor", "chrome.activeTab reads state")
assertEq(HDG.Selectors:Call("chrome.isTabActive_decor", state, {}), true, "decor tab active at start")
assertEq(HDG.Selectors:Call("chrome.isTabActive_acquisition", state, {}), false, "acquisition tab inactive")

-- Dispatch tab switch via UI_SET_PERSISTENT (matches ChromeController's path).
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_PERSISTENT,
    payload = { key = "view", value = "acquisition" },
})
assertEq(state.account.ui.view, "acquisition", "dispatch flipped view")
assertEq(HDG.Selectors:Call("chrome.activeTab", state, {}), "acquisition", "activeTab tracks state")
assertEq(HDG.Selectors:Call("chrome.isTabActive_decor", state, {}), false, "decor inactive after switch")
assertEq(HDG.Selectors:Call("chrome.isTabActive_acquisition", state, {}), true, "acquisition active after switch")

-- An UNKNOWN view name falls back to defaultView. State holds the bogus
-- value (persists across reload) but selectors return the default so the
-- pipeline doesn't crash on a corrupt SavedVars.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_PERSISTENT,
    payload = { key = "view", value = "nonexistent" },
})
assertEq(HDG.Selectors:Call("chrome.activeTab", state, {}), "decor",
    "unknown view falls back to defaultView")

-- ===== Acquisition data + selectors =========================================

-- Phase 3c: acq.allVendors reads HousingCatalogObserver. Mark catalog ready
-- now so the vendor selectors return data throughout the acquisition section.
-- (Phase 3b moved it later; 3c moves it earlier because allVendors needs it.)
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.CATALOG_LOAD_COMPLETED,
    payload = { loadedAt = 0, itemCount = 0, vendorCount = 0, generation = 1 },
})

local all = HDG.Selectors:Call("acq.allVendors", state, {})
-- Phase 3c: allVendors now returns unique vendor NAMES from the catalog
-- (deduplicated), not one row per npcID. Unique names <= VendorDB entry count.
local total = #all
assertTrue(total > 10, "acq.allVendors returns >10 vendors (got " .. total .. ")")

-- Sorted alphabetical by name.
for i = 2, math.min(#all, 50) do
    assertTrue((all[i-1].name or "") <= (all[i].name or ""),
        string.format("vendors sorted (i=%d: %q vs %q)", i, all[i-1].name, all[i].name))
end

-- Each record has the row-factory-required fields.
-- Phase 3c: npcID may be nil (VendorAugment stub returns nil; real data
-- resolves npcID for known vendors). The smoke test overrides VendorAugment
-- to nil, so npcID=nil is expected here.
local first = all[1]
assertTrue(type(first.name)      == "string", "name is string")
assertTrue(type(first.zone)      == "string" or first.zone == nil, "zone is string or nil")
assertTrue(type(first.faction)   == "string", "faction is string")
assertTrue(type(first.itemCount) == "number", "itemCount is number")
assertTrue(first.canWaypoint == false, "canWaypoint false when VendorAugment returns nil")

-- Faction: Phase 3c falls back to "Neutral" (factionRaw=N) when VendorAugment
-- returns nil. Old VendorDB path could emit "?" for missing tags; catalog
-- path always maps through FACTION_LABEL with N as default.
for _, v in ipairs(all) do
    assertTrue(
        v.faction == "Alliance" or v.faction == "Horde" or v.faction == "Neutral",
        "faction is Alliance/Horde/Neutral (got " .. tostring(v.faction) .. ")")
end

-- Search filter: empty query -> full list.
assertEq(HDG.Selectors:Call("acq.filterQuery", state, {}), "", "filter default empty")
assertEq(#HDG.Selectors:Call("acq.vendors", state, {}), #all, "vendors == all when unfiltered")

-- Search by zone substring (Vendor DB has zones like "Loch Modan",
-- "Silverpine Forest", etc.). Use "Stranglethorn" -- DB has at least one.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "searchQuery", value = "Stranglethorn" },
})
local zoneFiltered = HDG.Selectors:Call("acq.vendors", state, {})
assertTrue(#zoneFiltered > 0, "Stranglethorn match yields >0 results")
for _, v in ipairs(zoneFiltered) do
    assertTrue(
        v.name:lower():find("stranglethorn", 1, true)
        or (v.zone and v.zone:lower():find("stranglethorn", 1, true)),
        "filter match (name or zone): " .. v.name .. " / " .. v.zone)
end

-- Count label: "X of Y vendors" when filter active.
assertEq(HDG.Selectors:Call("acq.countLabel", state, {}),
    string.format("%d of %d vendors", #zoneFiltered, total),
    "countLabel filtered shape")

-- Clear filter -> full count, "X vendors" singular handling.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "searchQuery", value = "" },
})
assertEq(HDG.Selectors:Call("acq.countLabel", state, {}),
    string.format("%d vendors", total),
    "countLabel unfiltered shape")

-- ===== Master/detail =========================================================

-- No selection at start.
assertNil(HDG.Selectors:Call("acq.selectedNpcID",  state, {}), "no selectedNpcID at start")
assertNil(HDG.Selectors:Call("acq.selectedVendor", state, {}), "no selectedVendor at start")

-- Phase 3c: acq.selectedVendor looks up by npcID in acq.allVendors.
-- The smoke test stubs VendorAugment to return nil for all vendors.
-- Register 3 vendors with real npcIDs + distinct factions (N, H, A) so the
-- faction-atlas + faction-filter smoke tests still pass. Use vendors from the
-- sorted all[] list so the names are guaranteed to exist in the stub byVendor.
local firstVendorName  = all[1] and all[1].name or "VendorN"
local secondVendorName = all[2] and all[2].name or "VendorH"
local thirdVendorName  = all[3] and all[3].name or "VendorA"
HDG.StaticData.VendorAugment.ResolveName = function(_, name, zone)
    if name == firstVendorName  then return 88888 end
    if name == secondVendorName then return 88889 end
    if name == thirdVendorName  then return 88890 end
    return nil
end
HDG.StaticData.VendorAugment.Get = function(_, npcID)
    if npcID == 88888 then
        return { name = firstVendorName,  zone = "Test Zone",    mapID = 1,
                 x = 50.0, y = 50.0, faction = "N", expansion = "Test Expansion" }
    end
    if npcID == 88889 then
        return { name = secondVendorName, zone = "Orgrimmar",     mapID = 85,
                 x = 55.0, y = 65.0, faction = "H", expansion = "Classic" }
    end
    if npcID == 88890 then
        return { name = thirdVendorName,  zone = "Stormwind",     mapID = 84,
                 x = 45.0, y = 55.0, faction = "A", expansion = "Classic" }
    end
    return nil
end
-- Bump generation so acq.allVendors re-runs with updated VendorAugment.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.CATALOG_LOAD_COMPLETED,
    payload = { loadedAt = 0, itemCount = 0, vendorCount = 0, generation = 2 },
})
state = HDG.Store:GetState()
all   = HDG.Selectors:Call("acq.allVendors", state, {})
total = #all

-- Dispatch selection using the real npcID for firstVendorName.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedNpcID", value = 88888 },
})
assertEq(HDG.Selectors:Call("acq.selectedNpcID", state, {}), 88888,
    "selectedNpcID reads dispatched state")
local selected = HDG.Selectors:Call("acq.selectedVendor", state, {})
assertTrue(selected ~= nil, "selectedVendor resolves")
assertEq(selected.npcID, 88888, "selectedVendor matches target npcID")
assertEq(selected.name, firstVendorName, "selectedVendor name matches")

-- Pointing at a missing npcID resolves to nil.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedNpcID", value = -999999 },
})
assertNil(HDG.Selectors:Call("acq.selectedVendor", state, {}), "missing npcID -> nil")

-- ============================================================================
-- Faction dropdown
-- ============================================================================

assertEq(HDG.Selectors:Call("acq.factionFilter", state, {}), "all",
    "factionFilter defaults to all")
-- factionLabel selector retired with the WowStyle1Dropdown migration; the
-- trigger label now auto-renders from the menu items' radio text. Verify the
-- menu item shape directly instead.
local menu = HDG.Selectors:Call("acq.factionMenuItems", state, {})
assertTrue(#menu == 4, "menu has 4 items (all + 3 factions)")
assertEq(menu[1].value, "all", "first menu item is 'all'")
assertEq(menu[1].text, "All factions", "first menu item display text is 'All factions'")

-- Apply faction filter -> vendor list shrinks to that faction only.
-- Phase 3c: VendorAugment is stubbed to nil (except firstVendorName = "N"),
-- so all vendors fall back to faction="Neutral". Use "Neutral" for the filter test.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "factionFilter", value = "Neutral" },
})
assertEq(HDG.Selectors:Call("acq.factionFilter", state, {}), "Neutral",
    "factionFilter reads dispatched value")
local neutralOnly = HDG.Selectors:Call("acq.vendors", state, {})
assertTrue(#neutralOnly > 0, "Neutral filter yields some vendors")
for _, v in ipairs(neutralOnly) do
    assertEq(v.faction, "Neutral", "Neutral-only result: " .. v.name)
end

-- "Horde" faction filter: stub has exactly 1 Horde vendor (npcID 88889).
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "factionFilter", value = "Horde" },
})
local hordeOnly = HDG.Selectors:Call("acq.vendors", state, {})
assertEq(#hordeOnly, 1, "Horde filter yields 1 result (stub has 1 Horde vendor)")
assertEq(hordeOnly[1].faction, "Horde", "Horde filter result faction is Horde")

-- Search + faction AND together: searching "wood" while Neutral-only
-- yields the intersection (case-insensitive name/zone match AND Neutral).
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "factionFilter", value = "Neutral" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "searchQuery", value = "wood" },
})
local intersected = HDG.Selectors:Call("acq.vendors", state, {})
for _, v in ipairs(intersected) do
    assertEq(v.faction, "Neutral", "intersect Neutral: " .. v.name)
    local matched = v.name:lower():find("wood", 1, true)
        or (v.zone and v.zone:lower():find("wood", 1, true))
    assertTrue(matched, "intersect name/zone match: " .. v.name .. " / " .. tostring(v.zone))
end

-- Clearing both filters restores full vendor list.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "searchQuery", value = "" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "factionFilter", value = "all" },
})
assertEq(#HDG.Selectors:Call("acq.vendors", state, {}), total,
    "both filters cleared -> full list")

-- ============================================================================
-- View toggle (Shop by Vendor / Find by Item)
-- ============================================================================

-- Default viewMode is "vendor".
assertEq(HDG.Selectors:Call("acq.viewMode", state, {}), "vendor", "viewMode default")
assertEq(HDG.Selectors:Call("acq.isViewMode_vendor", state, {}), true, "vendor active default")
assertEq(HDG.Selectors:Call("acq.isViewMode_item",   state, {}), false, "item inactive default")

-- countLabel in vendor mode -> "X vendors"
assertEq(HDG.Selectors:Call("acq.countLabel", state, {}),
    string.format("%d vendors", total), "vendor-mode countLabel")

-- Items selector returns sorted list from AllDecorDB (Phase 10a migration).
assertTrue(type(_G.HDGR_AllDecorDB) == "table", "AllDecorDB loaded for item view")
local itemsAll = HDG.Selectors:Call("acq.allItems", state, {})
assertTrue(#itemsAll > 100, "acq.allItems has many items (got " .. #itemsAll .. ")")
-- AllDecorDB rows carry sourceType + sourceName for preset filters.
assertTrue(type(itemsAll[1].sourceType) == "number", "acq.allItems rows carry sourceType")
for i = 2, math.min(#itemsAll, 30) do
    assertTrue((itemsAll[i-1].name or "") <= (itemsAll[i].name or ""),
        string.format("items sorted (i=%d)", i))
end

-- Switch to item view.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "viewMode", value = "item" },
})
assertEq(HDG.Selectors:Call("acq.viewMode", state, {}), "item", "switched to item view")
assertEq(HDG.Selectors:Call("acq.isViewMode_vendor", state, {}), false, "vendor inactive")
assertEq(HDG.Selectors:Call("acq.isViewMode_item",   state, {}), true,  "item active")

-- countLabel pivots to "X items" in item mode.
local totalItems = #itemsAll
assertEq(HDG.Selectors:Call("acq.countLabel", state, {}),
    string.format("%d items", totalItems), "item-mode countLabel")

-- Search applies to items (name/profession/expansion). "chair" should
-- match items with "Chair" in the name (housing decor from AllDecorDB).
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "searchQuery", value = "chair" },
})
local searched = HDG.Selectors:Call("acq.items", state, {})
assertTrue(#searched > 0, "search 'chair' yields >0 items")
assertTrue(#searched < totalItems, "search narrows list")
for _, item in ipairs(searched) do
    local matched = item.name:lower():find("chair", 1, true)
        or (item.profession and item.profession:lower():find("chair", 1, true))
        or (item.expansion  and item.expansion:lower():find("chair", 1, true))
        or (item.sourceName and item.sourceName:lower():find("chair", 1, true))
    assertTrue(matched, "item matches search: " .. item.name)
end

-- countLabel in item mode w/ filter -> "X of Y items"
assertEq(HDG.Selectors:Call("acq.countLabel", state, {}),
    string.format("%d of %d items", #searched, totalItems),
    "item-mode filtered countLabel")

-- Bogus viewMode value falls back to "vendor" at the selector boundary.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "viewMode", value = "bogus" },
})
assertEq(HDG.Selectors:Call("acq.viewMode", state, {}), "vendor",
    "unknown viewMode falls back to vendor")

-- ===== Title selectors flip with viewMode =================================

-- Reset to clean state for title + item-detail tests.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "viewMode", value = "vendor" },
})
assertEq(HDG.Selectors:Call("acq.listTitle",   state, {}), "Vendors",         "listTitle in vendor mode")
-- detailTitle now binds to the selected vendor/item NAME (not "Selected Vendor")
-- so the title bar carries the identity. Nothing selected -> placeholder.
assertEq(HDG.Selectors:Call("acq.detailTitle", state, {}), "Select a vendor", "detailTitle: no selection in vendor mode -> 'Select a vendor'")

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "viewMode", value = "item" },
})
assertEq(HDG.Selectors:Call("acq.listTitle",   state, {}), "Search for Decor", "listTitle in item mode")
assertEq(HDG.Selectors:Call("acq.detailTitle", state, {}), "Select an item", "detailTitle: no selection in item mode -> 'Select an item'")

-- ===== Item-side detail selectors ========================================

-- No item selected -> placeholder string + empty meta.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedItemID", value = nil },
})
assertNil(HDG.Selectors:Call("acq.selectedItem", state, {}), "no selectedItem before pick")
assertEq(HDG.Selectors:Call("acq.selectedItem.name", state, {}), "Click an item",
    "item name placeholder")

-- Phase 3b: acq.selectedItem reads HousingCatalogObserver which gates on
-- session.catalog.status == "ready". Catalog was already marked ready
-- above (Phase 3c moved the first dispatch earlier); bump generation again
-- so acq.selectedItem re-evaluates with the latest sweep state.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.CATALOG_LOAD_COMPLETED,
    payload = { loadedAt = 0, itemCount = 1, vendorCount = 0, generation = 3 },
})

-- Pick an item -> all flat selectors resolve.
local targetItem = itemsAll[1]
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedItemID", value = targetItem.itemID },
})
local item = HDG.Selectors:Call("acq.selectedItem", state, {})
assertTrue(item ~= nil, "selectedItem resolves")
assertEq(item.itemID, targetItem.itemID, "selectedItem matches target")
-- Name is now color-wrapped by collection state (CollectionLabel) -- collected
-- recedes, uncollected takes the accent -- so compare against the helper output.
assertEq(HDG.Selectors:Call("acq.selectedItem.name", state, {}),
    HDG.Theme:CollectionLabel(item.isCollected, targetItem.name),
    "item name binds (collection-colored)")

-- Bogus itemID -> nil + placeholder restored.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedItemID", value = -99999999 },
})
assertNil(HDG.Selectors:Call("acq.selectedItem", state, {}), "bogus itemID -> nil")
assertEq(HDG.Selectors:Call("acq.selectedItem.name", state, {}), "Click an item",
    "bogus itemID -> name placeholder restored")

-- ===== Advanced filters collapsible + expansion dropdown ======

-- Clean slate -- earlier tests leave non-default values in search/faction/
-- viewMode that would cross-contaminate the expansion-filter assertions.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "searchQuery",     value = "" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "factionFilter",   value = "all" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "expansionFilter", value = "all" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "viewMode",        value = "vendor" },
})

-- Toggle defaults closed; label uses "+ ..."
assertEq(HDG.Selectors:Call("acq.advancedFiltersOpen", state, {}), false, "advanced closed by default")
assertEq(HDG.Selectors:Call("acq.advancedFiltersLabel", state, {}), "+ Advanced Filters", "closed label")

-- Open it -> label flips to "- ..."
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "advancedFiltersOpen", value = true },
})
assertEq(HDG.Selectors:Call("acq.advancedFiltersOpen", state, {}), true, "advanced opened")
assertEq(HDG.Selectors:Call("acq.advancedFiltersLabel", state, {}), "- Advanced Filters", "opened label")

-- Expansion filter defaults to "all".
assertEq(HDG.Selectors:Call("acq.expansionFilter", state, {}), "all", "expansion default")
-- expansionLabel retired with the Style1 dropdown migration; the dropdown
-- generator builds its trigger text from the menu items' radio text directly.

-- Menu items: "All" pinned first, then the expansions from HDG.Expansion.Each()
-- in release order (Classic -> Midnight), each label brand-colored via
-- GetColorHex (per the constants-based dropdown rework). The .value is the plain
-- expansion display name; .text wraps it in the brand color escape.
local expMenu = HDG.Selectors:Call("acq.expansionMenuItems", state, {})
assertTrue(#expMenu > 1, "expansion menu has >1 entry (got " .. #expMenu .. ")")
assertEq(expMenu[1].value, "all", "first item is 'all'")
assertEq(expMenu[1].text,  "All Expansions", "first item label")
for i = 2, #expMenu do
    assertTrue(type(expMenu[i].value) == "string" and expMenu[i].value ~= "",
        "expansion menu entry has a non-empty value")
    assertTrue((expMenu[i].text or ""):find(expMenu[i].value, 1, true) ~= nil,
        "expansion menu label contains the (brand-colored) expansion name")
end

-- Pick a specific expansion -> filter narrows acq.vendors.
local pickExp = expMenu[2].value   -- second entry, first real expansion
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "expansionFilter", value = pickExp },
})
assertEq(HDG.Selectors:Call("acq.expansionFilter", state, {}), pickExp,
    "expansion filter stores dispatched value")
-- The expansion filter is ITEM-based: a vendor passes if it sells >=1 item of
-- the picked expansion (matchesItemFilters -> GetExpansionForItem). The vendor's
-- display v.expansion (from VendorAugment) is a SEPARATE axis and may differ for
-- a multi-expansion vendor, so assert the filter's real contract, not v.expansion.
local function vendorSellsExpansion(v, exp)
    local ven = HDG.HousingCatalogObserver.byVendor[v.name]
    if not ven then return false end
    for _, itemID in ipairs(ven.items) do
        if HDG.HousingCatalogObserver:GetExpansionForItem(itemID) == exp then return true end
    end
    return false
end

local pickedVendors = HDG.Selectors:Call("acq.vendors", state, {})
assertTrue(#pickedVendors > 0, "expansion filter yields some vendors for " .. pickExp)
for _, v in ipairs(pickedVendors) do
    assertTrue(vendorSellsExpansion(v, pickExp),
        "filtered vendor sells a " .. pickExp .. " item: " .. v.name)
end

-- Triple-AND join: search + faction + expansion all active. Each gates
-- independently; we just check the result obeys all three constraints.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "factionFilter", value = "Alliance" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "searchQuery", value = "" },
})
local triple = HDG.Selectors:Call("acq.vendors", state, {})
for _, v in ipairs(triple) do
    assertEq(v.faction, "Alliance", "triple match faction")          -- vendor-level axis
    assertTrue(vendorSellsExpansion(v, pickExp), "triple match expansion (item-based)")
end

-- Reset path: dispatch all defaults -> full list.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "searchQuery",     value = "" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "factionFilter",   value = "all" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "expansionFilter", value = "all" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "viewMode",        value = "vendor" },
})
assertEq(#HDG.Selectors:Call("acq.vendors", state, {}), total,
    "after reset, vendor list is full")

-- ===== Zone + Rep dropdowns + tag system =======================

-- Zone + Rep axis selectors exist. Faction (Alliance/Horde/Neutral) is
-- the VENDOR's allegiance; Rep is per-ITEM reputation gating. The two
-- are independent axes.
for _, axis in ipairs({ "zone", "rep" }) do
    assertTrue(HDG.Selectors:Has("acq." .. axis .. "Filter"),    axis .. "Filter exists")
    assertTrue(HDG.Selectors:Has("acq." .. axis .. "MenuItems"), axis .. "MenuItems exists")
    assertEq(HDG.Selectors:Call("acq." .. axis .. "Filter", state, {}), "all",
        axis .. " defaults all")
end
-- zoneLabel + repLabel retired with the Style1 dropdown migration. The
-- per-axis MenuItems selectors pin "All <Plural>" as the first entry; the
-- dropdown's auto-render shows that as the trigger text when "all" is selected.
local zoneMenu = HDG.Selectors:Call("acq.zoneMenuItems", state, {})
local repMenu  = HDG.Selectors:Call("acq.repMenuItems",  state, {})
assertEq(zoneMenu[1].text, "All Zones", "zone menu first entry text")
assertEq(zoneMenu[1].value, "all", "zone menu first entry value")
assertEq(repMenu[1].text, "All Reps", "rep menu first entry text")
assertEq(repMenu[1].value, "all", "rep menu first entry value")

-- acq.allVendors caches a per-vendor `reps` set from item-level factionName.
-- At least some vendors should have non-empty rep sets (the VendorDB has
-- items gated by Tranquillien, Wildhammer Clan, Stormwind, etc.).
local allV = HDG.Selectors:Call("acq.allVendors", state, {})
local foundWithReps = false
for _, v in ipairs(allV) do
    if next(v.reps) ~= nil then foundWithReps = true; break end
end
assertTrue(foundWithReps, "at least one vendor has a non-empty reps set")

-- Rep menu items come from union of all vendors' reps -- distinct from
-- faction names (which are Alliance/Horde/Neutral). Sanity: rep menu
-- should NOT just be the 3 factions.
local repMenu = HDG.Selectors:Call("acq.repMenuItems", state, {})
assertTrue(#repMenu > 4, "rep menu has more than just A/H/N + All (got " .. #repMenu .. ")")
local hasNonFactionRep = false
for _, opt in ipairs(repMenu) do
    if opt.value ~= "all" and opt.value ~= "Alliance" and opt.value ~= "Horde" and opt.value ~= "Neutral" then
        hasNonFactionRep = true; break
    end
end
assertTrue(hasNonFactionRep, "rep menu includes per-item rep factions (not just A/H/N)")

-- Filter by a real rep faction -> vendors narrow to those selling
-- items gated by that rep.
local pickRep = nil
for _, opt in ipairs(repMenu) do
    if opt.value ~= "all" then pickRep = opt.value; break end
end
assertTrue(pickRep ~= nil, "found a real rep value to filter by")
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "repFilter", value = pickRep },
})
local repFiltered = HDG.Selectors:Call("acq.vendors", state, {})
assertTrue(#repFiltered > 0, "rep filter yields some vendors for " .. pickRep)
for _, v in ipairs(repFiltered) do
    assertEq(v.reps[pickRep], true,
        "vendor in rep filter has " .. pickRep .. " in its reps set: " .. v.name)
end
-- Reset rep filter
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "repFilter", value = "all" },
})

-- Zone menu derived from data: "All" first, then alphabetical zones.
local zMenu = HDG.Selectors:Call("acq.zoneMenuItems", state, {})
assertTrue(#zMenu > 5, "zone menu has multiple zones (got " .. #zMenu .. ")")
assertEq(zMenu[1].value, "all", "zone menu first is 'all'")

-- Apply zone filter -> vendors narrow to that zone.
local pickZone = zMenu[2].value
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "zoneFilter", value = pickZone },
})
local zVendors = HDG.Selectors:Call("acq.vendors", state, {})
assertTrue(#zVendors > 0, "zone filter yields some vendors")
for _, v in ipairs(zVendors) do
    assertEq(v.zone, pickZone, "vendor matches zone: " .. v.name)
end

-- "Active filters" selectors flip with state.
assertEq(HDG.Selectors:Call("acq.hasZoneFilter",    state, {}), true,  "hasZone true")
assertEq(HDG.Selectors:Call("acq.hasSearchFilter", state, {}), false, "hasSearch false")
assertEq(HDG.Selectors:Call("acq.anyFilterActive", state, {}), true,  "anyFilter true")
assertEq(HDG.Selectors:Call("acq.activeFiltersLabel", state, {}), "Active filters:",
    "label flips when filter active")

-- Tag display text uses "Axis: Value [x]" format -- the [x] reads as the
-- close affordance; click target is the whole tag (controller wires reset).
local zTag = HDG.Selectors:Call("acq.tagZone", state, {})
assertTrue(zTag:find("Zone:",  1, true) ~= nil, "tag has axis name: " .. zTag)
assertTrue(zTag:find(pickZone, 1, true) ~= nil, "tag has value: " .. zTag)
assertTrue(zTag:find("[x]", 1, true) ~= nil, "tag has close glyph: " .. zTag)

-- Clear back to all defaults -> activeFiltersLabel reverts.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "zoneFilter", value = "all" },
})
assertEq(HDG.Selectors:Call("acq.hasZoneFilter",    state, {}), false, "hasZone false after reset")
assertEq(HDG.Selectors:Call("acq.anyFilterActive", state, {}), false, "no filters active")
assertEq(HDG.Selectors:Call("acq.activeFiltersLabel", state, {}), "No active filters",
    "label reverts to 'No active filters'")

-- ===== Expandable vendor rows ==================================

-- Reset state to a known baseline.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "searchQuery",     value = "" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "factionFilter",   value = "all" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "expansionFilter", value = "all" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "zoneFilter",      value = "all" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "repFilter",       value = "all" },
})
-- ===== Vendor list flattened from TreeList to flat scrollbox.
-- acq.vendorTree + acq.expandedVendors + ACQ_TOGGLE_VENDOR_EXPANSION
-- retired; vendor items now live in the right panel's cardGrid, not as
-- tree children. acq.vendorRows is the current flat shape.

local vendors0 = HDG.Selectors:Call("acq.vendors",    state, {})
local rows0    = HDG.Selectors:Call("acq.vendorRows", state, {})
assertEq(#rows0, #vendors0, "vendorRows count matches filtered vendors")
for _, row in ipairs(rows0) do
    -- Phase 3c: npcID may be nil for vendors with no VendorAugment entry;
    -- canWaypoint=false in those cases. Name and allCollected are always present.
    assertTrue(row.name  ~= nil, "row carries name")
    assertTrue(row.allCollected ~= nil, "row stamps allCollected")
    -- If npcID is set, canWaypoint must be true (augment was found).
    if row.npcID ~= nil then
        assertTrue(row.canWaypoint == true, "npcID present -> canWaypoint true: " .. tostring(row.name))
    end
end

-- ===== Model preview resolver wired to housing catalog ======
-- Consolidated 2026-05-24: Resolve API now lives on HousingCatalogObserver
-- (the C_HousingCatalog owner). HDG.DecorInfoResolver was deleted in the
-- consolidation -- one searcher, one config, one per-item lookup API.
-- HousingCatalogObserver uses HDG.Modules:Declare for blizzardEvents
-- wiring, so the Modules engine must be loaded first.
dofile("../Core/HDGR_Modules.lua")
dofile("../Modules/HDGR_HousingCatalogObserver.lua")
assertTrue(HDG.HousingCatalogObserver ~= nil, "observer loaded")
assertTrue(type(HDG.HousingCatalogObserver.Resolve) == "function", "observer has Resolve")

-- Defensive nil paths (no C_HousingCatalog in test environment).
assertEq(HDG.HousingCatalogObserver:Resolve(nil),     nil, "Resolve tolerates nil")
assertEq(HDG.HousingCatalogObserver:Resolve("nope"),  nil, "Resolve rejects non-number")
assertEq(HDG.HousingCatalogObserver:Resolve(123),     nil, "Resolve returns nil for unknown itemID")

-- itemID -> decorID resolution: DecorDB has decorID for real items, so
-- the resolver gets past the "no decorID" guard. In test (no
-- C_HousingCatalog) it returns nil at the API-availability check.
local realItemID = nil
for id, entry in pairs(_G.HDGR_DecorDB or {}) do
    if entry.decorID then realItemID = id; break end
end
assertTrue(realItemID ~= nil, "DecorDB has at least one item with decorID")
assertEq(HDG.HousingCatalogObserver:Resolve(realItemID), nil,
    "Resolve returns nil in test env (no C_HousingCatalog), itemID is valid")

-- In WoW with the catalog loaded, Resolve(realItemID) returns:
--   { asset = <fileID>, uiModelSceneID = <num>, iconTexture = ..., name = ... }
-- The selector + binding chain then pushes that into the modelPreview
-- widget which shows the 3D model.
-- (Init / RegisterTrigger assertions removed in the 2026-05-24 consolidation:
-- HousingCatalogObserver inherits Reconciler's onEnable + Store:Subscribe
-- trigger; there's no separate Init method to call from tests anymore.)

-- DECOR_CATALOG_READY is registered + dispatchable without errors.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.DECOR_CATALOG_READY })

-- Phase 3c: Loading the real HousingCatalogObserver module above overwrites
-- GetAllVendorNames, GetItemsByVendor, and GetRow with real implementations
-- that return empty tables (no catalog sweep has run in test). Re-stub them
-- from VendorDB so acq.allVendors continues to return real data for the
-- vendor-view tests that follow (faction atlas, notes, detail-title, etc.).
do
    local obs      = HDG.HousingCatalogObserver
    local vendorDB = _G.HDGR_VendorDB
    local names2, byVendor2 = {}, {}
    if type(vendorDB) == "table" then
        for _, row in pairs(vendorDB) do
            if type(row) == "table" and type(row[1]) == "string" then
                local name = row[1]
                if not byVendor2[name] then
                    byVendor2[name] = { items = {}, zone = row[2] or "", faction = "", standing = "" }
                    table.insert(names2, name)
                end
                if type(row.items) == "table" then
                    for itemID in pairs(row.items) do
                        table.insert(byVendor2[name].items, itemID)
                    end
                end
            end
        end
        table.sort(names2)
    end
    obs.GetAllVendorNames = function(self)
        if not self:IsReady() then return {} end
        return names2
    end
    obs.GetItemsByVendor = function(self, vendorName)
        if not self:IsReady() then return nil end
        return byVendor2[vendorName]
    end
    -- Shared synthesizer for GetRow + IterateRows (same shape as pre-dofile stub).
    local function synthRow(itemID, vDB)
        local ad = _G.HDGR_AllDecorDB and _G.HDGR_AllDecorDB[itemID]
        if not ad then return nil end
        local gate = nil
        if type(vDB) == "table" then
            for _, vrow in pairs(vDB) do
                if type(vrow) == "table" and type(vrow.items) == "table" then
                    local idata = vrow.items[itemID]
                    if idata and type(idata[4]) == "string" and idata[4] ~= "" then
                        gate = { factionName = idata[4], standing = "" }
                        break
                    end
                end
            end
        end
        return {
            itemID            = itemID,
            decorID           = ad[1],
            name              = ad.name or "?",
            iconTexture       = 0,
            collected         = false,
            isOwned           = false,
            isAllowedIndoors  = true,
            isAllowedOutdoors = true,
            placementCost     = 0,
            quantity          = 0,
            numPlaced         = 0,
            firstAcquisitionBonus = 0,
            canCustomize      = false,
            quality           = nil,
            quest             = nil,
            achievement       = nil,
            vendors           = {},
            dataTagsByID      = {},
            factionGate       = gate,
            category          = nil,
            iconAtlas         = nil,
        }
    end
    obs.GetRow = function(self, itemID)
        if not self:IsReady() then return nil end
        return synthRow(itemID, vendorDB)
    end
    -- GetExpansionForItem: the real module method reads dataTagsByID which is
    -- empty in synthRow. Re-stub to read the .exp field from AllDecorDB so
    -- the expansion filter test (vendorSellsExpansion) returns the right value.
    obs.GetExpansionForItem = function(self, itemID)
        local ad = _G.HDGR_AllDecorDB and _G.HDGR_AllDecorDB[itemID]
        return ad and ad.exp
    end
    -- IterateRows: walk AllDecorDB and yield synthesized rows so that
    -- decor.allItems (used by acq.selected.items via decor.allItemsByID)
    -- returns real data rather than the empty R.byItemID from the real module.
    obs.IterateRows = function(self, fn)
        if not self:IsReady() then return end
        local allDecor = _G.HDGR_AllDecorDB
        if type(allDecor) == "table" then
            for itemID in pairs(allDecor) do
                local row = synthRow(itemID, vendorDB)
                if row then fn(itemID, row) end
            end
        end
    end
end
-- Restore VendorAugment to real data so the faction-atlas + milestone tests
-- below (which select vendors by factionRaw) can find vendors in VendorDB.
-- The real data file wasn't loaded earlier (tests only used stubs), so load
-- it now and reset the _byName index so ResolveName builds fresh.
dofile("../data/HDGR_VendorAugment.lua")
HDG.StaticData.VendorAugment._byName  = nil  -- force index rebuild from real data
HDG.StaticData.VendorAugment._byMapID = nil
HDG.StaticData.VendorAugment.Get = function(self, npcID)
    return _G.HDGR_VendorAugment and _G.HDGR_VendorAugment[npcID]
end
HDG.StaticData.VendorAugment.ResolveName = function(self, name, zone)
    HDG.StaticData.VendorAugment:_EnsureIndexes()
    if zone then
        local r = self._byName[name .. "/" .. zone]
                  or self._byName[name .. " (" .. zone .. ")"]
        if r then return r end
    end
    return self._byName[name]
end
-- Bump generation so acq.allVendors re-evaluates with the restored stubs.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.CATALOG_LOAD_COMPLETED,
    payload = { loadedAt = 0, itemCount = 0, vendorCount = 0, generation = 5 },
})
state = HDG.Store:GetState()

-- acq.previewInfo selector removed -- resolution moved to the widget
-- dispatcher (HDGR_Components.dispatchModelPreview) where the Blizzard
-- housing-catalog API call belongs. Selectors stay pure. The chain that
-- drives the widget is now: acq.selectedItemID -> dispatcher -> resolver.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedItemID", value = nil },
})
assertEq(HDG.Selectors:Call("acq.selectedItemID", state, {}), nil,
    "selectedItemID nil when no selection")

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedItemID", value = realItemID },
})
assertEq(HDG.Selectors:Call("acq.selectedItemID", state, {}), realItemID,
    "selectedItemID reads dispatched value")

-- ============================================================================
-- Preset chip strip (Missing / Achieve / Rep / Crafted / Quest)
-- ============================================================================
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_FILTER_RESET, payload = { tab = "acquisition" } })
local A = HDG.Constants.ACTIONS

-- Default: no preset.
assertEq(HDG.Selectors:Call("acq.preset", state, {}), nil, "preset default nil")

-- Each per-preset active selector exists and starts false.
for _, p in ipairs(HDG.Constants.ACQ_PRESETS) do
    local key = "acq.preset.active_" .. p.value
    assertTrue(HDG.Selectors:Has(key), key .. " registered")
    assertEq(HDG.Selectors:Call(key, state, {}), false, p.value .. " inactive at start")
end

-- Activate 'achievement' -- only achievement-source items pass.
HDG.Store:Dispatch({ type = A.ACQ_SET_PRESET, payload = { value = "achievement" } })
assertEq(HDG.Selectors:Call("acq.preset", state, {}), "achievement",
    "ACQ_SET_PRESET writes value")
assertEq(HDG.Selectors:Call("acq.preset.active_achievement", state, {}), true,
    "achievement active selector flips")
-- Achievement preset was broadened (HDG parity): now matches sourceType==1
-- OR altSourceType==1, so vendor-gated achievement items (srcType=Vendor +
-- alt=Achievement) are included. Test assertion updated to match.
local achItems = HDG.Selectors:Call("acq.items", state, {}) or {}
for _, item in ipairs(achItems) do
    assertTrue(item.sourceType == 1 or item.altSourceType == 1,
        "achievement preset only emits sourceType=1 or altSourceType=1 rows "
            .. "(got sourceType=" .. tostring(item.sourceType)
            .. ", altSourceType=" .. tostring(item.altSourceType) .. ")")
end

-- Re-dispatch the same preset -> clears it (toggle semantics).
HDG.Store:Dispatch({ type = A.ACQ_SET_PRESET, payload = { value = "achievement" } })
assertEq(HDG.Selectors:Call("acq.preset", state, {}), nil,
    "ACQ_SET_PRESET re-dispatch clears active preset")

-- 'endeavor' preset: sourceName/Detail substring match (data tagging is
-- future work; current data has near-zero Endeavor refs so the matcher
-- only needs to not crash and return a subset of allItems).
HDG.Store:Dispatch({ type = A.ACQ_SET_PRESET, payload = { value = "endeavor" } })
local endeavorItems = HDG.Selectors:Call("acq.items", state, {}) or {}
assertTrue(#endeavorItems <= totalItems,
    "endeavor preset narrows or equals the full set")

-- 'quest' preset: sourceType OR altSourceType in {2 (Quest), 3 (WorldQuest)}.
-- Production broadened to include altSourceType (HDG parity for vendor-gated
-- quest items).
HDG.Store:Dispatch({ type = A.ACQ_SET_PRESET, payload = { value = "quest" } })
local questItems = HDG.Selectors:Call("acq.items", state, {}) or {}
for _, item in ipairs(questItems) do
    local st  = item.sourceType
    local ast = item.altSourceType
    assertTrue(st == 2 or st == 3 or ast == 2 or ast == 3,
        "quest preset only emits sourceType or altSourceType in {2,3} "
            .. "(got sourceType=" .. tostring(st)
            .. ", altSourceType=" .. tostring(ast) .. ")")
end

-- ACQ_FILTER_RESET clears every filter axis at once.
HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "searchQuery", value = "test" } })
HDG.Store:Dispatch({ type = A.ACQ_SET_PRESET, payload = { value = "achievement" } })
HDG.Store:Dispatch({ type = A.ACQ_TOGGLE_MISSING })  -- missingOnly = true
HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "acquisition" } })
assertEq(HDG.Selectors:Call("acq.preset",      state, {}), nil,   "FILTER_RESET clears preset")
assertEq(HDG.Selectors:Call("acq.missingOnly", state, {}), false, "FILTER_RESET clears missingOnly")
assertEq(HDG.Selectors:Call("acq.filterQuery", state, {}), "",    "FILTER_RESET clears search")

-- ============================================================================
-- Vendor notes (account.vendorNotes keyed by npcID)
-- ============================================================================
HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "acquisition" } })

-- Pick a real vendor from acq.allVendors to test against.
-- Phase 3c: npcID may be nil for most vendors (VendorAugment stub returns nil
-- except for firstVendorName = npcID 88888). Use 88888 directly as sampleNpc.
local sampleNpc = 88888
assertTrue(sampleNpc ~= nil, "sampleNpc resolves")

-- Select that vendor -- acq.selected.note should default to "".
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedNpcID", value = sampleNpc },
})
assertEq(HDG.Selectors:Call("acq.selected.note", state, {}), "",
    "selected.note empty by default")

-- VENDOR_NOTE_SET writes text + ts; selector reads it.
HDG.Store:Dispatch({
    type    = A.VENDOR_NOTE_SET,
    payload = { npcID = sampleNpc, text = "Sells the chair I want", ts = 1000 },
})
assertEq(HDG.Selectors:Call("acq.selected.note", state, {}),
    "Sells the chair I want", "VENDOR_NOTE_SET writes text")

-- VENDOR_NOTE_CLEAR wipes the entry.
HDG.Store:Dispatch({ type = A.VENDOR_NOTE_CLEAR, payload = { npcID = sampleNpc } })
assertEq(HDG.Selectors:Call("acq.selected.note", state, {}), "",
    "VENDOR_NOTE_CLEAR wipes note")

-- LRU cap: 100 notes max. Seed 105 distinct npcIDs, oldest should drop.
local MAX = 100
for i = 1, 105 do
    HDG.Store:Dispatch({
        type    = A.VENDOR_NOTE_SET,
        payload = { npcID = -i, text = "n" .. i, ts = i },  -- ts ascends; earliest has lowest ts
    })
end
local notes = state.account.vendorNotes or {}
local count = 0
for _ in pairs(notes) do count = count + 1 end
assertTrue(count <= MAX, "vendorNotes LRU caps at " .. MAX .. " (got " .. count .. ")")
-- Lowest-ts entries should have been pruned.
assertEq(notes[-1], nil, "earliest vendor note pruned under LRU")
assertTrue(notes[-105] ~= nil, "newest vendor note retained")

-- ============================================================================
-- Review follow-up: faction atlas + green-name wraps + source filter
-- ============================================================================
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_FILTER_RESET, payload = { tab = "acquisition" } })

-- Pick a Horde vendor + a Neutral vendor to exercise the atlas branches.
-- Capture name + catalogZone too: acq.selected.items resolves the vendor's
-- item list via the stamped (selectedVendorName, selectedVendorZone) composite
-- key now, not selectedNpcID (the row click stamps all three in production).
local hordeNpc, neutralNpc
local hordeName, hordeZone  -- horde vendor needs name+zone for the milestone (item list) lookup
for _, v in ipairs(HDG.Selectors:Call("acq.allVendors", state, {}) or {}) do
    if v.factionRaw == "H" and not hordeNpc then
        hordeNpc, hordeName, hordeZone = v.npcID, v.name, v.catalogZone
    end
    if v.factionRaw == "N" and not neutralNpc then
        neutralNpc = v.npcID  -- neutral section only checks the title (selectedNpcID), no item list
    end
    if hordeNpc and neutralNpc then break end
end
assertTrue(hordeNpc ~= nil, "found a horde vendor")
assertTrue(neutralNpc ~= nil, "found a neutral vendor")

-- Switch to vendor view (faction atlas only renders in vendor mode title).
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "viewMode", value = "vendor" } })

-- Horde vendor -> title carries the horde atlas markup. The item count
-- suffix moved off the title and onto the sibling milestone label
-- (acq.milestoneText) in the 2026-05-23 panel-header restructure.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedNpcID", value = hordeNpc } })
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedVendorName", value = hordeName } })
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedVendorZone", value = hordeZone } })
local title = HDG.Selectors:Call("acq.detailTitle", state, {})
assertTrue(title:find("communities-create-button-wow-horde", 1, true) ~= nil,
    "horde vendor title includes the horde atlas markup")
local milestone = HDG.Selectors:Call("acq.milestoneText", state, {})
assertTrue(milestone:find("%d+/%d+ items") ~= nil,
    "milestoneText carries N/M items count (got: " .. milestone .. ")")

-- Neutral vendor -> no atlas markup (omitted), still has item count.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "selectedNpcID", value = neutralNpc } })
local ntitle = HDG.Selectors:Call("acq.detailTitle", state, {})
assertTrue(ntitle:find("communities-create-button", 1, true) == nil,
    "neutral vendor title has no faction atlas (got: " .. ntitle .. ")")

-- Green-name wrap: forge state directly so `isCollected(itemID)` returns
-- true for every item the horde vendor sells, then verify acq.detailTitle
-- wraps the name in |cFF60FF60...|r. Bypasses COLLECTION_BULK_LOAD's
-- byItem-derivation since the reducer's exact path isn't the test target;
-- we only care that the green wrap fires when the selector chain reports
-- vendorAllCollected.
local vendorDB = _G.HDGR_VendorDB
local raw = vendorDB and vendorDB[hordeNpc]
if raw and type(raw.items) == "table" then
    local col = state.account.collection
    local obs = HDG.HousingCatalogObserver
    col.ownedDecorIDs  = col.ownedDecorIDs  or {}
    -- Phase 4.4: decor.isCollected resolves decorID via byItemID[itemID].decorID.
    -- catalogByItem deleted. Seed decorID directly on the observer row.
    local nextID = 9000   -- fake decorIDs outside any real range
    for itemID in pairs(raw.items) do
        if obs.byItemID[itemID] then
            obs.byItemID[itemID].decorID = nextID
            -- 2026-05-26: ownership flows through R:IsOwned reading row
            -- counters. Set quantity=1 to fake ownership through the
            -- canonical formula (was: col.ownedDecorIDs[nextID] = true).
            obs.byItemID[itemID].quantity = 1
        end
        col.ownedDecorIDs[nextID]   = true
        nextID = nextID + 1
    end
    -- Selector memoizer keys on path-intersection; nudge by dispatching
    -- a benign action whose invalidates list covers ownedDecorIDs.
    HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
        payload = { view = "acquisition", key = "selectedNpcID", value = hordeNpc } })
    HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
        payload = { view = "acquisition", key = "selectedVendorName", value = hordeName } })
    HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
        payload = { view = "acquisition", key = "selectedVendorZone", value = hordeZone } })
    local greenTitle = HDG.Selectors:Call("acq.detailTitle", state, {})
    -- The selector wraps the title in Theme:GetTextStateColorToken("collected")
    -- (semantic.success colour code). Literal hex depends on the active scheme;
    -- assert the wrapping shape (|c<8 hex>...|r) instead of a baked colour.
    assertTrue(greenTitle:find("|c%x%x%x%x%x%x%x%x") ~= nil,
        "horde vendor title wraps in a colour code when all items collected (got: " .. greenTitle .. ")")
    assertTrue(greenTitle:find("|r", 1, true) ~= nil,
        "horde vendor title colour code is closed with |r")
end

-- Source filter narrowing: switch to item view, set sourceFilter to
-- "achievement", confirm acq.items returns only sourceType=1 rows.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_FILTER_RESET, payload = { tab = "acquisition" } })
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "viewMode", value = "item" } })
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "acquisition", key = "sourceFilter", value = "achievement" } })
-- sourceFilter=achievement matches sourceType==1 OR altSourceType==1
-- (broadened in production for HDG parity with vendor-gated achievement items).
local achOnly = HDG.Selectors:Call("acq.items", state, {}) or {}
for _, item in ipairs(achOnly) do
    assertTrue(item.sourceType == 1 or item.altSourceType == 1,
        "sourceFilter=achievement narrows to sourceType=1 or altSourceType=1 rows "
            .. "(got sourceType=" .. tostring(item.sourceType)
            .. ", altSourceType=" .. tostring(item.altSourceType) .. ")")
end

print(string.format("test_acquisition_smoke.lua passed (%d vendors, %d items)", total, totalItems))
