-- test_reducer_cases.lua
-- ============================================================================
-- Reducer coverage test. Dispatches every action in the closed taxonomy and
-- asserts the touched state slot changed shape. This is intentionally light --
-- the goal is "every action has a reducer case that doesn't crash on a
-- well-formed payload." Deep behavioral tests for individual reducer cases
-- belong in their domain test files (Phase 4+ will add e.g. test_cart.lua).
--
-- This file's other job: every action name appears here at least once, so
-- test_coverage_map.lua sees full coverage.

---@diagnostic disable: undefined-global
dofile("wow_mock.lua")
dofile("../Core/VDS_Constants.lua")
dofile("../Core/VDS_SchemeConstants.lua")
dofile("../Core/VDS_Paths.lua")
dofile("../Core/VDS_Log.lua")
dofile("../Core/VDS_Store.lua")
dofile("../Core/VDS_ActionMeta.lua")
VDS.Store:ValidateActionMetaExhaustiveness()
VDS_DB = {}
VDS.Store:LoadFromSavedVariables()

local A = VDS.Constants.ACTIONS
local function dispatch(t, p) VDS.Store:Dispatch({ type = t, payload = p }) end
local function state() return VDS.Store:GetState() end
local function eq(actual, expected, label)
    if actual ~= expected then
        error(label .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual), 2)
    end
end
local function truthy(v, label) if not v then error(label, 2) end end

print("Reducer cases:")

-- ===== Sandbox =====
-- (no sandbox actions -- Hello stripped in Phase 7 cleanup; the engine
-- paths it exercised are now covered by SET_OWNED_ONLY and other persisted
-- account writes below.)

-- ===== Engine =====
dispatch(A.MAIN_WINDOW_SHOW)
eq(state().session.ui.windowShown, true, "A.MAIN_WINDOW_SHOW sets shown=true")
dispatch(A.MAIN_WINDOW_HIDE)
eq(state().session.ui.windowShown, false, "A.MAIN_WINDOW_HIDE sets shown=false")
dispatch(A.MAIN_WINDOW_TOGGLE)
eq(state().session.ui.windowShown, true, "A.MAIN_WINDOW_TOGGLE flips false->true")
dispatch(A.MAIN_CONFIG_TOGGLE)
eq(state().session.ui.configShown, true, "A.MAIN_CONFIG_TOGGLE flips false->true")
dispatch(A.MAIN_CONFIG_TOGGLE)
eq(state().session.ui.configShown, false, "A.MAIN_CONFIG_TOGGLE flips back")
dispatch(A.CONFIG_SET, { key = "debug", value = true })
eq(state().account.config.debug, true, "A.CONFIG_SET writes config slot")
dispatch(A.BAG_TICK)
eq(state().session.bag.tick, 1, "A.BAG_TICK increments tick")
dispatch(A.ITEM_NAME_RESOLVED, { itemID = 3818, name = "Fadeleaf" })
eq(state().session.items.names[3818], "Fadeleaf", "A.ITEM_NAME_RESOLVED writes name cache")
eq(state().session.items.tick, 1, "A.ITEM_NAME_RESOLVED bumps tick")
dispatch(A.ITEM_NAMES_BATCH_RESOLVED, { entries = {
    { itemID = 765, name = "Silverleaf" },
    { itemID = 785, name = "Mageroyal" },
} })
eq(state().session.items.names[765], "Silverleaf", "A.ITEM_NAMES_BATCH_RESOLVED writes every entry")
eq(state().session.items.names[785], "Mageroyal", "A.ITEM_NAMES_BATCH_RESOLVED writes every entry (2nd)")
eq(state().session.items.tick, 2, "A.ITEM_NAMES_BATCH_RESOLVED bumps tick ONCE per batch")
dispatch(A.SESSION_IDENTITY_SET, { name = "X", realm = "Y", class = "WARRIOR" })
eq(state().session.identity.charKey, "X-Y", "A.SESSION_IDENTITY_SET assembles charKey")
print("  [OK] engine: MAIN_WINDOW_* / CONFIG_SET / BAG_TICK / SESSION_IDENTITY_SET")

-- ===== Filter / mode =====
dispatch(A.SET_FILTER_MODE, { mode = "canvas" })
eq(state().session.ui.filterMode, "canvas", "A.SET_FILTER_MODE writes filterMode")
dispatch(A.SET_SEARCH_TEXT, { text = "shadow" })
eq(state().session.ui.searchText, "shadow", "A.SET_SEARCH_TEXT writes searchText")
dispatch(A.SET_OWNED_ONLY, { value = true })
eq(state().account.config.ownedOnly, true, "A.SET_OWNED_ONLY writes ownedOnly")
dispatch(A.SET_REPLACE_BLIZZARD, { value = true })
eq(state().account.config.replaceBlizzard, true, "A.SET_REPLACE_BLIZZARD writes replaceBlizzard")
dispatch(A.SET_ACTIVE_TAB, { tab = "owned" })
eq(state().session.ui.activeTab, "owned", "A.SET_ACTIVE_TAB writes activeTab")
dispatch(A.SET_ACTIVE_SUBFILTER, { kind = "colorGroup", value = "Red" })
eq(state().session.ui.byView[state().session.ui.filterMode].activeColorGroup, "Red", "A.SET_ACTIVE_SUBFILTER colorGroup writes to byView bucket")
dispatch(A.SET_ACTIVE_SUBFILTER, { kind = "category", value = 5 })
eq(state().session.ui.byView[state().session.ui.filterMode].activeCategory, 5, "A.SET_ACTIVE_SUBFILTER category writes to byView bucket")
dispatch(A.SET_HERB_SORT_MODE, { mode = "tier" })
eq(state().session.ui.byView[state().session.ui.filterMode].herbSortMode, "tier", "A.SET_HERB_SORT_MODE writes herbSortMode to byView bucket")
dispatch(A.SET_SELECTED_DYE, { id = 42 })
eq(state().session.ui.selectedDyeID, 42, "A.SET_SELECTED_DYE writes flat selectedDyeID (not byView)")
dispatch(A.SET_SELECTED_HERB, { id = 999 })
eq(state().session.ui.selectedHerbID, 999, "A.SET_SELECTED_HERB writes flat selectedHerbID (not byView)")
print("  [OK] filter/mode: 10 actions")

-- ===== Cart =====
-- Removed: account.cart + CART_ADD_DYE + HERB_CART_ADD. Send-to-AH now reads
-- the deficit selectors (stock.buy.cart / stock.herbs.list) directly; no
-- intermediate persisted cart, no accumulation bug.
eq(state().account.cart, nil, "account.cart removed (selector-derived now)")
print("  [OK] cart: removed (selector-derived)")

-- ===== Custom themes =====
-- editingThemeName + activeTheme are now per-tab (R3); set filterMode to
-- "custom" first so writes land in the correct byView bucket.
dispatch(A.SET_FILTER_MODE, { mode = "custom" })
dispatch(A.CUSTOM_THEME_CREATE, { name = "MyTheme", dyes = { 1, 2, 3 } })
eq(state().account.customThemes[1].name, "MyTheme", "A.CUSTOM_THEME_CREATE writes theme")
eq(state().session.ui.byView["custom"].editingThemeName, "MyTheme", "A.CUSTOM_THEME_CREATE auto-enters edit mode")
dispatch(A.CUSTOM_THEME_EDIT_START, { name = "MyTheme" })
eq(state().session.ui.byView["custom"].editingThemeName, "MyTheme", "A.CUSTOM_THEME_EDIT_START sets editing")
dispatch(A.CUSTOM_THEME_TOGGLE_DYE, { name = "MyTheme", dyeID = 4 })
eq(state().account.customThemes[1].dyes[4], 4, "A.CUSTOM_THEME_TOGGLE_DYE adds dye")
dispatch(A.CUSTOM_THEME_TOGGLE_DYE, { name = "MyTheme", dyeID = 4 })
eq(state().account.customThemes[1].dyes[4], nil, "A.CUSTOM_THEME_TOGGLE_DYE removes dye second time")
dispatch(A.CUSTOM_THEME_RENAME, { from = "MyTheme", to = "Renamed" })
eq(state().account.customThemes[1].name, "Renamed", "A.CUSTOM_THEME_RENAME writes name")
eq(state().session.ui.byView["custom"].editingThemeName, "Renamed", "A.CUSTOM_THEME_RENAME follows editingThemeName")
dispatch(A.CUSTOM_THEME_EDIT_END)
eq(state().session.ui.byView["custom"].editingThemeName, nil, "A.CUSTOM_THEME_EDIT_END clears editing")
dispatch(A.CUSTOM_THEME_DELETE, { name = "Renamed" })
eq(#state().account.customThemes, 0, "A.CUSTOM_THEME_DELETE removes theme")
print("  [OK] customThemes: 6 actions")

-- ===== Canvas =====
dispatch(A.CANVAS_SET_DECOR, { decorID = 1001, numChannels = 2 })
eq(state().session.ui.canvas.decorID, 1001, "A.CANVAS_SET_DECOR writes decorID")
eq(state().session.ui.canvas.numChannels, 2, "A.CANVAS_SET_DECOR writes numChannels")
dispatch(A.CANVAS_SET_DECOR, { decorID = 1002, numChannels = 3, channelSet = { 1, 2 } })
eq(state().session.ui.canvas.activeChannel, 1, "A.CANVAS_SET_DECOR defaults activeChannel to first available channel (sparse channelSet skipping 0)")
dispatch(A.CANVAS_SET_CHANNEL_DYE, { ch = 0, dyeID = 50 })
eq(state().session.ui.canvas.dyes[0], 50, "A.CANVAS_SET_CHANNEL_DYE writes channel dye")
dispatch(A.CANVAS_CLEAR_CHANNEL, { ch = 0 })
eq(state().session.ui.canvas.dyes[0], nil, "A.CANVAS_CLEAR_CHANNEL nils channel")
dispatch(A.CANVAS_RANDOM, { dyes = { [0] = 7, [1] = 8 } })
eq(state().session.ui.canvas.dyes[1], 8, "A.CANVAS_RANDOM writes supplied dyes")
dispatch(A.CANVAS_SET_ACTIVE_CHANNEL, { ch = 2 })
eq(state().session.ui.canvas.activeChannel, 2, "A.CANVAS_SET_ACTIVE_CHANNEL writes activeChannel")
dispatch(A.CANVAS_SET_DECOR, { decorID = 99, numChannels = 3, channelSet = { 0, 1, 2 } })
dispatch(A.CANVAS_CYCLE_ACTIVE_CHANNEL)
eq(state().session.ui.canvas.activeChannel, 1, "A.CANVAS_CYCLE_ACTIVE_CHANNEL rotates 0->1")
dispatch(A.CANVAS_SET_PICKER_MODE, { mode = "loadouts" })
eq(state().session.ui.canvas.pickerMode, "loadouts", "A.CANVAS_SET_PICKER_MODE writes pickerMode")
dispatch(A.CANVAS_SET_PICKER_SEARCH, { text = "chair" })
eq(state().session.ui.canvas.pickerSearch, "chair", "A.CANVAS_SET_PICKER_SEARCH writes search")
dispatch(A.CANVAS_SAVE_LOADOUT, { decorID = 1001, dyes = { 1, 2, 3 }, name = "Cool" })
eq(state().account.canvas.loadouts[1001][1].name, "Cool", "A.CANVAS_SAVE_LOADOUT appends")
dispatch(A.CANVAS_SAVE_LOADOUT, { decorID = 1001, dyes = { 4, 5, 6 }, name = "Old" })
dispatch(A.CANVAS_RENAME_LOADOUT, { decorID = 1001, idx = 1, name = "Renamed" })
eq(state().account.canvas.loadouts[1001][1].name, "Renamed", "A.CANVAS_RENAME_LOADOUT updates name in place")
dispatch(A.CANVAS_DELETE_LOADOUT, { decorID = 1001, idx = 1 })
dispatch(A.CANVAS_DELETE_LOADOUT, { decorID = 1001, idx = 1 })
eq(#state().account.canvas.loadouts[1001], 0, "A.CANVAS_DELETE_LOADOUT removes")
dispatch(A.CANVAS_AUTOSAVE_SET, { value = true })
eq(state().account.canvas.autoSave, true, "A.CANVAS_AUTOSAVE_SET writes autoSave")
dispatch(A.CANVAS_PREVIEW_DETACH_SET, { detached = true })
eq(state().account.canvas.previewDetached, true, "A.CANVAS_PREVIEW_DETACH_SET writes previewDetached")
dispatch(A.CANVAS_SET_SCOPE, { value = { kind = "theme", name = "Ardenweald" } })
eq(state().session.ui.byView.canvas.scope.kind, "theme", "A.CANVAS_SET_SCOPE writes byView.canvas.scope")
dispatch(A.CANVAS_SET_SCOPE, { value = nil })
eq(state().session.ui.byView.canvas.scope, nil, "A.CANVAS_SET_SCOPE nil clears scope (All Dyes)")
dispatch(A.CANVAS_SET_CATEGORY, { value = 12 })
eq(state().session.ui.byView.canvas.selectedCategory, 12, "A.CANVAS_SET_CATEGORY writes byView.canvas.selectedCategory")
dispatch(A.CANVAS_CATALOG_UPDATE, { items = { { decorID = 7, name = "x" } } })
eq(#state().session.canvas.dyeableDecor, 1, "A.CANVAS_CATALOG_UPDATE writes dyeableDecor")
-- CANVAS_TOGGLE_EXPAND retired in Phase 5 (tree removed; no expand state).
dispatch(A.CANVAS_APPLY_LOADOUT, { decorID = 7, dyes = { [0] = 1, [1] = 2 }, numChannels = 2, channelSet = { 0, 1 } })
eq(state().session.ui.canvas.decorID, 7, "A.CANVAS_APPLY_LOADOUT writes decorID")
eq(state().session.ui.canvas.dyes[1], 2, "A.CANVAS_APPLY_LOADOUT writes dyes")
dispatch(A.CANVAS_HARMONISE, { dyes = { [0] = 10, [1] = 20, [2] = 30 } })
eq(state().session.ui.canvas.dyes[0], 10, "A.CANVAS_HARMONISE writes ch0 dye")
eq(state().session.ui.canvas.dyes[2], 30, "A.CANVAS_HARMONISE writes ch2 dye")
print("  [OK] canvas: 17 actions (CANVAS_TOGGLE_EXPAND retired Phase 5)")

-- ===== Live mode =====
dispatch(A.LIVE_MODE_ENTER, {
    decorID = 5000, decorName = "Test Chair",
    dyeSlots = { { ID = 1, dyeColorID = 100 } },
    originalDyes = { { slotID = 1, dyeID = 100 } },
})
eq(state().session.ui.live.active, true, "A.LIVE_MODE_ENTER sets active")
eq(state().session.ui.live.decorName, "Test Chair", "A.LIVE_MODE_ENTER writes decorName")
dispatch(A.LIVE_SLOTS_UPDATE, { dyeSlots = { { ID = 1, dyeColorID = 200 } } })
eq(state().session.ui.live.slots[1].dyeColorID, 200, "A.LIVE_SLOTS_UPDATE writes slots")
dispatch(A.LIVE_SET_SLOT_DYE_INTENT, { slotID = 1, dyeID = 333 })
eq(state().session.ui.live.intent.dyeID, 333, "A.LIVE_SET_SLOT_DYE_INTENT writes intent")
dispatch(A.LIVE_SLOT_FOCUS_SET, { slotID = 2 })
eq(state().session.ui.live.focusedSlot, 2, "A.LIVE_SLOT_FOCUS_SET writes focusedSlot")
dispatch(A.LIVE_RECENT_DYE_ADD, { dyeID = 11 })
dispatch(A.LIVE_RECENT_DYE_ADD, { dyeID = 12 })
dispatch(A.LIVE_RECENT_DYE_ADD, { dyeID = 11 })  -- dedupe
eq(state().account.live.recentDyes[1], 11, "A.LIVE_RECENT_DYE_ADD MRU push")
eq(#state().account.live.recentDyes, 2, "A.LIVE_RECENT_DYE_ADD dedupes")
dispatch(A.LIVE_AUTOSAVE_SET, { value = true })
eq(state().account.live.autoSave, true, "A.LIVE_AUTOSAVE_SET writes autoSave")
dispatch(A.LIVE_COST_UPDATE, { spend = 3, remove = 1 })
eq(state().session.ui.live.cost.spend,  3, "A.LIVE_COST_UPDATE writes spend")
eq(state().session.ui.live.cost.remove, 1, "A.LIVE_COST_UPDATE writes remove")
dispatch(A.LIVE_COMMIT_SNAPSHOT, { dyeSlots = { { ID = 1, dyeColorID = 200 }, { ID = 2 } } })
eq(state().session.ui.live.originalDyes[1].dyeID, 200, "A.LIVE_COMMIT_SNAPSHOT re-snapshots originalDyes")
eq(state().session.ui.live.originalDyes[2].dyeID, 0, "A.LIVE_COMMIT_SNAPSHOT normalizes empty slot to 0")
dispatch(A.LIVE_MODE_EXIT)
eq(state().session.ui.live.active, false, "A.LIVE_MODE_EXIT clears active")
eq(state().session.ui.live.cost.spend, 0, "A.LIVE_MODE_EXIT resets cost")
print("  [OK] live: 9 actions")

-- ===== Ownership + scan =====
dispatch(A.OWNERSHIP_TICK, { ownedCount = 50, totalCount = 200, perDye = { [1] = 1 } })
eq(state().session.dyes.ownership.ownedCount, 50, "A.OWNERSHIP_TICK writes ownedCount")
eq(state().session.dyes.ownership.totalCount, 200, "A.OWNERSHIP_TICK writes totalCount")
dispatch(A.DYEABLE_ITEMS_UPDATE, { itemIDs = { 100, 200 } })
truthy(state().account.dyeableItems[100], "A.DYEABLE_ITEMS_UPDATE writes item flag")
dispatch(A.DYE_DATA_INIT, { categories = { 1, 2 }, dyes = { 10, 20 } })
eq(#state().session.dyes.catalog.categories, 2, "A.DYE_DATA_INIT writes categories")
print("  [OK] ownership: 3 actions")

-- ===== Warehouse =====
dispatch(A.STOCK_SET_BUY_GAP, { value = true })
eq(state().account.warehouse.buyGap, true, "A.STOCK_SET_BUY_GAP writes buyGap")
print("  [OK] warehouse: STOCK_SET_BUY_GAP (rest covered in test_warehouse_*.lua)")

-- ===== Catalog (ADR-001) =====
local tick0 = state().session.catalog.tick
dispatch(A.CATALOG_READY)
eq(state().session.catalog.tick, tick0 + 1, "A.CATALOG_READY bumps tick by 1")
dispatch(A.CATALOG_READY)
eq(state().session.catalog.tick, tick0 + 2, "A.CATALOG_READY bumps tick again")
dispatch(A.CATALOG_STATUS_SET, { status = "scanning", attempt = 2 })
eq(state().session.canvas.catalog.status, "scanning", "A.CATALOG_STATUS_SET writes status")
eq(state().session.canvas.catalog.attempt, 2, "A.CATALOG_STATUS_SET writes attempt")
print("  [OK] catalog: CATALOG_READY + CATALOG_STATUS_SET (ADR-001)")

-- ===== Window + minimap =====
-- PANEL_POSITION_SAVE now only carries titleX/titleY (screen coords of
-- titlePanel.TOPLEFT). The outer frame's anchor type/x/y is no longer
-- persisted -- refreshLayout always re-anchors the frame from titleAnchor +
-- title placement offset.
dispatch(A.PANEL_POSITION_SAVE, { titleX = 100, titleY = 200 })
eq(state().account.panelPosition.titleX, 100, "A.PANEL_POSITION_SAVE writes titleX")
eq(state().account.panelPosition.titleY, 200, "A.PANEL_POSITION_SAVE writes titleY")
dispatch(A.MINIMAP_HIDE_SET, { hide = true })
eq(state().account.minimap.hide, true, "A.MINIMAP_HIDE_SET writes hide")
dispatch(A.MINIMAP_POSITION_SAVE, { pos = 270 })
eq(state().account.minimap.minimapPos, 270, "A.MINIMAP_POSITION_SAVE writes pos")
print("  [OK] window/minimap: 3 actions")

-- ===== HARD_RESET (last, since it wipes the world) =====
dispatch(A.HARD_RESET)
eq(state().account.cart, nil, "A.HARD_RESET leaves cart absent (selector-derived)")
eq(state().account.config.scheme, "DefaultDark", "A.HARD_RESET resets scheme")
print("  [OK] HARD_RESET")

print("test_reducer_cases.lua passed")
