-- + 9a smoke: decor browser end-to-end.
-- Verifies the spec-clean composition:
--   decor.allItems    -- master sorted list from HousingCatalogObserver (Phase 3a)
--   decor.filterQuery -- reads session.ui.decor.searchQuery
--   decor.items       -- filtered (case-insensitive substring on name + 9a filters)
--   decor.headerLabel -- "OWNED/TOTAL (PCT%)" with optional " * N shown" tail

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_StoreFurnishings.lua")
dofile("../Core/HDGR_Store.lua")
dofile("../UI/HDGR_Selectors.lua")
dofile("../UI/HDGR_Selectors_Status.lua")

-- Phase 3a: stub HousingCatalogObserver before loading Selectors_Decor.
-- byItemID + byDecorID are seeded from AllDecorDB after that file loads (below).
HDG.HousingCatalogObserver = HDG.HousingCatalogObserver or {}
-- Canonical IsOwned helper stub (mirrors production formula
-- quantity + remainingRedeemable + numPlaced > 0).
HDG.HousingCatalogObserver.IsOwned = HDG.HousingCatalogObserver.IsOwned
    or function(self, rowOrID)
        local row
        if type(rowOrID) == "table" then row = rowOrID
        elseif type(rowOrID) == "number" then
            row = HDG.HousingCatalogObserver.byItemID and HDG.HousingCatalogObserver.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

local obs = HDG.HousingCatalogObserver
obs.byItemID     = obs.byItemID     or {}
obs.byDecorID    = obs.byDecorID    or {}
obs.tagIDToGroup = obs.tagIDToGroup or {}

function obs:IsReady()
    local s = HDG.Store:GetState().session.catalog
    return s and s.status == "ready"
end
function obs:GetRow(itemID)
    if not self:IsReady() then return nil end
    return self.byItemID[itemID]
end
function obs:GetIcon(itemID)
    local row = self:GetRow(itemID)
    return row and row.iconTexture
end
function obs:GetExpansionForItem(itemID)
    local row = self:GetRow(itemID)
    if not (row and row.dataTagsByID) then return nil end
    for tagID, displayName in pairs(row.dataTagsByID) do
        if self.tagIDToGroup[tagID] == "Expansion" then
            return displayName
        end
    end
    return nil
end
function obs:IterateRows(fn)
    if not self:IsReady() then return end
    for itemID, row in pairs(self.byItemID) do fn(itemID, row) end
end

dofile("../UI/HDGR_Selectors_Decor.lua")
-- See test_acquisition_smoke for stub rationale (cookbook 10).
HDG.ItemNameResolver = HDG.ItemNameResolver or {
    ResolveIcon = function() return nil end,
    ResolveName = function() return nil end,
}
dofile("../UI/HDGR_Selectors_Acquisition.lua")
dofile("../UI/HDGR_Selectors_Recipes.lua")
dofile("../data/HDGR_DecorDB.lua")       -- still needed: selectedItem.profession fallback
-- AllDecorDB is retired (archived). Inline fixture provides enough rows for
-- filter/selection tests. Row shape mirrors the catalog observer struct.
-- ItemAugment data not loaded in this test; empty table satisfies the StaticData
-- TOC-order guard. Phase 3a selectors default sourceType/altSourceType to 0
-- when ItemAugment returns nil.
_G.HDGR_ItemAugment = _G.HDGR_ItemAugment or {}
dofile("../Modules/HDGR_StaticData.lua")

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

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

-- ============================================================================
-- Phase 3a: seed HousingCatalogObserver.byItemID with an inline fixture.
-- AllDecorDB is retired; rows here are self-contained. Each row is a minimal
-- catalog-shaped struct (cold catalog: isOwned=false, quantity=0).
-- dispatch CATALOG_LOAD_COMPLETED to make IsReady() true.
-- The fixture includes multiple "chair" rows so the search-filter path is
-- exercised (lines below: "filter produced some matches").
-- ============================================================================
local seededByItemID = {
    [235523] = { itemID = 235523, decorID = 494, name = "Sturdy Wooden Chair",
                 iconTexture = 134400, iconAtlas = nil, canCustomize = true,
                 isOwned = false, quantity = 0, numPlaced = 0, placementCost = 1,
                 isAllowedIndoors = true, isAllowedOutdoors = true,
                 firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
    [235633] = { itemID = 235633, decorID = 498, name = "Circular Woolen Rug",
                 iconTexture = 134400, iconAtlas = nil, canCustomize = true,
                 isOwned = false, quantity = 0, numPlaced = 0, placementCost = 3,
                 isAllowedIndoors = true, isAllowedOutdoors = true,
                 firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
    [235675] = { itemID = 235675, decorID = 377, name = "Three-Candle Wrought Iron Chandelier",
                 iconTexture = 134400, iconAtlas = nil, canCustomize = true,
                 isOwned = false, quantity = 0, numPlaced = 0, placementCost = 1,
                 isAllowedIndoors = true, isAllowedOutdoors = true,
                 firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
    [235677] = { itemID = 235677, decorID = 383, name = "Wrought Iron Floor Lamp",
                 iconTexture = 134400, iconAtlas = nil, canCustomize = false,
                 isOwned = false, quantity = 0, numPlaced = 0, placementCost = 3,
                 isAllowedIndoors = true, isAllowedOutdoors = true,
                 firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
    [238860] = { itemID = 238860, decorID = 676, name = "Deluxe Chair with Cushion",
                 iconTexture = 134400, iconAtlas = nil, canCustomize = false,
                 isOwned = false, quantity = 0, numPlaced = 0, placementCost = 3,
                 isAllowedIndoors = true, isAllowedOutdoors = true,
                 firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
    [238863] = { itemID = 238863, decorID = 679, name = "Kaldorei Desk",
                 iconTexture = 134400, iconAtlas = nil, canCustomize = false,
                 isOwned = false, quantity = 0, numPlaced = 0, placementCost = 3,
                 isAllowedIndoors = true, isAllowedOutdoors = true,
                 firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
}
local total = 0
for _ in pairs(seededByItemID) do total = total + 1 end
obs.byItemID = seededByItemID
-- Phase 4.4: also build byDecorID so tagsForFilter/storedCount selectors work.
local seededByDecorID = {}
for _, row in pairs(seededByItemID) do
    if row.decorID then seededByDecorID[row.decorID] = row end
end
obs.byDecorID = seededByDecorID

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.CATALOG_LOAD_COMPLETED,
    payload = { loadedAt = 0, itemCount = total, vendorCount = 0, generation = 1 },
})

-- allItems: master sorted list.
local all = HDG.Selectors:Call("decor.allItems", state, {})
assertEq(#all, total, "allItems length matches AllDecorDB (observer seeded)")
for i = 2, math.min(#all, 100) do
    assertTrue((all[i-1].name or "") <= (all[i].name or ""),
        string.format("allItems sorted (i=%d: %q vs %q)", i, all[i-1].name, all[i].name))
end

-- Each item has the Phase 3a row shape (catalog-first migration).
local first = all[1]
assertTrue(type(first.itemID)     == "number",  "first.itemID is number")
assertTrue(type(first.name)       == "string",  "first.name is string")
assertTrue(type(first.exp)        == "string",  "first.exp is string")        -- "?" when no tag
-- sourceType dropped from the decor.allItems list row in the canonical-row-bake
-- refactor (commit ccbd7921): it's a detail-surface field now (decor.selectedItem),
-- list filtering keys off row.sourceTags directly. No list consumer reads it.
assertTrue(type(first.isOwned)    == "boolean", "first.isOwned is boolean")
assertTrue(type(first.numStored)  == "number",  "first.numStored is number")
assertEq(first.isOwned,   false, "first.isOwned false on cold catalog")
assertEq(first.numStored, 0,     "first.numStored 0 on cold catalog")

-- filterQuery: defaults to "" when state slice absent.
assertEq(HDG.Selectors:Call("decor.filterQuery", state, {}), "", "filterQuery default empty")

-- items == allItems when no filter active.
local items = HDG.Selectors:Call("decor.items", state, {})
assertEq(#items, #all, "items == allItems when unfiltered")

-- headerLabel: no "shown" tail when unfiltered (filterActive == false).
local label = HDG.Selectors:Call("decor.headerLabel", state, {})
assertTrue(not label:find("shown", 1, true), "headerLabel: no 'shown' tail when unfiltered")

-- Apply a filter via the proper dispatch path -- SetUITransientView ->
-- UI_SET_TRANSIENT reducer case. NEVER mutate state directly in tests
-- (mirrors production discipline; if the dispatch path is broken the
-- test fails for the right reason).
HDG.Store:Dispatch({
    type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "searchQuery", value = "Chair" },
})
assertEq(HDG.Selectors:Call("decor.filterQuery", state, {}), "Chair",
    "filterQuery reads dispatched state")

-- items shrinks to just the matches (case-insensitive substring on name).
local filtered = HDG.Selectors:Call("decor.items", state, {})
assertTrue(#filtered < #all, "filtered count is less than total")
assertTrue(#filtered > 0, "filter produced some matches (DecorDB has chairs)")
for _, item in ipairs(filtered) do
    assertTrue(item.name:lower():find("chair", 1, true),
        string.format("filtered item %q matches needle", item.name))
end

-- headerLabel is filter-scoped when a filter is active: "collected/shown (pct%)",
-- so the shown count is the denominator.
label = HDG.Selectors:Call("decor.headerLabel", state, {})
assertTrue(label:find("/" .. tostring(#filtered) .. " (", 1, true) ~= nil,
    "headerLabel: shown count is the denominator when filter active")

-- Case-insensitivity: "CHAIR" matches the same set.
HDG.Store:Dispatch({
    type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "searchQuery", value = "CHAIR" },
})
local upcase = HDG.Selectors:Call("decor.items", state, {})
assertEq(#upcase, #filtered, "uppercase filter same count (case-insensitive)")

-- Clearing the filter (empty string) restores everything.
HDG.Store:Dispatch({
    type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "searchQuery", value = "" },
})
assertEq(#HDG.Selectors:Call("decor.items", state, {}), #all,
    "cleared filter restores full list")

-- A nonsense query yields zero items -> filter-scoped "0/0 (0%)" label.
HDG.Store:Dispatch({
    type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "searchQuery", value = "zzzzqqqzzz" },
})
assertEq(#HDG.Selectors:Call("decor.items", state, {}), 0, "no matches -> empty")
assertTrue(HDG.Selectors:Call("decor.headerLabel", state, {}):find("0/0 (", 1, true) ~= nil,
    "headerLabel: '0/0' (collected/shown) when filter has no matches")

-- (Phase 6 profession chip strip retired Phase 9b -- profession filtering
--  migrated to the tag system under topFilter='crafted'. Dead state +
--  selectors + reducer + tests all pruned in the Phase 10a review.)

-- Clear search filter so the next section runs in isolation.
HDG.Store:Dispatch({
    type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "searchQuery", value = "" },
})
assertEq(#HDG.Selectors:Call("decor.items", state, {}), total,
    "filter cleared -> full list")

-- ============================================================================
-- Decor detail pane selectors (selection chain + previewInfo)
-- NOTE: decor.selectedItem still reads AllDecorDB directly (Phase 3a does not
-- migrate it). Assertions below rely on the AllDecorDB path remaining live.
-- ============================================================================

-- No selection at start -> all four detail bindings return safe defaults.
assertEq(HDG.Selectors:Call("decor.selectedItemID", state, {}), nil,
    "selectedItemID nil with no selection")
assertEq(HDG.Selectors:Call("decor.selectedItem", state, {}), nil,
    "selectedItem nil with no selection")
assertEq(HDG.Selectors:Call("decor.selectedItem.name", state, {}), "Click an item",
    "name placeholder when nothing selected")
assertEq(HDG.Selectors:Call("decor.selectedItem.profession", state, {}), "",
    "profession empty when nothing selected")
assertEq(HDG.Selectors:Call("decor.selectedItem.expansion", state, {}), "",
    "expansion empty when nothing selected")
assertEq(HDG.Selectors:Call("decor.selectedItem.itemID", state, {}), "",
    "itemID empty when nothing selected")
-- (decor.previewInfo selector removed -- resolver call lives at the
-- modelPreview widget dispatcher seam now, not in the selector chain.
-- Selectors stay pure functions of (state, ctx). The chain that drives
-- the widget is decor.selectedItemID -> widget dispatcher -> resolver.)

-- Select a real item -- the first row of allItems is a deterministic pick.
-- Row shape from AllDecorDB; .exp instead of .expansion; .profession
-- gone (Phase 9b adds it via account.recipes lookup under topFilter=Crafted).
local sample = all[1]
assertTrue(type(sample.itemID) == "number", "sample has itemID")
HDG.Store:Dispatch({
    type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "selectedItemID", value = sample.itemID },
})

assertEq(HDG.Selectors:Call("decor.selectedItemID", state, {}), sample.itemID,
    "selectedItemID reads dispatched value")
local sel = HDG.Selectors:Call("decor.selectedItem", state, {})
assertTrue(type(sel) == "table", "selectedItem populates on dispatch")
assertEq(sel.itemID, sample.itemID, "selectedItem carries itemID")
assertEq(sel.name, sample.name, "selectedItem.name matches AllDecorDB row")
assertEq(HDG.Selectors:Call("decor.selectedItem.name", state, {}), sample.name,
    "name binding returns raw name (no prefix)")
-- Profession binding: AllDecorDB rows lack .profession; selector falls back
-- to HDGR_DecorDB lookup. For non-recipe items the selector returns "" so
-- the detail pane doesn't display "Profession: ?". For recipe items the
-- string is prefixed "Profession: <name>".
local profBind = HDG.Selectors:Call("decor.selectedItem.profession", state, {})
assertTrue(type(profBind) == "string", "profession binding is a string")
assertTrue(profBind == "" or profBind:find("Profession:", 1, true) == 1,
    "profession binding is empty (non-recipe) or 'Profession:'-prefixed (got: " .. profBind .. ")")
-- decor.selectedItem reads HousingCatalogObserver:GetExpansionForItem (catalog-first).
-- The smoke-test observer seeds empty dataTagsByID, so expansion is "?" -> selector
-- hides "?" placeholder and returns "" (same as no-selection).
assertEq(HDG.Selectors:Call("decor.selectedItem.expansion", state, {}),
    "",
    "expansion binding returns '' when observer has no tag (? suppressed)")
assertEq(HDG.Selectors:Call("decor.selectedItem.itemID", state, {}),
    "itemID: " .. tostring(sample.itemID),
    "itemID binding prefixed")

-- Selecting a nonexistent itemID -> selectedItem nil but selectedItemID set.
HDG.Store:Dispatch({
    type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "selectedItemID", value = -9999 },
})
assertEq(HDG.Selectors:Call("decor.selectedItemID", state, {}), -9999,
    "nonexistent itemID still set in state")
assertEq(HDG.Selectors:Call("decor.selectedItem", state, {}), nil,
    "selectedItem nil when AllDecorDB has no matching row")

-- Clearing the selection restores placeholder strings.
HDG.Store:Dispatch({
    type = HDG.Constants.ACTIONS.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "selectedItemID", value = nil },
})
assertEq(HDG.Selectors:Call("decor.selectedItem.name", state, {}), "Click an item",
    "placeholder returns after deselection")

print(string.format("test_decor_smoke.lua passed (%d items)", total))
