-- Smoke: collection state + new action surface + per-item flag selectors.
-- Covers state shape (favorites/userNotes/recipes/liveDecorIDs), the 12 new
-- Actions, the new filter-flag selectors, and the LRU pruning in
-- the NOTE_SET reducer. Reconciler internals (ReconcileFull / _CommitSweep)
-- are exercised in test_reconciler_smoke; this file focuses on the Store
-- and selector surface that downstream UI consumes.

dofile("wow_mock.lua")
dofile("../Core/HDGR_SchemeConstants.lua")
dofile("../Core/HDGR_Constants.lua")
dofile("../Core/HDGR_Actions.lua")
dofile("../Core/HDGR_Resolvers.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: decor.allItems reads HousingCatalogObserver, not AllDecorDB.
-- Full stub; byItemID seeded via inline fixture below (AllDecorDB is retired).
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")
-- AllDecorDB is retired (archived). Inline fixture seeded into obs.byItemID below.
_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()
local A      = HDG.Constants.ACTIONS

-- Phase 3a: seed HousingCatalogObserver.byItemID with an inline fixture.
-- AllDecorDB is retired; rows are self-contained. Section 7/8 filter tests
-- need OWNED_ITEM and STORED_ITEM present; we include additional rows so
-- decor.allItems produces a non-trivial list.
do
    local seeded = {
        [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 Val'sharah Bed",
                     iconTexture = 134400, iconAtlas = nil, canCustomize = false,
                     isOwned = false, quantity = 0, numPlaced = 0, placementCost = 3,
                     isAllowedIndoors = true, isAllowedOutdoors = true,
                     firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
    }
    obs.byItemID = seeded
    -- Phase 4.4: also build byDecorID for storedCount/coverage selectors.
    local byDecorID = {}
    for _, row in pairs(seeded) do
        if row.decorID then byDecorID[row.decorID] = row end
    end
    obs.byDecorID = byDecorID
    HDG.Store:Dispatch({
        type    = A.CATALOG_LOAD_COMPLETED,
        payload = { loadedAt = 0, itemCount = 0, vendorCount = 0, generation = 1 },
    })
end

-- ============================================================================
-- 1. Default state shape
-- ============================================================================

assertTrue(type(state.account.favorites)                    == "table", "account.favorites present")
assertTrue(type(state.account.userNotes)                    == "table", "account.userNotes present")
assertTrue(type(state.account.recipes)                      == "table", "account.recipes present")
assertTrue(type(state.account.collection.ownedDecorIDs)     == "table", "collection.ownedDecorIDs present")
-- Phase 4.4: liveDecorIDs removed from collection slot (observer owns row store)
assertTrue(state.account.collection.liveDecorIDs == nil, "collection.liveDecorIDs absent (Phase 4.4)")
assertEq(next(state.account.favorites),                 nil, "favorites empty at fresh boot")
assertEq(next(state.account.userNotes),                 nil, "userNotes empty at fresh boot")
assertEq(next(state.account.recipes),                   nil, "recipes empty at fresh boot")
assertEq(next(state.account.collection.ownedDecorIDs),  nil, "ownedDecorIDs empty at fresh boot")

-- ============================================================================
-- 2. Favorites: FAVORITE_TOGGLE round-trip
-- ============================================================================

HDG.Store:Dispatch({ type = A.FAVORITE_TOGGLE, payload = { itemID = 12345 } })
assertEq(state.account.favorites[12345], true, "FAVORITE_TOGGLE turns on")
HDG.Store:Dispatch({ type = A.FAVORITE_TOGGLE, payload = { itemID = 12345 } })
assertEq(state.account.favorites[12345], nil,  "FAVORITE_TOGGLE clears on repeat")

-- decor.isFavorite curried selector
HDG.Store:Dispatch({ type = A.FAVORITE_TOGGLE, payload = { itemID = 99999 } })
local isFav = HDG.Selectors:Call("decor.isFavorite", state, {})
assertTrue(type(isFav) == "function", "decor.isFavorite returns curried fn")
assertEq(isFav(99999), true,  "isFavorite(99999) true after toggle on")
assertEq(isFav(11111), false, "isFavorite(11111) false (never set)")

-- ============================================================================
-- 3. User notes: NOTE_SET + NOTE_CLEAR
-- ============================================================================

HDG.Store:Dispatch({ type = A.NOTE_SET, payload = { itemID = 56269, text = "Vintage chair", ts = 1000 } })
assertEq(state.account.userNotes[56269].text, "Vintage chair", "NOTE_SET stores text")
assertEq(state.account.userNotes[56269].ts,   1000,            "NOTE_SET stores ts")

HDG.Store:Dispatch({ type = A.NOTE_CLEAR, payload = { itemID = 56269 } })
assertEq(state.account.userNotes[56269], nil, "NOTE_CLEAR removes entry")

-- LRU pruning at MAX_USER_NOTES=500. Insert 501 entries; the oldest by ts
-- should be evicted after the 501st insert.
for i = 1, 500 do
    HDG.Store:Dispatch({ type = A.NOTE_SET, payload = { itemID = i, text = "n" .. i, ts = i } })
end
local count = 0
for _ in pairs(state.account.userNotes) do count = count + 1 end
assertEq(count, 500, "userNotes at cap = 500")
assertTrue(state.account.userNotes[1] ~= nil, "oldest entry (ts=1) still present at cap")

HDG.Store:Dispatch({ type = A.NOTE_SET, payload = { itemID = 1001, text = "n1001", ts = 1001 } })
local count2 = 0
for _ in pairs(state.account.userNotes) do count2 = count2 + 1 end
assertEq(count2, 500, "userNotes still 500 after one more insert (LRU evicted)")
assertEq(state.account.userNotes[1],    nil, "oldest entry (ts=1) evicted")
assertTrue(state.account.userNotes[1001] ~= nil, "newest entry present")

-- Cleanup for the rest of the tests.
state.account.userNotes = {}

-- ============================================================================
-- 4. Recipe knowledge: RECIPE_KNOWLEDGE_UPDATED + decor.craftableState
-- ============================================================================

HDG.Store:Dispatch({ type = A.RECIPE_KNOWLEDGE_UPDATED, payload = {
    entries = {
        [257100] = { spellID = 1262829, selfKnown = true,  altKnown = false },
        [257101] = { spellID = 1262830, selfKnown = false, altKnown = false },
    },
} })
local crafty = HDG.Selectors:Call("decor.craftableState", state, {})
assertEq(crafty(257100), "known_self",    "selfKnown -> known_self")
assertEq(crafty(257101), "recipe_exists", "no flags -> recipe_exists")
assertEq(crafty(999999), "not_a_recipe",  "absent from recipes -> not_a_recipe")

-- ============================================================================
-- 5. Filter state: SET/TOGGLE actions + selectors
-- ============================================================================

-- Defaults at first read (filters table lazy-inits).
assertEq(HDG.Selectors:Call("decor.topFilter",       state, {}), "all",  "topFilter default 'all'")
assertEq(HDG.Selectors:Call("decor.activeTag",       state, {}), nil,    "activeTag default nil")
assertEq(HDG.Selectors:Call("decor.onlyUncollected", state, {}), false,  "onlyUncollected default false")
assertEq(HDG.Selectors:Call("decor.onlyStored",      state, {}), false,  "onlyStored default false")

-- DECOR_SET_TOP_FILTER: changes topFilter + clears activeTag
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG,        payload = { tag = "Alchemy" } })
HDG.Store:Dispatch({ type = A.DECOR_SET_TOP_FILTER, payload = { value = "crafted" } })
assertEq(HDG.Selectors:Call("decor.topFilter", state, {}), "crafted", "topFilter switched to 'crafted'")
assertEq(HDG.Selectors:Call("decor.activeTag", state, {}), nil,        "activeTag cleared by top-filter switch")

-- Invalid topFilter values are no-ops
HDG.Store:Dispatch({ type = A.DECOR_SET_TOP_FILTER, payload = { value = "garbage" } })
assertEq(HDG.Selectors:Call("decor.topFilter", state, {}), "crafted", "invalid topFilter value rejected")

-- DECOR_SET_TAG
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = "Tailoring" } })
assertEq(HDG.Selectors:Call("decor.activeTag", state, {}), "Tailoring", "activeTag set")
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = nil } })
assertEq(HDG.Selectors:Call("decor.activeTag", state, {}), nil, "activeTag cleared with nil")

-- (DECOR_SET_UNRELEASED_MODE tri-state + decor.unreleasedMode selector
--  retired in Phase 4.5 -- catalog ships released-only items so the
--  toggle was meaningless.)

-- Toggles
HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_UNCOLLECTED })
assertEq(HDG.Selectors:Call("decor.onlyUncollected", state, {}), true, "onlyUncollected toggled on")
HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_UNCOLLECTED })
assertEq(HDG.Selectors:Call("decor.onlyUncollected", state, {}), false, "onlyUncollected toggled off")

HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_STORED })
assertEq(HDG.Selectors:Call("decor.onlyStored", state, {}), true, "onlyStored toggled on")
HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_STORED })
assertEq(HDG.Selectors:Call("decor.onlyStored", state, {}), false, "onlyStored toggled off")

-- decor.filterActive: false at cold defaults (topFilter='all' + everything cleared)
-- after the reset sequence
HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })
assertEq(HDG.Selectors:Call("decor.filterActive", state, {}), false,
    "filterActive false after RESET")

-- Activate something -> filterActive flips true
HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_UNCOLLECTED })
assertEq(HDG.Selectors:Call("decor.filterActive", state, {}), true,
    "filterActive true when onlyUncollected = true")
HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_UNCOLLECTED })  -- reset for next section

-- ============================================================================
-- 6. DECOR_FILTER_RESET: atomic reset of all filter state
-- ============================================================================

-- Set everything non-default
HDG.Store:Dispatch({ type = A.DECOR_SET_TOP_FILTER,        payload = { value = "crafted" } })
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG,               payload = { tag = "Alchemy" } })
HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_UNCOLLECTED })
HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_STORED })
HDG.Store:Dispatch({ type = A.DECOR_SET_SEARCH,            payload = { query = "chair" } })

-- Confirm non-default state
assertEq(HDG.Selectors:Call("decor.topFilter",       state, {}), "crafted",  "pre-reset topFilter")
assertEq(HDG.Selectors:Call("decor.activeTag",       state, {}), "Alchemy",   "pre-reset activeTag")
assertEq(HDG.Selectors:Call("decor.onlyUncollected", state, {}), true,        "pre-reset onlyUncollected")
assertEq(HDG.Selectors:Call("decor.onlyStored",      state, {}), true,        "pre-reset onlyStored")
assertEq(HDG.Selectors:Call("decor.filterQuery",     state, {}), "chair",     "pre-reset search")

-- Reset
HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

assertEq(HDG.Selectors:Call("decor.topFilter",       state, {}), "all",  "reset -> topFilter 'all'")
assertEq(HDG.Selectors:Call("decor.activeTag",       state, {}), nil,    "reset -> activeTag nil")
assertEq(HDG.Selectors:Call("decor.onlyUncollected", state, {}), false,  "reset -> onlyUncollected false")
assertEq(HDG.Selectors:Call("decor.onlyStored",      state, {}), false,  "reset -> onlyStored false")
assertEq(HDG.Selectors:Call("decor.filterQuery",     state, {}), "",     "reset -> search empty")

-- ============================================================================
-- 7. Per-item flag selectors + decor.items composition
-- ============================================================================

-- Seed catalog ownership directly (skipping reconciler).
-- itemID/decorID pairs from HDGR_AllDecorDB so the rows actually appear in
-- decor.allItems: 235523/494 (Sturdy Wooden Chair) and 235633/498 (Circular Woolen Rug).
local OWNED_ITEM   = 235523
local OWNED_DECOR  = 494
local STORED_ITEM  = 235633
local STORED_DECOR = 498

-- Phase 4.4: update observer rows to reflect catalog state for these items
-- (the reducer no longer writes catalog mirrors to state).
obs.byItemID[OWNED_ITEM] = {
    itemID = OWNED_ITEM, decorID = OWNED_DECOR, name = "Sturdy Wooden Chair",
    quantity = 1, numPlaced = 0, isOwned = true, firstAcquisitionBonus = 0,
    canCustomize = true, destroyableInstanceCount = 0, dataTagsByID = {}, vendors = {},
}
obs.byDecorID[OWNED_DECOR] = obs.byItemID[OWNED_ITEM]
obs.byItemID[STORED_ITEM] = {
    itemID = STORED_ITEM, decorID = STORED_DECOR, name = "Circular Woolen Rug",
    quantity = 0, numPlaced = 0, isOwned = false, firstAcquisitionBonus = 5,
    canCustomize = true, destroyableInstanceCount = 3, dataTagsByID = {}, vendors = {},
}
obs.byDecorID[STORED_DECOR] = obs.byItemID[STORED_ITEM]

HDG.Store:Dispatch({ type = A.COLLECTION_BULK_LOAD, payload = {
    owned = { [OWNED_DECOR] = true },
} })

-- Per-item flag selectors
local isColl = HDG.Selectors:Call("decor.isCollected", state, {})
assertEq(isColl(OWNED_ITEM),  true,  "isCollected(owned item) true")
assertEq(isColl(STORED_ITEM), false, "isCollected(stored-but-unowned item) false")
assertEq(isColl(999999),      false, "isCollected(unknown itemID) false")

local isStored = HDG.Selectors:Call("decor.isStored", state, {})
assertEq(isStored(OWNED_ITEM),  false, "isStored(owned item) false (numStored=0)")
assertEq(isStored(STORED_ITEM), true,  "isStored(stored item) true (numStored=3)")

-- Aggregates
assertEq(HDG.Selectors:Call("decor.ownedCount", state, {}), 1,
    "ownedCount = 1 after the bulk load")
assertEq(HDG.Selectors:Call("decor.storedCount", state, {}), 1,
    "storedCount = 1 (one row with numStored > 0)")

-- ============================================================================
-- 8. decor.items filter composition
-- ============================================================================

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

-- decor.items returns allItems unfiltered by released state (catalog-by-construction).
local shown    = HDG.Selectors:Call("decor.items", state, {})
local allShown = HDG.Selectors:Call("decor.allItems", state, {})
assertTrue(#shown > 0, "decor.items has rows")
assertEq(#shown, #allShown, "decor.items returns the full allItems list when unfiltered")

-- Only-uncollected: drops the one owned item
HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_UNCOLLECTED })
local uncoll = HDG.Selectors:Call("decor.items", state, {})
assertEq(#uncoll, #shown - 1, "onlyUncollected drops exactly the one owned row")
for _, item in ipairs(uncoll) do
    assertTrue(item.itemID ~= OWNED_ITEM,
        "uncollected list excludes the owned item")
end

-- Only-stored: only items with numStored > 0
HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_UNCOLLECTED })   -- clear
HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_STORED })
local stored = HDG.Selectors:Call("decor.items", state, {})
assertEq(#stored, 1, "onlyStored shows the single numStored-bearing row")
assertEq(stored[1].itemID, STORED_ITEM, "stored list matches the seeded row")

-- ============================================================================
-- 9. Phase 4.4: liveDecorIDs removed from state (observer owns row store)
-- ============================================================================
-- liveDecorIDs was deleted from NewCollection() in Phase 4.4.
-- The observer's byDecorID table is the authoritative row store.
assertTrue(state.account.collection.liveDecorIDs == nil,
    "liveDecorIDs absent from state post-4.4")
assertTrue(HDG.HousingCatalogObserver.byDecorID[OWNED_DECOR] ~= nil,
    "observer byDecorID[OWNED_DECOR] present after test seed")
assertTrue(HDG.HousingCatalogObserver.byDecorID[STORED_DECOR] ~= nil,
    "observer byDecorID[STORED_DECOR] present after test seed")
assertTrue(HDG.HousingCatalogObserver.byDecorID[88888] == nil,
    "observer byDecorID[88888] absent (not seeded)")

io.write("test_collection_smoke.lua passed\n")
