-- Smoke test: Phase 14.0 Styles scaffold.
-- Verifies the new state shape seeds correctly, the 4 new actions dispatch
-- without tripping the closed-taxonomy guard, the cache-tick bumps via
-- STYLES_INVALIDATE_CACHE, and collection membership writes go to
-- account.collections.

dofile("wow_mock.lua")
dofile("../Core/HDGR_SchemeConstants.lua")
dofile("../Core/HDGR_Constants.lua")
dofile("../Core/HDGR_Format.lua")
-- StyleSerializer uses bit.* which the wow_mock provides; ParsersModule
-- delegates to StyleSerializer for blob formats and falls back to text
-- parsers for everything else.
dofile("../Modules/HDGR_StyleSerializer.lua")
dofile("../Modules/HDGR_Parsers.lua")
dofile("../Core/HDGR_Store.lua")
-- Selectors: Store unconditionally calls HDG.Selectors:InvalidateMemos on every
-- _RawDispatch (no-guards policy). Test must load Selectors so the registry exists.
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(v, label) if not v then error(label, 2) end end

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

-- ===== State shape: Styles session UI + cache tick + collections =====

assertTrue(state.session.ui.styles, "session.ui.styles seeded")
assertEq(state.session.ui.styles.view, "landing", "default view")
assertEq(state.session.ui.styles.selectedID, nil, "no selection at boot")
assertTrue(state.session.ui.styles.landing,  "landing sub-state seeded")
assertTrue(state.session.ui.styles.detail,   "detail sub-state seeded")
assertTrue(state.session.ui.styles.curator,  "curator sub-state seeded")
assertTrue(state.session.ui.styles.smartset, "smartset sub-state seeded")
assertTrue(state.session.ui.styles.import,   "import sub-state seeded")

assertTrue(state.session.styles,          "session.styles seeded")
assertEq(state.session.styles.cacheTick, 0, "cacheTick starts at 0")

assertTrue(state.account.collections, "account.collections seeded")
local n = 0
for _ in pairs(state.account.collections) do n = n + 1 end
assertEq(n, 0, "no collections at boot")

-- ===== STYLES_SET_VIEW dispatches =====

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_SET_VIEW,
    payload = { view = "curator" },
})
assertEq(state.session.ui.styles.view, "curator", "view -> curator")
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_SET_VIEW,
    payload = { view = "smartset" },
})
assertEq(state.session.ui.styles.view, "smartset", "view -> smartset")
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_SET_VIEW,
    payload = { view = "landing" },
})
assertEq(state.session.ui.styles.view, "landing", "view -> landing")

-- ===== STYLES_INVALIDATE_CACHE bumps the tick =====

HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_INVALIDATE_CACHE })
assertEq(state.session.styles.cacheTick, 1, "cacheTick -> 1 after first invalidation")
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_INVALIDATE_CACHE })
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_INVALIDATE_CACHE })
assertEq(state.session.styles.cacheTick, 3, "cacheTick monotonic")

-- ===== Collection membership writes =====

-- Seed a fake collection so the reducer cases have a target.
state.account.collections["style:test-tavern"] = {
    id          = "style:test-tavern",
    type        = "style",
    displayName = "Test Tavern",
    items       = { 100, 200 },
}

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.COLLECTION_STYLE_ITEM_ADDED,
    payload = { collectionID = "style:test-tavern", itemID = 300 },
})
assertEq(#state.account.collections["style:test-tavern"].items, 3,
         "item added")
assertEq(state.account.collections["style:test-tavern"].items[3], 300,
         "added itemID landed at end")

-- Duplicate adds are no-ops.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.COLLECTION_STYLE_ITEM_ADDED,
    payload = { collectionID = "style:test-tavern", itemID = 300 },
})
assertEq(#state.account.collections["style:test-tavern"].items, 3,
         "duplicate add is no-op")

-- Remove pulls from the array.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.COLLECTION_STYLE_ITEM_REMOVED,
    payload = { collectionID = "style:test-tavern", itemID = 200 },
})
assertEq(#state.account.collections["style:test-tavern"].items, 2, "item removed")
assertEq(state.account.collections["style:test-tavern"].items[1], 100, "100 retained")
assertEq(state.account.collections["style:test-tavern"].items[2], 300, "300 retained")

-- Removing a non-member is a no-op.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.COLLECTION_STYLE_ITEM_REMOVED,
    payload = { collectionID = "style:test-tavern", itemID = 999 },
})
assertEq(#state.account.collections["style:test-tavern"].items, 2,
         "non-member remove is no-op")

-- ===== 14.3 Curator parity: SELECT_TARGET + CREATE_STYLE =====

-- CREATE_STYLE writes a "style:<slug>" collection AND auto-selects it as
-- the active Move target.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_CREATE_STYLE,
    payload = { displayName = "Tavern Walls" },
})
local newID
for id, c in pairs(state.account.collections) do
    if c.type == "style" and c.displayName == "Tavern Walls" then newID = id end
end
assertTrue(newID, "CREATE_STYLE: collection materialized")
assertEq(state.session.ui.styles.curator.selectedTargetID, newID,
         "CREATE_STYLE: auto-selects new style as Move target")

-- SELECT_TARGET writes the highlight, doesn't move anything yet.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_CURATOR_SELECT_TARGET,
    payload = { targetID = "style:test-tavern" },
})
assertEq(state.session.ui.styles.curator.selectedTargetID, "style:test-tavern",
         "SELECT_TARGET: writes selectedTargetID")

-- Now move-without-payload reads selectedTargetID. Seed selection.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_CURATOR_TOGGLE_SELECT,
    payload = { itemID = 555 },
})
local beforeMove = #state.account.collections["style:test-tavern"].items
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_CURATOR_MOVE })
assertEq(#state.account.collections["style:test-tavern"].items, beforeMove + 1,
         "MOVE without targetID: uses selectedTargetID")

-- Duplicate-name CREATE: appends -2 disambiguator.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_CREATE_STYLE,
    payload = { displayName = "Tavern Walls" },
})
assertTrue(state.account.collections["style:tavern-walls-2"],
           "CREATE_STYLE duplicate: -2 suffix appended")

-- ===== STYLES_DELETE_STYLE: account.collections AND vendorShoppingLists =====
-- account.collections path: deleting a style removes it.
assertTrue(state.account.collections["style:test-tavern"], "pre-delete: style present")
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_DELETE_STYLE,
    payload = { collectionID = "style:test-tavern" },
})
assertEq(state.account.collections["style:test-tavern"], nil,
         "DELETE_STYLE: style removed from account.collections")

-- Shopping lists live in account.vendorShoppingLists and the landing card
-- carries a "vsl:"-prefixed id. The reducer must strip the prefix and delete
-- from the right slot (regression: account.collections-only path no-op'd this).
assertTrue(state.account.vendorShoppingLists, "vendorShoppingLists slot seeded")
state.account.vendorShoppingLists["L1"] = { name = "Tavern Haul", items = {} }
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_DELETE_STYLE,
    payload = { collectionID = "vsl:L1" },
})
assertEq(state.account.vendorShoppingLists["L1"], nil,
         "DELETE_STYLE: shopping list removed via vsl: prefix")

-- ===== 14.4 Smart Set Builder reducer paths =====

HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_SMARTSET_BEGIN })
local s = state.session.ui.styles.smartset
assertEq(s.draftKey, nil, "BEGIN: no draftKey for fresh draft")
assertEq(s.draft.displayName, "My Style 1", "BEGIN: seeds default name (_seededSmartsetName: UnitName fallback 'My' + collectionSeq 0 -> +1)")
assertEq(s.activeAxis, "room", "BEGIN: default axis = room")
assertEq(s.activeSeverity, "all", "BEGIN: severity tab = all")
assertEq(s.dirty, false, "BEGIN: dirty = false")

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_SMARTSET_SET_FIELD,
    payload = { field = "displayName", value = "Lavish Bedroom" },
})
assertEq(s.draft.displayName, "Lavish Bedroom", "SET_FIELD: displayName written")
assertEq(s.dirty, true, "SET_FIELD: dirty -> true")

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_SMARTSET_SET_AXIS,
    payload = { axis = "color" },
})
assertEq(s.activeAxis, "color", "SET_AXIS: color")

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_SMARTSET_TOGGLE_TAG,
    payload = { axis = "color", tag = "red", severity = "signature" },
})
assertEq(s.rules.color.red, "signature", "TOGGLE_TAG: tag set")

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_SMARTSET_TOGGLE_TAG,
    payload = { axis = "color", tag = "red", severity = "signature" },
})
assertEq(s.rules.color, nil, "TOGGLE_TAG: same severity untoggles + drops empty axis")

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_SMARTSET_TOGGLE_TAG,
    payload = { axis = "color", tag = "blue", severity = "accent" },
})
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_SMARTSET_SAVE })
assertTrue(s.draftKey, "SAVE: draftKey assigned")
assertTrue(state.account.collections[s.draftKey], "SAVE: collection landed in account")
assertEq(state.account.collections[s.draftKey].type, "smartset", "SAVE: type = smartset")
assertEq(state.account.collections[s.draftKey].rules.color.blue, "accent",
         "SAVE: rules persisted")
assertEq(s.dirty, false, "SAVE: dirty cleared")

HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_SMARTSET_CANCEL })
assertEq(s.draftKey, nil, "CANCEL: draft cleared")
assertEq(s.draft.displayName, "", "CANCEL: name reset")
local r = 0
for _ in pairs(s.rules) do r = r + 1 end
assertEq(r, 0, "CANCEL: rules cleared")

-- ===== 14.5 Snapshot + Import reducer paths =====

-- Snapshot: empty payload (no items) is a no-op (no collection written).
local beforeCount = 0
for _ in pairs(state.account.collections) do beforeCount = beforeCount + 1 end
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_SNAPSHOT_PLACED })
local afterCount = 0
for _ in pairs(state.account.collections) do afterCount = afterCount + 1 end
assertEq(afterCount, beforeCount, "SNAPSHOT empty placedDecor: no-op")

-- Seed two decor entries via OBSERVED, then snapshot.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_PLACED_DECOR_OBSERVED,
    payload = { decorGUID = "g1", decorID = 5001, itemID = 235523, name = "Chair" },
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_PLACED_DECOR_OBSERVED,
    payload = { decorGUID = "g2", decorID = 5002, itemID = 235633, name = "Rug" },
})
local placedN = 0
for _ in pairs(state.session.styles.placedDecor) do placedN = placedN + 1 end
assertEq(placedN, 2, "OBSERVED x2: placedDecor map size = 2")

-- Snapshot is now account-wide + payload-driven: the controller scans the catalog
-- (numPlaced>0) and passes distinct itemIDs; the reducer just writes payload.items.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_SNAPSHOT_PLACED,
    payload = { items = { 235523, 235633 }, takenAt = 1000, displayName = "Placed Decor - test" },
})
local snapID
for id, c in pairs(state.account.collections) do
    if c.type == "snapshot" then snapID = id end
end
assertTrue(snapID, "SNAPSHOT: collection created")
assertEq(#state.account.collections[snapID].items, 2, "SNAPSHOT: 2 items captured")
assertEq(state.account.collections[snapID].displayName, "Placed Decor - test", "SNAPSHOT: displayName from payload")

-- Remove an entry from the live map (placedDecor still powers recent-activity).
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_PLACED_DECOR_REMOVED,
    payload = { decorGUID = "g2" },
})
assertEq(state.session.styles.placedDecor.g2, nil, "REMOVED: entry gone")

HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_PLACED_DECOR_CLEAR })
placedN = 0
for _ in pairs(state.session.styles.placedDecor) do placedN = placedN + 1 end
assertEq(placedN, 0, "CLEAR: placedDecor wiped")

-- Import: empty urlText parse -> parseError, no preview.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_IMPORT_SET_URL,
    payload = { text = "" },
})
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_IMPORT_PARSE })
assertTrue(state.session.ui.styles.import.parseError, "PARSE empty: error set")
assertEq(state.session.ui.styles.import.previewItems, nil, "PARSE empty: no preview")

-- Comma-separated IDs.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_IMPORT_SET_URL,
    payload = { text = "235523, 235633, 235675, 235523" },  -- duplicate dropped
})
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_IMPORT_PARSE })
assertEq(#state.session.ui.styles.import.previewItems, 3,
         "PARSE: dedup'd 3 unique IDs")

-- Commit creates a shopping collection.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_IMPORT_COMMIT,
    payload = { displayName = "Tavern Shopping List" },
})
local shopID
for id, c in pairs(state.account.collections) do
    if c.type == "shopping" then shopID = id end
end
assertTrue(shopID, "IMPORT COMMIT: shopping collection created")
assertEq(#state.account.collections[shopID].items, 3, "COMMIT: 3 items persisted")
assertEq(state.account.collections[shopID].displayName, "Tavern Shopping List",
         "COMMIT: name applied")
assertEq(state.session.ui.styles.import.urlText, "",
         "COMMIT: urlText cleared after commit")
assertEq(state.session.ui.styles.import.previewItems, nil,
         "COMMIT: preview cleared after commit")

-- RESET clears any pending session.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.STYLES_IMPORT_SET_URL,
    payload = { text = "garbage 12345" },
})
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.STYLES_IMPORT_RESET })
assertEq(state.session.ui.styles.import.urlText, "", "RESET: urlText cleared")
assertEq(state.session.ui.styles.import.parseError, nil, "RESET: parseError cleared")

-- ===== Parsers module: format dispatch ==========================

-- Plain digit CSV - the fallback path.
local r = HDG.Parsers:Parse("100 200 300, 400")
assertTrue(r.ok, "csv parse ok")
assertEq(#r.items, 4, "csv parse: 4 items")
assertEq(r.parserName, "digits", "csv parser name = digits")

-- Wowhead-style URL with items= query param.
r = HDG.Parsers:Parse("https://www.wowhead.com/shopping-list?items=235523,235633,235675")
assertTrue(r.ok, "wowhead url parse ok")
assertEq(#r.items, 3, "wowhead: 3 items")
assertEq(r.source, "wowhead", "wowhead source labeled")

-- housing.wowdb.com URL.
r = HDG.Parsers:Parse("https://housing.wowdb.com/list?items=100,200")
assertTrue(r.ok, "wowdb url parse ok")
assertEq(r.source, "housing.wowdb.com", "wowdb source labeled")

-- Chat item links.
r = HDG.Parsers:Parse("|cffa335ee|Hitem:235523::::::::70:::::|h[Sturdy Chair]|h|r and |Hitem:235633|h[Rug]|h")
assertTrue(r.ok, "item-link parse ok")
assertEq(#r.items, 2, "item-link: 2 items")
assertEq(r.parserName, "itemLinks", "parser name = itemLinks")

-- Empty input -> error.
r = HDG.Parsers:Parse("")
assertEq(r.ok, false, "empty parse fails")
assertTrue(r.error, "empty parse has error string")

-- HDG:1: style blob -> rejected with style-builder hint (correct for
-- shopping-list import flow; styles import via Smart Set Builder).
r = HDG.Parsers:Parse("HDG:1:garbagepayload")
assertEq(r.ok, false, "HDG:1: blob rejected on shopping flow")

print("test_styles_smoke: OK")
