-- test_shopping_smoke.lua -- reducer + selector smoke tests for the
-- shopping list port (HDG -> HDG, 2026-05-23).

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

local function assertEq(actual, expected, label)
    if actual ~= expected then
        error(label .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual), 2)
    end
end
local function assertTrue(cond, msg)
    if not cond then error((msg or "assertTrue failed"), 2) end
end

HDG.Store:LoadFromSavedVariables()
local state = HDG.Store:GetState()

-- =================================================================
-- State shape
-- =================================================================
-- EnsureShopping auto-seeds a default list on a fresh account (so "Add to Cart"
-- works out of the box), so the load-time defaults reflect that seeded list.
assertEq(type(state.account.vendorShoppingLists), "table",
    "default state has vendorShoppingLists map")
assertEq(state.account.activeShoppingListId, "L1",
    "fresh account auto-seeds + activates a default list (L1)")
assertEq(state.account.shoppingListSeq, 1,
    "auto-seed advances shoppingListSeq to 1")
assertEq(state.account.vendorShoppingLists.L1.name, "Shopping List",
    "auto-seeded list is named 'Shopping List'")
assertEq(#state.account.vendorShoppingLists.L1.items, 0,
    "auto-seeded list starts with empty items")
assertEq(state.account.ui.shoppingWidgetShown, false,
    "default state has shoppingWidgetShown = false")
assertEq(type(state.session.ui.shoppingList), "table",
    "default state has session.ui.shoppingList")
assertEq(type(state.session.ui.shoppingList.expanded), "table",
    "default state has session.ui.shoppingList.expanded")
assertEq(state.session.ui.shoppingList.expanded.wishList, false,
    "wishList expanded default false (= expanded)")

-- =================================================================
-- Reducer cases (12 actions)
-- =================================================================
local A = HDG.Constants.ACTIONS
local function dispatch(t, p) HDG.Store:Dispatch({ type = t, payload = p }) end

-- The auto-seeded L1 IS the first list (already active). Rename it to drive the
-- deterministic L1/L2/L3 flow below; CREATE is still exercised in the monotonicity
-- section. (On a fresh account EnsureShopping seeds + activates L1, so CREATE no
-- longer mints the first list.)
local firstID = "L1"
dispatch(A.SHOPPING_LIST_RENAME, { id = firstID, name = "Test List" })
assertEq(HDG.Store:GetState().account.shoppingListSeq, 1, "shoppingListSeq stays 1 (L1 auto-seeded)")
assertEq(HDG.Store:GetState().account.vendorShoppingLists[firstID].name, "Test List",
    "L1 list name renamed")
assertEq(#HDG.Store:GetState().account.vendorShoppingLists[firstID].items, 0,
    "L1 list starts with empty items")
assertEq(HDG.Store:GetState().account.activeShoppingListId, firstID,
    "seeded L1 is active")

-- ITEM_ADD via active list (listID nil -> falls back to active)
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 123, npcID = 456, qty = 2 })
local items = HDG.Store:GetState().account.vendorShoppingLists[firstID].items
assertEq(#items, 1, "ITEM_ADD appends entry")
assertEq(items[1].itemID, 123, "entry itemID")
assertEq(items[1].npcID, 456, "entry npcID")
assertEq(items[1].qty, 2, "entry qty")

-- ITEM_ADD coalesces same (itemID, npcID) tuple
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 123, npcID = 456, qty = 3 })
items = HDG.Store:GetState().account.vendorShoppingLists[firstID].items
assertEq(#items, 1, "ITEM_ADD coalesces same (itemID, npcID)")
assertEq(items[1].qty, 5, "coalesced qty = 2 + 3")

-- ITEM_SET_QTY
dispatch(A.SHOPPING_ITEM_SET_QTY, { itemID = 123, npcID = 456, qty = 7 })
assertEq(HDG.Store:GetState().account.vendorShoppingLists[firstID].items[1].qty, 7,
    "ITEM_SET_QTY updates qty in place")

-- ITEM_ADJUST_QTY: relative delta applied to CURRENT state (rapid-click safe).
dispatch(A.SHOPPING_ITEM_ADJUST_QTY, { itemID = 123, npcID = 456, delta = 1 })
assertEq(HDG.Store:GetState().account.vendorShoppingLists[firstID].items[1].qty, 8,
    "ADJUST_QTY +1 reads current state (7 -> 8)")
-- Two consecutive +1 accumulate (the bug a captured-absolute snapshot caused).
dispatch(A.SHOPPING_ITEM_ADJUST_QTY, { itemID = 123, npcID = 456, delta = 1 })
dispatch(A.SHOPPING_ITEM_ADJUST_QTY, { itemID = 123, npcID = 456, delta = 1 })
assertEq(HDG.Store:GetState().account.vendorShoppingLists[firstID].items[1].qty, 10,
    "ADJUST_QTY accumulates across dispatches (8 -> 10)")
dispatch(A.SHOPPING_ITEM_ADJUST_QTY, { itemID = 123, npcID = 456, delta = -3 })
assertEq(HDG.Store:GetState().account.vendorShoppingLists[firstID].items[1].qty, 7,
    "ADJUST_QTY -3 (10 -> 7)")
-- Crossing zero removes the row (remove-at-<=0 owned by the reducer).
dispatch(A.SHOPPING_ITEM_ADJUST_QTY, { itemID = 123, npcID = 456, delta = -7 })
assertEq(#HDG.Store:GetState().account.vendorShoppingLists[firstID].items, 0,
    "ADJUST_QTY to <=0 removes the entry")
-- Re-seed for the ITEM_REMOVE test below.
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 123, npcID = 456, qty = 2 })

-- ITEM_REMOVE
dispatch(A.SHOPPING_ITEM_REMOVE, { itemID = 123, npcID = 456 })
assertEq(#HDG.Store:GetState().account.vendorShoppingLists[firstID].items, 0,
    "ITEM_REMOVE drops matching entry")

-- Wishlist (npcID nil)
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 789, qty = 1 })
local wishEntry = HDG.Store:GetState().account.vendorShoppingLists[firstID].items[1]
assertEq(wishEntry.npcID, nil, "wishlist entry has nil npcID")

-- LIST_RENAME
dispatch(A.SHOPPING_LIST_RENAME, { id = firstID, name = "Renamed" })
assertEq(HDG.Store:GetState().account.vendorShoppingLists[firstID].name, "Renamed",
    "LIST_RENAME updates name")

-- LIST_DUPLICATE (L1 -> L2, deterministic)
dispatch(A.SHOPPING_LIST_DUPLICATE, { id = firstID, name = "Copy" })
local count = 0
for _ in pairs(HDG.Store:GetState().account.vendorShoppingLists) do count = count + 1 end
assertEq(count, 2, "LIST_DUPLICATE produces a 2nd list")
assertTrue(HDG.Store:GetState().account.vendorShoppingLists["L2"] ~= nil, "L2 created by duplicate")
assertEq(HDG.Store:GetState().account.vendorShoppingLists["L2"].name, "Copy", "L2 has duplicate name")

-- LIST_SET_META
dispatch(A.SHOPPING_LIST_SET_META, { id = firstID, key = "source", value = "https://example.com" })
assertEq(HDG.Store:GetState().account.vendorShoppingLists[firstID].meta.source,
    "https://example.com", "LIST_SET_META writes to meta")

-- LIST_CLEAR
dispatch(A.SHOPPING_LIST_CLEAR, { id = firstID })
assertEq(#HDG.Store:GetState().account.vendorShoppingLists[firstID].items, 0,
    "LIST_CLEAR empties items")

-- LIST_DELETE (active list -> active flips to remaining L2)
dispatch(A.SHOPPING_LIST_DELETE, { id = firstID })
assertEq(HDG.Store:GetState().account.vendorShoppingLists[firstID], nil,
    "LIST_DELETE removes the list")
assertEq(HDG.Store:GetState().account.activeShoppingListId, "L2",
    "deleting active list re-activates a remaining list")

-- LIST_ACTIVATE (no-op on nonexistent id)
dispatch(A.SHOPPING_LIST_ACTIVATE, { id = "nonexistent" })
assertEq(HDG.Store:GetState().account.activeShoppingListId, "L2",
    "ACTIVATE on nonexistent id is a no-op")
dispatch(A.SHOPPING_LIST_ACTIVATE, { id = "L2" })
assertEq(HDG.Store:GetState().account.activeShoppingListId, "L2",
    "ACTIVATE on existing id works")

-- WIDGET_TOGGLE flips boolean
local before = HDG.Store:GetState().account.ui.shoppingWidgetShown
dispatch(A.SHOPPING_WIDGET_TOGGLE)
assertEq(HDG.Store:GetState().account.ui.shoppingWidgetShown, not before,
    "WIDGET_TOGGLE flips shown state")
dispatch(A.SHOPPING_WIDGET_TOGGLE)
assertEq(HDG.Store:GetState().account.ui.shoppingWidgetShown, before,
    "WIDGET_TOGGLE second flip restores state")

-- =================================================================
-- Counter monotonicity across DELETE/CREATE cycles (collision guard)
-- =================================================================
dispatch(A.SHOPPING_LIST_CREATE, { name = "L3 check" })
assertEq(HDG.Store:GetState().account.shoppingListSeq, 3,
    "counter advances past deleted slots")
assertTrue(HDG.Store:GetState().account.vendorShoppingLists["L3"] ~= nil, "L3 minted")
assertTrue(HDG.Store:GetState().account.vendorShoppingLists["L1"] == nil, "L1 stays deleted")

-- =================================================================
-- Selectors (Phase 2)
-- =================================================================
-- StaticData + ShoppingCodec required by the selectors + SHOPPING_LIST_IMPORT.
dofile("../Modules/HDGR_StaticData.lua")
dofile("../Modules/HDGR_ShoppingCodec.lua")

-- Override HousingCatalogObserver:GetItemIDByDecorID for tests:
-- synthetic itemIDs (100/200/300) trip the codec's decor heuristic
-- (id < 100000); identity stub passes them through unchanged.
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

HDG.HousingCatalogObserver.GetItemIDByDecorID = function(_, id) return id end
-- decor.* selectors call HDG.ItemNameResolver:ResolveIcon strictly when
-- the catalog row is cold. See test_acquisition_smoke for stub rationale.
HDG.ItemNameResolver = HDG.ItemNameResolver or {
    ResolveIcon = function() return nil end,
    ResolveName = function() return nil end,
}

-- Phase 3f: shopping.activeListEntries now calls VendorAugment:Get(npcID) and
-- HousingCatalogObserver:IsReady(). Pre-populate the augment global so
-- StaticData:Get() doesn't throw the "missing table" assertion.
_G.HDGR_VendorAugment = _G.HDGR_VendorAugment or {}
_G.HDGR_ItemAugment   = _G.HDGR_ItemAugment   or {}

-- Stub with observer cold so rows are emitted without catalog enrichment.
HDG.HousingCatalogObserver = HDG.HousingCatalogObserver or {}
local _obs = HDG.HousingCatalogObserver
_obs._ready = false
function _obs:IsReady()      return self._ready end
function _obs:GetRow()       return nil end
function _obs:GetBindTypeForItem() return "BoP" end  -- no rows in smoke -> nothing tradeable

-- ControllerHelpers.UI.populateSourceFlags needed by the selector (called when
-- row is non-nil; with observer cold it is never called, but the reference must
-- resolve at load time to avoid an indexing error during selector registration).
HDG.ControllerHelpers = HDG.ControllerHelpers or {}
HDG.ControllerHelpers.UI = HDG.ControllerHelpers.UI or {}
HDG.ControllerHelpers.UI.populateSourceFlags = HDG.ControllerHelpers.UI.populateSourceFlags
    or function() end  -- no-op stub; observer cold means it's never called

dofile("../UI/HDGR_Selectors_Shopping.lua")

-- Seed: create a 4th list + populate with items so the grouping selectors
-- have something to work with.
dispatch(A.SHOPPING_LIST_CREATE, { name = "Selector Test" })   -- L4
local sID = "L4"
dispatch(A.SHOPPING_LIST_ACTIVATE, { id = sID })
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 100, npcID = 1, qty = 3 })
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 200, npcID = 1, qty = 1 })
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 300, qty = 5 })  -- wishlist

local activeList = HDG.Selectors:Call("shopping.activeList", HDG.Store:GetState(), {})
assertEq(activeList.name, "Selector Test", "activeList returns the active record")
assertEq(#activeList.items, 3, "active list has 3 items")
assertEq(HDG.Selectors:Call("shopping.activeListId", HDG.Store:GetState(), {}), sID,
    "activeListId returns the bare ID")

local menuItems = HDG.Selectors:Call("shopping.activeListMenuItems", HDG.Store:GetState(), {})
assertTrue(#menuItems >= 2, "menu items includes at least 2 lists")
-- Each menu entry has text + value (the dropdown's heterogeneous shape)
for _, m in ipairs(menuItems) do
    assertTrue(type(m.text) == "string", "menu item has text")
    assertTrue(type(m.value) == "string", "menu item has value (list id)")
end

local summary = HDG.Selectors:Call("shopping.summary", HDG.Store:GetState(), {})
assertEq(summary.itemCount, 9, "summary itemCount sums qty across entries (3+1+5)")
assertEq(summary.vendorCount, 1, "summary vendorCount counts unique npcIDs (excluding nil)")
assertEq(summary.wishCount, 5, "summary wishCount sums wishlist qty")

local txt = HDG.Selectors:Call("shopping.summaryText", HDG.Store:GetState(), {})
assertTrue(type(txt) == "string" and #txt > 0, "summaryText non-empty when list active")
assertTrue(txt:find("items"), "summaryText includes 'items'")

dispatch(A.SHOPPING_LIST_SET_META, { id = sID, key = "source", value = "https://example.com/list" })
local attr = HDG.Selectors:Call("shopping.attribution", HDG.Store:GetState(), {})
assertEq(attr.source, "https://example.com/list", "attribution.source from meta")
assertTrue(HDG.Selectors:Call("shopping.hasAttribution", HDG.Store:GetState(), {}),
    "hasAttribution true after setting meta.source")

local rows = HDG.Selectors:Call("shopping.entriesByZone", HDG.Store:GetState(), {})
-- Heterogeneous tree projection rows.
assertTrue(#rows > 0, "tree projection emits rows")
local kinds = {}
for _, row in ipairs(rows) do kinds[row.kind] = (kinds[row.kind] or 0) + 1 end
assertEq(kinds.wishHeader, 1, "exactly one wishHeader row")
assertTrue(kinds.wishItem  >= 1, "at least one wishlist item")
-- zone + vendor + item rows depend on VendorDB being loaded; in headless
-- tests without VendorDB seeded, items with npcID may bucket under
-- "Unknown" zone with vendor.name = "?". Test for STRUCTURAL presence.
assertTrue(kinds.zone   >= 1, "at least one zone header")
assertTrue(kinds.vendor >= 1, "at least one vendor header")
assertTrue(kinds.item   >= 1, "at least one item under a vendor")

-- =================================================================
-- SHOPPING_LIST_IMPORT roundtrip (Phase 3 integration)
-- =================================================================
local sourceList = HDG.Store:GetState().account.vendorShoppingLists[sID]
local encoded = HDG.ShoppingCodec.Encode(sourceList)
dispatch(A.SHOPPING_LIST_IMPORT, { encoded = encoded })
-- IMPORT auto-activates the new list -- verify shape parity.
local activeAfter = HDG.Selectors:Call("shopping.activeList", HDG.Store:GetState(), {})
assertEq(activeAfter.name, sourceList.name, "imported list name matches source")
assertEq(#activeAfter.items, #sourceList.items, "imported items count matches")

-- IMPORT garbage -> no new list created
local listsBefore = 0
for _ in pairs(HDG.Store:GetState().account.vendorShoppingLists) do listsBefore = listsBefore + 1 end
dispatch(A.SHOPPING_LIST_IMPORT, { encoded = "garbage-not-hdgr1" })
local listsAfter = 0
for _ in pairs(HDG.Store:GetState().account.vendorShoppingLists) do listsAfter = listsAfter + 1 end
assertEq(listsAfter, listsBefore, "IMPORT garbage is a no-op")

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