-- VDS_Store
-- ============================================================================
-- Single source of truth. Only the reducer writes. Add reducer cases + state
-- factories for new actions; do not modify the engine surface.

VDS = VDS or {}
VDS.Store = VDS.Store or { state = {}, _subscribers = {}, _actionMeta = {} }

local Store = VDS.Store
local A     = VDS.Constants.ACTIONS
local C     = VDS.Constants

-- Default-shape factories. Covers every donor VDS_DB field so strict-read holds post-Load.

local function NewConfig()
    return {
        debug             = false,
        scheme            = "DefaultDark",
        showMinimapButton = true,
        -- VDS domain config: defaults tuned for new users (Replace dye picker +
        -- minimap on; ownedOnly + debug off).
        replaceBlizzard   = true,
        ownedOnly         = false,
        -- Debug-only catalog kill switch. Toggled via /vds forcefail; when
        -- ON, DecorInfoResolver:OnSearchResults treats searcher results as
        -- empty so the retry/restart/failed UI path can be exercised.
        -- MUST default false; CI / shipped users never see force-fail.
        forceCatalogEmpty = false,
        -- Debug-only. Toggled via /vds alldecor; when ON, DecorInfoResolver
        -- appends DB-only decor (in the channel DB but not this character's
        -- catalog -- event-gated, etc.) to the Canvas picker, tagged "[DB]".
        debugAllDecor     = false,
    }
end

local function NewAccountUI()
    return {
        view             = "hello",   -- sandbox view until Phase 5
    }
end

local function NewMinimap()
    return { hide = false, minimapPos = 200 }
end

local function NewAccountCanvas()
    return {
        autoSave         = false,
        previewDetached  = false,
        loadouts         = {},  -- [decorID] = { {dyes={[0],[1],[2]}, name, ts}, ... }
        -- Catalog snapshot for cross-session fallback. Populated on every
        -- successful CANVAS_CATALOG_UPDATE; consumed at boot to prime
        -- session.canvas.dyeableDecor before the live searcher returns.
        -- Stays nil for new installs (no snapshot yet); first successful
        -- live dispatch writes it.
        catalogSnapshot  = nil,  -- { items = {...}, categoryNames = {...}, ts = N } | nil
    }
end

local function NewAccountLive()
    return {
        autoSave    = false,
        recentDyes  = {},  -- bounded ring buffer to LIVE_RECENT_DYES_MAX
    }
end

local function NewWarehouse()
    return {
        -- Floor of 1 so the view shows signal on first paint. Table never replaced
        -- (by-reference SavedVars contract, cookbook 11).
        targetGroups = {
            Red = 1, Orange = 1, Yellow = 1, Green = 1, Teal = 1,
            Blue = 1, Purple = 1, Pink = 1, Brown = 1, Grey = 1,
            Black = 1, White = 1,
        },
        -- Per-dye overrides. Sparse; phantom overrides (== group default) are tolerated --
        -- pruning requires catalog access which reducers can't have.
        targetDyes = {},
        fillMode = "buy",           -- "buy" | "craft", sticky
        -- When true (default), Send-to-AH + the herbs popout subtract on-hand
        -- (bag + bank + reagent bank + warband) from the herb totals so the
        -- numbers reflect "what to BUY", not "what the crafts consume". Flip
        -- false to see gross requirements for the planned crafts.
        buyGap = true,
        -- Sparse: presence = collapsed, absence = expanded. Default all-collapsed.
        collapsedGroups = {
            Red = true, Orange = true, Yellow = true, Green = true, Teal = true,
            Blue = true, Purple = true, Pink = true, Brown = true, Grey = true,
            Black = true, White = true,
        },
    }
end

local function NewMigratedFrom()
    return { vds = false }  -- one-shot flag: was VDS_DB imported? (HDG_DB is not a source)
end

local function NewAccount()
    return {
        config        = NewConfig(),
        ui            = NewAccountUI(),
        panelPosition = nil,            -- { titleX, titleY } screen coords of titlePanel.TOPLEFT, or nil
        minimap       = NewMinimap(),
        dyeableItems  = {},             -- [itemID] = true (positive-only)
        customThemes  = {},             -- array of { name, rgb?, desc?, dyes={...} }
        canvas        = NewAccountCanvas(),
        live          = NewAccountLive(),
        migratedFrom  = NewMigratedFrom(),
        warehouse     = NewWarehouse(),
        schemaVersion = 2,  -- v1=pre-warehouse; v2=+warehouse; bump in EnsureAccount for new shapes
    }
end

local function NewSessionCanvas()
    return {
        decorID         = nil,
        dyes            = { [0] = nil, [1] = nil, [2] = nil },
        activeChannel   = 0,
        numChannels     = 3,
        channelSet      = nil,            -- sparse channel-index list per item
        pickerMode      = "decor",        -- "decor" | "loadouts"
        pickerSearch    = "",
    }
end

local function NewSessionLive()
    return {
        active        = false,
        decorID       = nil,
        decorName     = nil,
        slots         = nil,            -- snapshot from C_HousingCustomizeMode
        originalDyes  = {},             -- for cancel/revert
        intent        = nil,            -- {slotID, dyeID} pending apply
        focusedSlot   = nil,
        cost          = { spend = 0, remove = 0 },  -- dye-bottle cost; LiveCustomizeObserver dispatches LIVE_COST_UPDATE
    }
end

-- Canonical baseline-snapshot helper. Builds the live.originalDyes table from
-- the raw dyeSlots array (Blizzard's C_HousingCustomizeMode.GetSelectedDecorInfo
-- shape: per-slot { ID, dyeColorID, channel, ... }). Used by both the
-- LIVE_MODE_ENTER reducer (initial baseline when the user clicks a decor) and
-- the LIVE_COMMIT_SNAPSHOT reducer (post-Apply baseline refresh). Single source
-- of truth for the originalDyes shape so consumers (Apply gate, Cancel,
-- live.cost selector) see consistent data regardless of which path produced it.
--
-- Empty slots read back from Blizzard as dyeColorID = 0 (no-dye sentinel; see
-- MCP gotcha on ApplyDyeToSelectedDecor). Normalising nil to 0 here means
-- consumers can compare with `orig.dyeID == cur.dyeColorID` without per-call
-- nil-coalescing.
local function buildOriginalDyes(dyeSlots)
    local snap = {}
    for i, slot in ipairs(dyeSlots or {}) do
        snap[i] = { slotID = slot.ID, dyeID = slot.dyeColorID or 0 }  -- exception(boundary): Blizzard's no-dye read is nil/0; normalize to sentinel 0
    end
    return snap
end

local function NewViewBucket()
    return {}   -- slots populated lazily by reducers; nil == "tab never set this"
end

local function NewSessionUI()
    return {
        -- Session-scoped (not persisted): persisting true caused a dead-key bug where
        -- reconciler only fires on state CHANGE, so saved-true + frame-hidden never resolved.
        windowShown          = false,
        configShown          = false,   -- config sidebar; session-scoped so it starts closed
        filterMode           = "category",
        activeTab            = "color",
        searchText           = "",
        -- Selection at top of session.ui (NOT byView): narrow invalidation for hover-preview.
        -- In byView it caused 60+ DataProvider rebuilds/sec while scanning the dye list.
        selectedDyeID        = nil,
        selectedHerbID       = nil,
        -- Pre-created so selectors do strict reads (byView[mode].X) without lazy-create guards.
        byView = {
            category = NewViewBucket(),
            custom   = NewViewBucket(),
            theme    = NewViewBucket(),
            crafting = NewViewBucket(),
            herbs    = NewViewBucket(),
            -- canvas view bucket. selectedCategory = catID (number) or nil for "All".
            -- (expandedDecors slot retired in Phase 5 along with the tree picker.)
            canvas   = { scope = nil, selectedCategory = nil },
        },
        canvas               = NewSessionCanvas(),
        live                 = NewSessionLive(),
    }
end

local function NewSession()
    return {
        ui       = NewSessionUI(),
        bag      = { tick = 0 },           -- bumped by BagObserver; selectors declare "session.bag.tick"
        -- names pre-init empty; nil = "still loading" in selectors.
        items    = { names = {}, tick = 0 },
        identity = { charKey = "", name = "", realm = "", class = "" }, -- HDGR 9363a86d: one slot, one writer
        catalog  = { tick = 0 },           -- ADR-001: invalidation seam for DecorInfoResolver
        -- VDS domain transient
        dyes = {
            -- Empty pre-init so selectors can strict-read before DYE_DATA_INIT lands.
            catalog   = {
                categories    = {},
                dyes          = {},
                infos         = {},
                itemIDToDye   = {},
                categoryNames = {},
            },
            ownership = { ownedCount = 0, totalCount = 0, perDye = {} },
        },
        -- Populated by DecorInfoResolver post CATALOG_READY. Array of { decorID, name,
        -- asset, isOwned, channels, icon, categoryID }.
        -- decorCategoryNames: catID -> human-readable name from C_HousingCatalog.
        --   Named distinctly from session.dyes.catalog.categoryNames (which is for
        --   dye color-groups, NOT housing decor categories).
        canvas = {
            dyeableDecor = {}, decorCategoryNames = {},
            -- Cold-start status surface. Driven by CATALOG_STATUS_SET from
            -- DecorInfoResolver. Read by canvas.picker.emptyHint selector to
            -- show "Loading... / Retrying N/M / Failed" to users.
            catalog = { status = "idle", attempt = 0, max = 3 },
        },
        stock = {
            -- BagObserver replaces perItem per scan. Selectors MUST bind session.stock.tick
            -- (not perItem) -- the reference flips each scan so a direct bind never invalidates.
            perItem = {},
            tick    = 0,
        },
        log     = {},
        combat  = { queue = {} },
    }
end

-- Ensure*: idempotent additive evolution. Extend when a release adds new slots.

local function EnsureAccount(account)
    account.config       = account.config       or NewConfig()
    -- New config sub-fields may land mid-cycle; backfill each.
    if account.config.scheme            == nil then account.config.scheme            = "DefaultDark" end
    if account.config.debug             == nil then account.config.debug             = false end
    if account.config.showMinimapButton == nil then account.config.showMinimapButton = true end
    if account.config.replaceBlizzard   == nil then account.config.replaceBlizzard   = true  end
    if account.config.ownedOnly         == nil then account.config.ownedOnly         = false end
    if account.config.forceCatalogEmpty == nil then account.config.forceCatalogEmpty = false end
    if account.config.debugAllDecor     == nil then account.config.debugAllDecor     = false end
    -- cartOnly was the "Show cart entries only" filter; deprecated.
    account.config.cartOnly = nil
    account.ui           = account.ui           or NewAccountUI()
    account.minimap      = account.minimap      or NewMinimap()
    account.dyeableItems = account.dyeableItems or {}
    account.customThemes = account.customThemes or {}
    -- account.cart removed in v2.x: Send-to-Auctionator reads deficit selectors
    -- directly; no intermediate persisted cart. Stale data on existing installs
    -- gets dropped here so the SavedVariables footprint shrinks on next save.
    account.cart = nil
    account.canvas       = account.canvas       or NewAccountCanvas()
    account.canvas.loadouts = account.canvas.loadouts or {}
    if account.canvas.autoSave        == nil then account.canvas.autoSave        = false end
    if account.canvas.previewDetached == nil then account.canvas.previewDetached = false end
    -- catalogSnapshot stays nil if absent -- not a default, an opt-in slot
    -- written on first successful live dispatch. No back-fill needed.
    account.live         = account.live         or NewAccountLive()
    account.live.recentDyes = account.live.recentDyes or {}
    if account.live.autoSave == nil then account.live.autoSave = false end
    account.migratedFrom = account.migratedFrom or NewMigratedFrom()

    -- Pre-v2 installs have no schemaVersion; set to 2 when warehouse landed.
    -- Future: `if account.schemaVersion < 3 then migrate_v2_to_v3() end`
    if account.schemaVersion == nil then account.schemaVersion = 2 end

    if not account.warehouse then
        -- Direct literal; when adding a sub-field also add a nil-guard in
        -- the else branch below, parallel to account.config handling above.
        account.warehouse = {
            targetGroups = {
                Red=1, Orange=1, Yellow=1, Green=1, Teal=1, Blue=1,
                Purple=1, Pink=1, Brown=1, Grey=1, Black=1, White=1,
            },
            targetDyes = {},
            fillMode   = "buy",
            buyGap     = true,
            collapsedGroups = {
                Red=true, Orange=true, Yellow=true, Green=true, Teal=true, Blue=true,
                Purple=true, Pink=true, Brown=true, Grey=true, Black=true, White=true,
            },
        }
    else
        local w = account.warehouse
        if w.targetGroups == nil then
            w.targetGroups = { Red=1, Orange=1, Yellow=1, Green=1, Teal=1,
                Blue=1, Purple=1, Pink=1, Brown=1, Grey=1, Black=1, White=1 }
        end
        if w.targetDyes == nil then w.targetDyes = {} end
        if w.fillMode   == nil then w.fillMode   = "buy" end
        if w.buyGap     == nil then w.buyGap     = true  end
        if w.collapsedGroups == nil then
            w.collapsedGroups = {
                Red = true, Orange = true, Yellow = true, Green = true, Teal = true,
                Blue = true, Purple = true, Pink = true, Brown = true, Grey = true,
                Black = true, White = true,
            }
        end
    end
end

-- Action meta registry + boot validator

function Store:RegisterActionMeta(meta)
    for actionType, def in pairs(meta) do
        self._actionMeta[actionType] = def
    end
end

function Store:GetMeta(actionType) return self._actionMeta[actionType] end

function Store:ValidateActionMetaExhaustiveness()
    local missing = {}
    for _, actionType in pairs(A) do
        local meta = self._actionMeta[actionType]
        if not (meta and meta.invalidates) then  -- exception(false-positive): exhaustiveness validator probes for missing meta
            missing[#missing + 1] = actionType
        end
    end
    if #missing > 0 then
        error("VDS.Store: missing action meta for " .. table.concat(missing, ", "), 2)
    end
end

-- Load from SavedVariables

function Store:LoadFromSavedVariables()
    _G[C.SAVEDVAR_KEY] = _G[C.SAVEDVAR_KEY] or {}
    local db = _G[C.SAVEDVAR_KEY]
    if not db.account then db.account = NewAccount() end
    self.state.account = db.account                     -- BY REFERENCE
    self.state.session = NewSession()
    EnsureAccount(self.state.account)
    self:MigrateFromVDS()
end

-- One-shot import from legacy VDS v1.x (pre-rewrite) VDS_DB layout. The v1
-- layout was flat -- VDS_DB.customDyeThemes, .replaceBlizzard, .dyeableItems
-- etc. at the top level. v2 (Lattice rewrite) nests everything under
-- VDS_DB.account. On first boot post-upgrade, harvest only customDyeThemes
-- (per release decision: palettes are the only legacy state worth keeping)
-- and wipe the orphan top-level fields so they don't bloat SavedVariables.
function Store:MigrateFromVDS()
    local mig = self.state.account.migratedFrom
    if mig.vds then return end
    local db = _G.VDS_DB
    if type(db) ~= "table" then mig.vds = true; return end

    -- Legacy v1 detection: dyeableItems was always populated by v1 and is the
    -- most reliable shape marker (customDyeThemes could legitimately be empty
    -- on a fresh v1 install). Skip migration entirely if neither marker present.
    local isLegacyV1 = type(db.dyeableItems) == "table"
                    or type(db.customDyeThemes) == "table"
    if not isLegacyV1 then mig.vds = true; return end

    local palettes = db.customDyeThemes  -- harvest before wipe

    -- Wipe all v1 top-level fields. Preserves db.account (the v2 shape, just
    -- created by LoadFromSavedVariables) and db.migratedFrom if it existed.
    for k in pairs(db) do
        if k ~= "account" and k ~= "migratedFrom" then db[k] = nil end
    end

    -- Inject preserved palettes.
    local acc = self.state.account
    local imported = 0
    if type(palettes) == "table" then
        for _, theme in ipairs(palettes) do
            acc.customThemes[#acc.customThemes + 1] = theme
            imported = imported + 1
        end
    end

    mig.vds   = true
    mig.vdsTS = (_G.time and _G.time()) or 0
    VDS.Log:Success("migrate",
        ("imported %d custom palette(s) from VDS v1; legacy state wiped"):format(imported))
end

function Store:GetState() return self.state end

-- Subscribe + Notify

function Store:Subscribe(fn)
    local token = {}
    self._subscribers[token] = fn
    return token
end

function Store:Unsubscribe(token)
    self._subscribers[token] = nil
end

local function notify(self, actionType, invalidation)
    -- Subscriber callbacks are our own controllers / theme repaints. Tier-1
    -- guards live at the actual protected-API call sites in the controllers
    -- (InCombatLockdown + pcall around SetAttribute, etc.). Blanket pcall
    -- here is the anti-pattern per Reference/MIDNIGHT_SECRET_VALUES.md --
    -- it converts real bugs (selector returns wrong shape, controller
    -- call-site typo) into silent "subscriber error" log lines + corrupt
    -- downstream state. ErrorBoundaryMiddleware on the dispatch chain
    -- handles crash-recovery at the outer layer. Matches HDGR pattern.
    for _, fn in pairs(self._subscribers) do
        fn(actionType, invalidation)
    end
end

-- Reducer helpers

local function ringPush(ring, value, max)
    -- Most-recent-first; dedupe by removing the prior occurrence then prepending.
    for i = #ring, 1, -1 do
        if ring[i] == value then table.remove(ring, i) end
    end
    table.insert(ring, 1, value)
    while #ring > max do table.remove(ring) end
end

local function cycleNext(list, current)
    for i, v in ipairs(list) do
        if v == current then return list[(i % #list) + 1] end
    end
    return list[1]
end

-- Dispatch + reducer
--
-- Reentrancy: inner Dispatches (e.g. BE -> Apply -> row factory -> Dispatch) are
-- queued and drained by the outer; one notify fires per outer Dispatch with combined
-- invalidations. Running inner Dispatch sync would re-enter Apply mid-binding and
-- corrupt the scrollBox data provider.

function Store:Dispatch(action)
    if type(action) ~= "table" or not action.type then
        error("Store:Dispatch: malformed action", 2)
    end
    if not self._actionMeta[action.type] then
        error("Store:Dispatch: unknown action " .. tostring(action.type), 2)
    end

    -- Reentrant call: enqueue and let the outer Dispatch drain.
    if self._dispatching then
        self._queue = self._queue or {}
        self._queue[#self._queue + 1] = action
        return
    end

    self._dispatching = true
    self._queue = { action }
    local primaryType = action.type
    self._dispatchCounts = self._dispatchCounts or {}  -- DIAGNOSTIC
    self._dispatchCounts[action.type] = (self._dispatchCounts[action.type] or 0) + 1

    while #self._queue > 0 do
        -- Drain queue into a batch; subsequent enqueues during reducer
        -- accumulate in self._queue and are picked up by the next loop.
        local batch = self._queue
        self._queue = {}
        local combined = {}
        for _, a in ipairs(batch) do
            local inv = self:_RunReducer(a)
            if type(inv) == "table" then
                for _, p in ipairs(inv) do combined[#combined + 1] = p end
            end
        end
        notify(self, primaryType, combined)
        -- Subscribers may enqueue more during notify; loop picks them up.
    end

    self._dispatching = false
end

-- Single-action reducer: mutates state, returns invalidation paths. Does NOT notify.
function Store:_RunReducer(action)
    local meta = self._actionMeta[action.type]
    local payload = action.payload or {}
    local s   = self.state
    local acc = s.account
    local ses = s.session
    local ui  = ses.ui

    -- Reducer
    if action.type == A.MAIN_WINDOW_TOGGLE then
        ses.ui.windowShown = not ses.ui.windowShown

    elseif action.type == A.MAIN_WINDOW_SHOW then
        ses.ui.windowShown = true

    elseif action.type == A.MAIN_WINDOW_HIDE then
        ses.ui.windowShown = false

    elseif action.type == A.MAIN_CONFIG_TOGGLE then
        ses.ui.configShown = not ses.ui.configShown

    elseif action.type == A.CONFIG_SET then
        acc.config[payload.key] = payload.value

    elseif action.type == A.HARD_RESET then
        -- The new account MUST be anchored back into _G[SAVEDVAR_KEY] so
        -- the persist tick writes the reset defaults, not the dead-by-
        -- reference old table. See review §6 / cookbook 11.
        _G[C.SAVEDVAR_KEY] = {}
        _G[C.SAVEDVAR_KEY].account = NewAccount()
        self.state.account = _G[C.SAVEDVAR_KEY].account
        EnsureAccount(self.state.account)

    elseif action.type == A.BAG_TICK then
        ses.bag.tick = ses.bag.tick + 1

    elseif action.type == A.ITEM_NAME_RESOLVED then
        -- names table mutated in-place; tick is the invalidation seam.
        -- payload.name == nil is the "retry hint" path: GET_ITEM_INFO_RECEIVED
        -- fired but both name caches were still empty in the handler. Bump
        -- tick to force a selector re-derive so the row factory gets another
        -- chance to call Request, which may now find a name on a later event.
        if payload.name then
            ses.items.names[payload.itemID] = payload.name
        end
        ses.items.tick = ses.items.tick + 1
        VDS.Log:Debug("items", string.format("[4] reducer id=%s name='%s' tick=%d",
            tostring(payload.itemID), tostring(payload.name), ses.items.tick))

    elseif action.type == A.ITEM_NAMES_BATCH_RESOLVED then
        -- Debounced batch: ItemEventListener callbacks accumulate into
        -- payload.entries (array of {itemID, name}); reducer applies all in
        -- one shot and bumps tick ONCE. 50 cold resolves in one frame -> 1
        -- tick bump instead of 50 (which would cause 50 selector re-derives
        -- per visible widget with session.items.tick in reads).
        local entries = payload.entries
        if type(entries) == "table" then
            for _, e in ipairs(entries) do
                if e.name then ses.items.names[e.itemID] = e.name end
            end
            if #entries > 0 then
                ses.items.tick = ses.items.tick + 1
                VDS.Log:Debug("items", string.format("[4] reducer batch n=%d tick=%d",
                    #entries, ses.items.tick))
            end
        end

    elseif action.type == A.SESSION_IDENTITY_SET then
        local id = ses.identity
        id.name    = payload.name
        id.realm   = payload.realm
        id.class   = payload.class
        id.charKey = payload.name .. "-" .. payload.realm

    -- ===== Filter / mode =====
    elseif action.type == A.SET_FILTER_MODE then
        -- Guard: invalid mode (e.g. misrouted chip "buy"/"craft") has no byView bucket;
        -- a nil index crash would follow. Ignore silently; state stays valid.
        if ui.byView[payload.mode] ~= nil then
            ui.filterMode = payload.mode
        end

    elseif action.type == A.SET_SEARCH_TEXT then
        ui.searchText = payload.text

    elseif action.type == A.SET_OWNED_ONLY then
        acc.config.ownedOnly = not not payload.value

    elseif action.type == A.SET_REPLACE_BLIZZARD then
        acc.config.replaceBlizzard = not not payload.value

    elseif action.type == A.SET_ACTIVE_TAB then
        ui.activeTab = payload.tab

    elseif action.type == A.SET_ACTIVE_SUBFILTER then
        -- kind selects which slot to set; all writes go through the current view bucket.
        local kind, value = payload.kind, payload.value
        local bucket = ui.byView[ui.filterMode]
        if     kind == "colorGroup"     then bucket.activeColorGroup     = value
        elseif kind == "category"       then bucket.activeCategory       = value
        elseif kind == "craftGroup"     then bucket.activeCraftGroup     = value
        elseif kind == "herbGroup"      then bucket.activeHerbGroup      = value
        elseif kind == "herbExpansion"  then bucket.activeHerbExpansion  = value
        elseif kind == "theme"          then bucket.activeTheme          = value
        elseif kind == "themePalette"   then bucket.activeThemePalette   = value
        else error("SET_ACTIVE_SUBFILTER: unknown kind " .. tostring(kind), 2) end

    elseif action.type == A.SET_HERB_SORT_MODE then
        -- Two forms accepted:
        --   payload.mode == nil  -> cycle to next entry in HERB_SORT_MODES_CYCLE
        --   payload.mode == "..."-> set explicit value
        local bucket = ui.byView[ui.filterMode]
        if payload.mode then
            bucket.herbSortMode = payload.mode
        else
            bucket.herbSortMode = cycleNext(C.HERB_SORT_MODES_CYCLE, bucket.herbSortMode or "Expac")
        end

    elseif action.type == A.SET_SELECTED_DYE then
        ui.selectedDyeID = payload.id

    elseif action.type == A.SET_SELECTED_HERB then
        ui.selectedHerbID = payload.id

    -- ===== Cart =====
    -- Removed: account.cart and CART_ADD_DYE / HERB_CART_ADD. The Send-to-AH
    -- button now reads stock.buy.cart / stock.herbs.list directly -- each click
    -- recomputes from current deficit, no persisted projection.

    -- ===== Custom themes =====
    elseif action.type == A.CUSTOM_THEME_CREATE then
        acc.customThemes[#acc.customThemes + 1] = {
            name = payload.name, dyes = payload.dyes, rgb = payload.rgb,
        }
        -- Auto-enter edit mode (CUSTOM_THEME_TOGGLE_DYE fires while editingThemeName is set).
        ui.byView[ui.filterMode].editingThemeName = payload.name
        ui.byView[ui.filterMode].activeTheme      = payload.name

    elseif action.type == A.CUSTOM_THEME_DELETE then
        for i, t in ipairs(acc.customThemes) do
            if t.name == payload.name then table.remove(acc.customThemes, i); break end
        end
        for _, bucket in pairs(ui.byView) do
            if bucket.activeTheme      == payload.name then bucket.activeTheme      = nil end
            if bucket.editingThemeName == payload.name then bucket.editingThemeName = nil end
        end

    elseif action.type == A.CUSTOM_THEME_RENAME then
        for _, t in ipairs(acc.customThemes) do
            if t.name == payload.from then t.name = payload.to end
        end
        for _, bucket in pairs(ui.byView) do
            if bucket.activeTheme      == payload.from then bucket.activeTheme      = payload.to end
            if bucket.editingThemeName == payload.from then bucket.editingThemeName = payload.to end
        end

    elseif action.type == A.CUSTOM_THEME_EDIT_START then
        ui.byView[ui.filterMode].editingThemeName = payload.name
        ui.byView[ui.filterMode].activeTheme      = payload.name

    elseif action.type == A.CUSTOM_THEME_EDIT_END then
        ui.byView[ui.filterMode].editingThemeName = nil

    elseif action.type == A.CUSTOM_THEME_TOGGLE_DYE then
        for _, t in ipairs(acc.customThemes) do
            if t.name == payload.name then
                local found = false
                for i, d in ipairs(t.dyes) do
                    if d == payload.dyeID then table.remove(t.dyes, i); found = true; break end
                end
                if not found then t.dyes[#t.dyes + 1] = payload.dyeID end
                break
            end
        end

    -- ===== Canvas =====
    elseif action.type == A.CANVAS_SET_DECOR then
        local c = ui.canvas
        c.decorID       = payload.decorID
        c.dyes          = { [0] = nil, [1] = nil, [2] = nil }
        -- Default to the first AVAILABLE channel, not hardcoded 0: sparse decor
        -- ({1,2}) has no channel 0, so a 0 default applied dyes to a dead channel.
        -- Mirrors CANVAS_APPLY_LOADOUT below.
        c.activeChannel = (payload.channelSet and payload.channelSet[1]) or 0
        c.numChannels   = payload.numChannels
        c.channelSet    = payload.channelSet

    elseif action.type == A.CANVAS_SET_CHANNEL_DYE then
        ui.canvas.dyes[payload.ch] = payload.dyeID

    elseif action.type == A.CANVAS_CLEAR_CHANNEL then
        ui.canvas.dyes[payload.ch] = nil

    elseif action.type == A.CANVAS_RANDOM then
        -- Random pick computed by Canvas controller per channel constraints; supplied in payload.dyes.
        ui.canvas.dyes = payload.dyes or { [0] = nil, [1] = nil, [2] = nil }

    elseif action.type == A.CANVAS_HARMONISE then
        -- Harmonise fills every channel from the autoFill engine; computed by
        -- Controller_Canvas. Active channel keeps its current dye; other channels
        -- receive the strategy's picks. Shape mirrors CANVAS_RANDOM.
        ui.canvas.dyes = payload.dyes or { [0] = nil, [1] = nil, [2] = nil }

    elseif action.type == A.CANVAS_SET_ACTIVE_CHANNEL then
        ui.canvas.activeChannel = payload.ch

    elseif action.type == A.CANVAS_CYCLE_ACTIVE_CHANNEL then
        -- Donor: VDS_Canvas.lua CycleActiveChannel. Must `break` not `return` --
        -- a bare return here previously exited Dispatch before notify() fired.
        local set = ui.canvas.channelSet or { 0, 1, 2 }
        if #set > 0 then
            local cur = ui.canvas.activeChannel or set[1]
            local matched = false
            for i, ch in ipairs(set) do
                if ch == cur then
                    ui.canvas.activeChannel = set[(i % #set) + 1]
                    matched = true
                    break
                end
            end
            if not matched then ui.canvas.activeChannel = set[1] end
        end

    elseif action.type == A.CANVAS_SET_PICKER_MODE then
        ui.canvas.pickerMode = payload.mode

    elseif action.type == A.CANVAS_SET_PICKER_SEARCH then
        ui.canvas.pickerSearch = payload.text

    elseif action.type == A.CANVAS_SAVE_LOADOUT then
        local id = payload.decorID
        acc.canvas.loadouts[id] = acc.canvas.loadouts[id] or {}
        local list = acc.canvas.loadouts[id]
        local newDyes = payload.dyes

        -- Dedupe-on-save (donor PromoteIfDuplicate): matching {d0,d1,d2} -> promote to head.
        local matchedIdx
        for i, existing in ipairs(list) do
            local ed = existing.dyes or {}
            if ed[0] == newDyes[0] and ed[1] == newDyes[1] and ed[2] == newDyes[2] then
                matchedIdx = i; break
            end
        end
        if matchedIdx then
            local promoted = table.remove(list, matchedIdx)
            promoted.ts = (_G.time and _G.time()) or promoted.ts
            if payload.name and payload.name ~= "" then promoted.name = payload.name end
            table.insert(list, 1, promoted)
        else
            -- Cap per decor; oldest dropped at cap.
            local entry = {
                dyes = newDyes,
                name = payload.name,
                ts   = (_G.time and _G.time()) or 0,
            }
            table.insert(list, 1, entry)
            while #list > C.FLOATING_WIDGET.CANVAS_MAX_LOADOUTS_PER_DECOR do
                table.remove(list)
            end
        end

        -- Sweep: remove duplicate dye-combos anywhere in the list (cleans up
        -- legacy dups saved before PromoteIfDuplicate landed AND any pair the
        -- promote-on-save check missed). Keeps the first occurrence (head =
        -- most-recently-saved). Mutates list in-place per ADR-007.
        do
            local seen, cleaned = {}, {}
            for _, e in ipairs(list) do
                local d = e.dyes or {}
                local key = tostring(d[0]) .. "|" .. tostring(d[1]) .. "|" .. tostring(d[2])
                if not seen[key] then
                    seen[key] = true
                    cleaned[#cleaned + 1] = e
                end
            end
            for i = #list, 1, -1 do list[i] = nil end
            for i, e in ipairs(cleaned) do list[i] = e end
        end

    elseif action.type == A.CANVAS_DELETE_LOADOUT then
        local list = acc.canvas.loadouts[payload.decorID]
        if list then table.remove(list, payload.idx) end

    elseif action.type == A.CANVAS_RENAME_LOADOUT then
        local list = acc.canvas.loadouts[payload.decorID]
        if list and list[payload.idx] then list[payload.idx].name = payload.name end

    elseif action.type == A.CANVAS_AUTOSAVE_SET then
        acc.canvas.autoSave = not not payload.value

    -- CANVAS_TOGGLE_EXPAND retired in Phase 5 (tree replaced by flat list).

    elseif action.type == A.CANVAS_APPLY_LOADOUT then
        -- One shot: single dispatch repaints model + channel rows (donor ApplyLoadout).
        local c = ui.canvas
        c.decorID       = payload.decorID
        c.numChannels   = payload.numChannels
        c.channelSet    = payload.channelSet
        c.dyes          = payload.dyes or { [0] = nil, [1] = nil, [2] = nil }
        c.activeChannel = (payload.channelSet and payload.channelSet[1]) or 0

    elseif action.type == A.CANVAS_PREVIEW_DETACH_SET then
        acc.canvas.previewDetached = not not payload.detached

    elseif action.type == A.CANVAS_SET_SCOPE then
        -- payload.value = { kind = "theme"|"custom", name = "..." } or nil for "All Dyes".
        -- Sticky across view-switches (ADR-008 spirit); reset button clears explicitly.
        ui.byView.canvas.scope = payload.value

    elseif action.type == A.CANVAS_SET_CATEGORY then
        -- payload.value = catID (number) or nil for "All". Filters the canvas
        -- decor list to a single Housing catalog category. Sticky per ADR-008.
        ui.byView.canvas.selectedCategory = payload.value

    -- ===== Live mode =====
    elseif action.type == A.LIVE_MODE_ENTER then
        local L = ui.live
        L.active        = true
        L.decorID       = payload.decorID
        L.decorName     = payload.decorName
        L.slots         = payload.dyeSlots
        -- originalDyes derived from payload.dyeSlots via the shared helper
        -- (was previously pre-computed by the observer as a separate code
        -- path; unified 2026-05-27 so LIVE_MODE_ENTER and LIVE_COMMIT_SNAPSHOT
        -- emit identical shapes).
        L.originalDyes  = buildOriginalDyes(payload.dyeSlots)
        -- Auto-focus first slot so dye-row clicks dispatch LIVE_SET_SLOT_DYE_INTENT immediately
        -- (donor VDS_LivePreview.lua:632-634).
        if payload.dyeSlots and payload.dyeSlots[1] then
            L.focusedSlot = payload.dyeSlots[1].ID
        end
        -- Edit-in-place semantics don't apply in live mode (user is dyeing decor,
        -- not editing palettes). Clear any leftover editingThemeName so chip-click
        -- palette filtering works on the main dye list.
        local cb = ui.byView.custom
        if cb then cb.editingThemeName = nil end

    elseif action.type == A.LIVE_MODE_EXIT then
        local L = ui.live
        L.active        = false
        L.decorID       = nil
        L.decorName     = nil
        L.slots         = nil
        L.originalDyes  = {}
        L.intent        = nil
        L.focusedSlot   = nil
        L.cost          = { spend = 0, remove = 0 }

    elseif action.type == A.LIVE_SLOTS_UPDATE then
        ui.live.slots = payload.dyeSlots

    elseif action.type == A.LIVE_SET_SLOT_DYE_INTENT then
        ui.live.intent = { slotID = payload.slotID, dyeID = payload.dyeID }

    elseif action.type == A.LIVE_SLOT_FOCUS_SET then
        ui.live.focusedSlot = payload.slotID

    elseif action.type == A.LIVE_RECENT_DYE_ADD then
        ringPush(acc.live.recentDyes, payload.dyeID, C.LIVE_RECENT_DYES_MAX)

    elseif action.type == A.LIVE_AUTOSAVE_SET then
        acc.live.autoSave = not not payload.value

    elseif action.type == A.LIVE_COST_UPDATE then
        ui.live.cost = { spend = payload.spend, remove = payload.remove }

    elseif action.type == A.LIVE_COMMIT_SNAPSHOT then
        -- Re-snapshot live.originalDyes from the dyeSlots passed in the
        -- payload (the controller sends current live.slots, captured at
        -- dispatch time). Dispatched by dispatchApply right after
        -- CommitDyesForSelectedDecor so the "what was committed" baseline
        -- tracks each successful commit, not just the LIVE_MODE_ENTER
        -- moment. Apply gate's slot-vs-original comparison stays accurate
        -- after each commit instead of going stale.
        ui.live.originalDyes = buildOriginalDyes(payload.dyeSlots)

    -- ===== Warehouse =====
    elseif action.type == A.STOCK_TICK then
        -- BagObserver emits a fresh table per scan; safe to replace. Selectors use tick, not perItem ref.
        ses.stock.perItem = payload.perItem
        ses.stock.tick    = ses.stock.tick + 1

    elseif action.type == A.STOCK_SET_GROUP_TARGET then
        acc.warehouse.targetGroups[payload.group] = math.max(0, payload.value)

    elseif action.type == A.STOCK_BUMP_ALL_GROUPS then
        -- Applies delta to every group (>= 0 clamp); per-dye overrides preserved.
        local delta = payload.delta
        for group, v in pairs(acc.warehouse.targetGroups) do
            acc.warehouse.targetGroups[group] = math.max(0, v + delta)
        end

    elseif action.type == A.STOCK_RESET_ALL_TARGETS then
        -- Reset: every group target to 1, all per-dye overrides cleared.
        for group in pairs(acc.warehouse.targetGroups) do
            acc.warehouse.targetGroups[group] = 1
        end
        acc.warehouse.targetDyes = {}

    elseif action.type == A.STOCK_SET_DYE_TARGET then
        -- Auto-clear: override == group default -> remove it (mockup invariant).
        -- Phantom overrides from later group-default changes are tolerated (pruning needs catalog).
        local default = acc.warehouse.targetGroups[payload.group] or 0  -- exception(nullable): no group target set = 0
        local v = math.max(0, payload.value)
        if v == default then
            acc.warehouse.targetDyes[payload.dyeID] = nil
        else
            acc.warehouse.targetDyes[payload.dyeID] = v
        end

    elseif action.type == A.STOCK_RESET_DYE_OVERRIDE then
        acc.warehouse.targetDyes[payload.dyeID] = nil

    elseif action.type == A.STOCK_SET_FILL_MODE then
        if payload.mode == "buy" or payload.mode == "craft" then
            acc.warehouse.fillMode = payload.mode
        end

    elseif action.type == A.STOCK_SET_BUY_GAP then
        acc.warehouse.buyGap = not not payload.value

    elseif action.type == A.STOCK_TOGGLE_GROUP then
        local g = payload.group
        if g then
            local cur = acc.warehouse.collapsedGroups[g]
            acc.warehouse.collapsedGroups[g] = (not cur) and true or nil
        end

    elseif action.type == A.STOCK_COLLAPSE_ALL_GROUPS then
        -- targetGroups is the canonical 12-key source; avoids coupling to StaticData.COLOR_GROUP_ORDER.
        for group in pairs(acc.warehouse.targetGroups) do
            acc.warehouse.collapsedGroups[group] = true
        end

    elseif action.type == A.STOCK_EXPAND_ALL_GROUPS then
        -- Empty map = all expanded (absence == expanded; truthy presence == collapsed).
        for k in pairs(acc.warehouse.collapsedGroups) do
            acc.warehouse.collapsedGroups[k] = nil
        end

    -- ===== Ownership + scan =====
    elseif action.type == A.OWNERSHIP_TICK then
        ses.dyes.ownership = {
            ownedCount = payload.ownedCount,
            totalCount = payload.totalCount,
            perDye     = payload.perDye,
        }

    elseif action.type == A.DYEABLE_ITEMS_UPDATE then
        for _, itemID in ipairs(payload.itemIDs or {}) do
            acc.dyeableItems[itemID] = true
        end

    elseif action.type == A.DYE_DATA_INIT then
        ses.dyes.catalog = {
            categories  = payload.categories,
            dyes        = payload.dyes,
            infos       = payload.infos,           -- {name, nameLower, colorGroup, itemID, catID, swatchRGB}; selectors stay pure (invariant §1)
            itemIDToDye = payload.itemIDToDye,     -- reverse map: theme + crafting resolve palette itemIDs to dyeIDs
            categoryNames = payload.categoryNames, -- catID -> label; read by DyeOwnershipObserver:ResolveCategoryName
        }

    -- ===== Catalog (ADR-001 amendment) =====
    elseif action.type == A.CATALOG_READY then
        ses.catalog.tick = ses.catalog.tick + 1

    elseif action.type == A.CANVAS_CATALOG_UPDATE then
        ses.canvas.dyeableDecor       = payload.items
        ses.canvas.decorCategoryNames = payload.categoryNames
        -- Snapshot to account.canvas for cross-session fallback. Boot
        -- rehydrate reads this slot to populate session.canvas.dyeableDecor
        -- before the live searcher returns. See DecorInfoResolver:onEnable.
        -- payload.fromSnapshot=true skips the write to avoid overwriting
        -- the live snapshot when we're hydrating from it.
        if not payload.fromSnapshot then
            acc.canvas.catalogSnapshot = {
                items         = payload.items,
                categoryNames = payload.categoryNames,
                ts            = (_G.time and _G.time()) or 0,  -- boundary: test env may lack time
            }
            VDS.Log:Debug("catalog", string.format(
                "[C5] reducer CANVAS_CATALOG_UPDATE items=%d (snapshot saved to SV)",
                payload.items and #payload.items or 0))
        else
            VDS.Log:Debug("catalog", string.format(
                "[C5] reducer CANVAS_CATALOG_UPDATE items=%d (from snapshot; SV unchanged)",
                payload.items and #payload.items or 0))
        end

    elseif action.type == A.CATALOG_STATUS_SET then
        ses.canvas.catalog.status = payload.status or "idle"  -- exception(optional): status omitted = reset to idle
        if payload.attempt ~= nil then ses.canvas.catalog.attempt = payload.attempt end

    -- ===== Window + minimap =====
    elseif action.type == A.PANEL_POSITION_SAVE then
        -- titlePanel.TOPLEFT is invariant across view changes (collapse, side panels, canvas extension).
        acc.panelPosition = { titleX = payload.titleX, titleY = payload.titleY }

    elseif action.type == A.MINIMAP_HIDE_SET then
        acc.minimap.hide = not not payload.hide

    elseif action.type == A.MINIMAP_POSITION_SAVE then
        acc.minimap.minimapPos = payload.pos
    end

    -- Resolve invalidation set; returned to Dispatch for batching (no notify here).
    if type(meta.invalidates) == "function" then
        return meta.invalidates(action)
    end
    return meta.invalidates
end

function Store:_RawReset() -- test seam: reset state between cases without re-dofile
    self.state = {}
    self._subscribers = {}
end

-- Export factories for tests (test_state_factories reads them).
Store._factories = {
    NewAccount      = NewAccount,
    NewWarehouse    = NewWarehouse,
    NewSession      = NewSession,
    NewConfig       = NewConfig,
    NewAccountUI    = NewAccountUI,
    NewMinimap      = NewMinimap,
    NewAccountCanvas = NewAccountCanvas,
    NewAccountLive  = NewAccountLive,
    NewSessionUI    = NewSessionUI,
    NewSessionCanvas = NewSessionCanvas,
    NewSessionLive  = NewSessionLive,
    NewMigratedFrom = NewMigratedFrom,
}
