-- ============================================================================
-- Housing Decor Guide - Store (Simplified)
-- Direct state management with persistence
-- ============================================================================

local addonName, privateDB = ...
local HDG = _G.HousingDecorGuide

-- Deep copy helper (only used for array operations)
local function DeepCopy(orig)
    local copy
    if type(orig) == "table" then
        copy = {}
        for k, v in pairs(orig) do
            copy[k] = DeepCopy(v)
        end
    else
        copy = orig
    end
    return copy
end

-- Authoritative key sets for flat-schema (v2) load/save. Source of truth
-- for which top-level HDG_DB.* keys are config/ui-routed. The proxy at
-- HDG_Config.lua duplicates these temporarily; they're removed in Phase 8.
local CONFIG_KEYS = {
    "debug", "showMinimapButton", "theme", "scale", "fontScale", "fontFamily",
    "bgOpacity", "quotesEnabled", "vamooseChattiness", "vamooseChatOutput",
    "lumberQuotesEnabled", "craftQuotesEnabled", "quotesOnlyChat",
    "closeProfWindowAfterCraft", "showProfessionButtons",
    "preferredPriceAddon", "tsmPriceMode", "showRawMaterials",
    "myMaterialsMode", "materialSortMode", "craftableFilter",
    "knownFilter", "expansionFilter", "searchReagents", "uncollectedFilter",
    "lumberCollapsed", "lumberAutoShow", "showLumberMaterials",
    "lumberHUDEnabled", "lumberHUDPosition", "lumberHUDVisible",
    "lumberRadarPinToTracker", "tooltipDecorTag",
    "zoneScannerEnabled", "zoneScannerPopup", "zoneScannerSquirrels",
    "zoneScannerChat", "zoneScannerSound", "shopListAutoDecrement",
    "defaultTab", "localeOverride",
}
local UI_KEYS = {
    "framePosition", "activeTab", "professionFilter",
    "characterProfessionFilter", "collapsedMaterials",
    "trainersCollapsed", "stylesCollapsed",
    "hiddenCharacters",  -- written via state.ui.hiddenCharacters by HDG_AltsTab; v2-flat must persist or hidden state resets every /reload
    "lumberPinned", "pinnedLumberPosition",
    "vendorListPosition", "vendorListPinned",
    "materialsListPosition", "materialsListPinned",
}
-- Expose for HDG_Migration.lua (which writes flat keys before Store loads)
-- and for tests. Keep table identity stable: do not reassign these locals.
HDG = HDG or {}
HDG.Store = HDG.Store or {}
HDG.Store._CONFIG_KEYS = CONFIG_KEYS
HDG.Store._UI_KEYS = UI_KEYS

-- Default state template
local DEFAULT_STATE = {
    config = {
        debug = false,
        showMinimapButton = true,
        theme = "HDGDefault",
        scale = 1.0,
        fontScale = 0,
        fontFamily = "FRIZQT__",
        bgOpacity = 0.95,
        quotesEnabled = true,
        vamooseChattiness = 30,
        vamooseChatOutput = false,
        lumberQuotesEnabled = true,
        craftQuotesEnabled = true,
        quotesOnlyChat = false,
        closeProfWindowAfterCraft = true,
        showProfessionButtons = true,
        preferredPriceAddon = nil,
        tsmPriceMode = "server",
        showRawMaterials = false,
        myMaterialsMode = false,
        materialSortMode = "totals",
        craftableFilter = false,
        knownFilter = false,
        expansionFilter = "All",
        searchReagents = false,
        uncollectedFilter = false,
        lumberCollapsed = false,
        lumberAutoShow = false,
        showLumberMaterials = false,
        lumberHUDEnabled = true,
        lumberHUDPosition = nil,
        lumberHUDVisible = false,
        lumberRadarPinToTracker = false,
        tooltipDecorTag = true,
        zoneScannerEnabled = false,
        zoneScannerPopup = false,
        zoneScannerSquirrels = false,
        zoneScannerChat = true,
        zoneScannerSound = false,
        shopListAutoDecrement = true,
        defaultTab = nil,  -- If set, switches to this tab on first main-frame show per session. Right-click any tab button to set.
        localeOverride = nil,  -- locale code string or nil; doc-only, Lua does not store nil
    },
    ui = {
        framePosition = nil,
        activeTab = "recipes",
        professionFilter = "All",
        characterProfessionFilter = {},
        collapsedMaterials = {},
        trainersCollapsed = {},
        stylesCollapsed = {},
        hiddenCharacters = {},
        lumberPinned = false,
        pinnedLumberPosition = nil,
        vendorListPosition = nil,
        vendorListPinned = false,
        materialsListPosition = nil,
        materialsListPinned = false,
    },
    craftingQueue = {},
    mogul = {
        lumberBudget = nil,         -- nil means "use live count"
        viewMode = "char",          -- "char" | "account"
        optimizeBy = "lumberOnly",  -- "lumberOnly" | "lumberPlusMats"
        mode = "profit",            -- "profit" | "collection"
        algorithm = "greedy",       -- "greedy" | "lp"
        showCandidates = false,
        -- Supply-impact controls how the optimizer treats listing-this-many-on-the-AH
        -- as a market-pressure problem. mode = off (current behaviour, ignore impact),
        -- smooth (apply per-unit decay so each successive craft of the same recipe is
        -- worth less in the ranking), or cap (hard ceiling: refuse to plan more than N
        -- of any single recipe). Account-scoped via HDG_DB.mogul.
        supplyImpact = {
            mode  = "smooth",       -- "off" | "smooth" | "cap"
            cap   = 10,             -- max crafts per recipe in cap mode
            decay = 0.07,           -- per-unit revenue haircut in smooth mode (0..1)
        },
    },
    characters = {},
    craftingHistory = {},
    trackedReagents = {},
    minimap = {
        hide = false,
        minimapPos = 220,
        lock = false,
        showMinimapButton = true,
        showInCompartment = true,
        icon = nil,
    },
    lumberCoordinates = {},
    farmingHistory = {},
    vendorShoppingLists = {}, -- map of listId -> { name, items, meta, createdAt }
    activeShoppingListId = "default",
    -- House Tab cockpit layout state. Mutated only via Store setters
    -- (SetWidgetEnabled, SetWidgetOrder, SetWidgetWidth, SetWidgetOverride,
    -- ResetWidgetOverrides, ResetHouseTabLayout). Setters fire
    -- HDG_HT_LAYOUT_CHANGED so the cockpit re-renders without manual calls.
    houseTab = {
        enabled         = {},  -- [widgetID] = true | false (nil = use spec default)
        order           = {},  -- ordered array of widgetIDs (overrides spec ordering)
        width           = {},  -- [widgetID] = "third" | "twoThirds" | "full"
        layoutOverrides = {},  -- [widgetID] = { minHeight, padding, headerHeight, iconSize, ... }
    },
    -- Unified decor-collection table. Stores Styles, Crates, Library entries,
    -- and Shipping Crates under a single shape: { id, type, name, members,
    -- parent (optional), ...metadata }. Populated initially by the
    -- v1-collections migration from customStyles + projects.crates.
    -- Use HDG.Store:Collection_* methods for all reads/writes.
    collections = {},
    -- Migration flags — see HDG_Migration.lua.
    migrations = {},
    layout = {},
    userNotes = {},
    vendorNotes = {},
    vendorNameCache = {},
    vendorCurrencyCache = {},
    -- Projects module — house topology, crates, library, plans, etc.
    -- Schema seeded for the full Projects feature surface; P1 uses houses/rooms/ui.
    -- See docs/superpowers/specs/2026-05-01-hdg-projects-module-design.md
    projects = {
        schemaVersion  = 1,
        houses         = {},
        rooms          = {},
        crates         = {},
        library        = {},
        shippingCrates = {},
        plans          = {},
        orphans        = {},
        -- Manual room-connection assertions, keyed by floorID. Each entry is
        -- { fromID, fromDir, toID, toDir }. _floorLayout seeds these before
        -- running cardinal-matching, so user assertions override the
        -- algorithm's guesses.
        connections    = {},
        ui = {
            activeView      = "landing",
            selectedHouse   = nil,
            selectedFloor   = 1,
            selectedRoomID  = nil,
            selectedCrateID = nil,
            activeMode      = "reflect",
            activePlanID    = nil,
        },
    },
    -- Module-owned state (migrated from direct-to-HDG_DB). Modules read/write
    -- via HDG.Store.state.X + QueueSave().
    collection = nil,        -- HDG_Data.lua — {version, ownedDecorIDs, lastSweep}
    directPriceCache = {},   -- HDG_PriceIntegration.lua — itemID -> price
    directPriceCacheTime = nil, -- HDG_PriceIntegration.lua — last AH scan timestamp
    ownedAuctions = {},      -- HDG_GoblinTab.lua — { [auctionID] = {...} }
    -- NOTE: HDG_DB.recentActivity still written direct by HDG_RecentActivity.lua
    -- (migration deferred). Preserve-line in SaveToSavedVariables keeps it alive.
    altsViewMode = "skills",     -- was HDG_Settings fallthrough; migrated in v2-flatten-savedvars
    recentActivityCap = 20,      -- was HDG_Settings.recentActivity.sessionCap
    -- pendingCraftInfo lives on privateDB.pendingCraftInfo (HDG_PowerCrafting); not Store-state.
}

HDG.Store = {
    state = DeepCopy(DEFAULT_STATE),
    saveTimer = nil,
    onThemeChange = nil,  -- Theme change callback
}

function HDG.Store:GetState()
    return self.state
end

-- Read accessors. These wrap the common state walks that callers used to
-- inline ("state and state.config and state.config[key]"); keep them here
-- so consumers can write HDG.Store:GetConfig("key") instead of duplicating
-- the nil-guard chain across every UI module.

function HDG.Store:GetConfig(key)
    return self.state and self.state.config and self.state.config[key]
end

function HDG.Store:GetUIState(key)
    return self.state and self.state.ui and self.state.ui[key]
end

function HDG.Store:GetCraftingQueue()
    return self.state and self.state.craftingQueue or {}
end

-- Per-character profession filter (Recipes tab profession sidebar). Stored
-- under state.ui.characterProfessionFilter[charKey]. The Store hides the
-- charKey lookup so callers don't have to duplicate the GetCharacterKey() +
-- nil-guard chain. Defaults to "All" when no entry exists for the character.
function HDG.Store:GetCharacterProfessionFilter()
    local charKey = HDG_Data and HDG_Data.GetCharacterKey and HDG_Data.GetCharacterKey()
    local map = self:GetUIState("characterProfessionFilter")
    return (charKey and map and map[charKey]) or "All"
end

function HDG.Store:SetCharacterProfessionFilter(professionName)
    local charKey = HDG_Data and HDG_Data.GetCharacterKey and HDG_Data.GetCharacterKey()
    self:Dispatch("SET_CHARACTER_PROFESSION_FILTER", { charKey = charKey, filter = professionName })
end

-- ============================================================================
-- PERSISTENCE (Simplified auto-serialize)
-- ============================================================================

-- Valid tab keys for activeTab validation
local VALID_TABS = { recipes=1, acquisition=1, preview=1, goblin=1, trainers=1, characters=1, styles=1, data=1, config=1 }

-- Type-safe merge: only overwrite when saved value matches default's type
local function MergeTable(target, source)
    if not source or type(source) ~= "table" then return end
    for k, v in pairs(source) do
        local targetVal = target[k]
        if type(v) == "table" and type(targetVal) == "table" then
            MergeTable(targetVal, v)
        elseif targetVal == nil or type(v) == type(targetVal) then
            target[k] = v
        end -- type mismatch on non-nil default: silently keep default
    end
end

-- Clamp numeric config values to safe ranges
local function ValidateConfig(config)
    if type(config.scale) == "number" then
        config.scale = math.max(0.5, math.min(2.0, config.scale))
    end
    if type(config.fontScale) == "number" then
        config.fontScale = math.max(-5, math.min(5, config.fontScale))
    end
    if type(config.bgOpacity) == "number" then
        config.bgOpacity = math.max(0.3, math.min(1.0, config.bgOpacity))
    end
    if type(config.vamooseChattiness) == "number" then
        config.vamooseChattiness = math.max(0, math.min(99, config.vamooseChattiness))
    end
end

-- Validate UI state values
local function ValidateUI(ui)
    if ui.activeTab and not VALID_TABS[ui.activeTab] then
        ui.activeTab = "recipes"
    end
end

-- Validate minimap values
local function ValidateMinimap(minimap)
    if type(minimap.minimapPos) == "number" then
        minimap.minimapPos = minimap.minimapPos % 360
    end
end

-- Sanitize an array table: keep only entries passing filterFn
local function SanitizeArray(saved, filterFn)
    if type(saved) ~= "table" then return nil end
    local clean = {}
    for _, entry in ipairs(saved) do
        if filterFn(entry) then
            table.insert(clean, entry)
        end
    end
    return clean
end

function HDG.Store:LoadFromSavedVariables()
    HDG_DB = HDG_DB or {}

    -- Merge config/ui/minimap with type safety
    MergeTable(self.state.config, HDG_DB.config)
    MergeTable(self.state.ui, HDG_DB.ui)
    MergeTable(self.state.minimap, HDG_DB.minimap)
    MergeTable(self.state.houseTab, HDG_DB.houseTab)

    -- Validate merged values
    ValidateConfig(self.state.config)
    ValidateUI(self.state.ui)
    ValidateMinimap(self.state.minimap)

    -- v2 flat schema: read config/ui keys from top-level HDG_DB.*
    if HDG_DB.schemaVersion == 2 then
        for _, k in ipairs(CONFIG_KEYS) do
            local v = HDG_DB[k]
            local def = DEFAULT_STATE.config[k]
            if v ~= nil and (def == nil or type(v) == type(def)) then
                self.state.config[k] = v
            end
        end
        for _, k in ipairs(UI_KEYS) do
            local v = HDG_DB[k]
            local def = DEFAULT_STATE.ui[k]
            if v ~= nil and (def == nil or type(v) == type(def)) then
                self.state.ui[k] = v
            end
        end
        if type(HDG_DB.altsViewMode) == "string" then
            self.state.altsViewMode = HDG_DB.altsViewMode
        end
        if type(HDG_DB.mogul) == "table" then
            local m = HDG_DB.mogul
            if type(m.lumberBudget) == "number" then self.state.mogul.lumberBudget = m.lumberBudget end
            if m.viewMode == "char" or m.viewMode == "account" then self.state.mogul.viewMode = m.viewMode end
            if m.optimizeBy == "lumberOnly" or m.optimizeBy == "lumberPlusMats" then self.state.mogul.optimizeBy = m.optimizeBy end
            if m.mode == "profit" or m.mode == "collection" then self.state.mogul.mode = m.mode end
            if m.algorithm == "greedy" or m.algorithm == "lp" then self.state.mogul.algorithm = m.algorithm end
            if type(m.showCandidates) == "boolean" then self.state.mogul.showCandidates = m.showCandidates end
            if type(m.supplyImpact) == "table" then
                local si = m.supplyImpact
                if si.mode == "off" or si.mode == "smooth" or si.mode == "cap" then
                    self.state.mogul.supplyImpact.mode = si.mode
                end
                if type(si.cap) == "number" and si.cap >= 1 and si.cap <= 9999 then
                    self.state.mogul.supplyImpact.cap = math.floor(si.cap)
                end
                if type(si.decay) == "number" and si.decay >= 0 and si.decay <= 0.99 then
                    self.state.mogul.supplyImpact.decay = si.decay
                end
            end
        end
        if type(HDG_DB.recentActivityCap) == "number" then
            self.state.recentActivityCap = HDG_DB.recentActivityCap
        end
    end

    -- Array tables: sanitize entries
    local clean
    -- Migration: craftingQueue was formerly named shoppingList
    local queueKey = HDG_DB.craftingQueue or HDG_DB.shoppingList
    -- Schema migration (2026-05-09): queue items used `id`/`spell`. Renamed to
    -- `itemID`/`spellID` for readability. Rewrite legacy entries in place.
    if type(queueKey) == "table" then
        for _, entry in ipairs(queueKey) do
            if type(entry) == "table" then
                if entry.id and not entry.itemID then
                    entry.itemID = entry.id
                    entry.id = nil
                end
                if entry.spell and not entry.spellID then
                    entry.spellID = entry.spell
                    entry.spell = nil
                end
            end
        end
    end
    clean = SanitizeArray(queueKey, function(e) return type(e) == "table" and e.itemID end)
    if clean then self.state.craftingQueue = clean end

    -- Multi-list shopping: migrate old format or load new format
    if type(HDG_DB.vendorShoppingLists) == "table" then
        -- New multi-list format: validate each list
        for id, list in pairs(HDG_DB.vendorShoppingLists) do
            if type(list) == "table" and type(list.items) == "table" then
                local items = SanitizeArray(list.items, function(e) return type(e) == "table" and e.itemID end)
                self.state.vendorShoppingLists[id] = {
                    name = type(list.name) == "string" and list.name or ("List " .. id),
                    items = items or {},
                    meta = type(list.meta) == "table" and list.meta or {},
                    createdAt = list.createdAt or time(),
                }
            end
        end
        if type(HDG_DB.activeShoppingListId) == "string" and self.state.vendorShoppingLists[HDG_DB.activeShoppingListId] then
            self.state.activeShoppingListId = HDG_DB.activeShoppingListId
        end
    elseif type(HDG_DB.vendorShoppingList) == "table" then
        -- Old single-list format: migrate to multi-list
        clean = SanitizeArray(HDG_DB.vendorShoppingList, function(e) return type(e) == "table" and e.itemID end)
        self.state.vendorShoppingLists["default"] = {
            name = "Shopping List",
            items = clean or {},
            meta = type(HDG_DB.vendorShoppingListMeta) == "table" and HDG_DB.vendorShoppingListMeta or {},
            createdAt = time(),
        }
        self.state.activeShoppingListId = "default"
    end

    clean = SanitizeArray(HDG_DB.craftingHistory, function(e) return type(e) == "table" and e.itemID end)
    if clean then self.state.craftingHistory = clean end

    clean = SanitizeArray(HDG_DB.farmingHistory, function(e) return type(e) == "table" and (e.sessionId or e.timestamp) end)
    if clean then self.state.farmingHistory = clean end

    -- Dict tables: type-checked assignment
    if type(HDG_DB.characters) == "table" then self.state.characters = HDG_DB.characters end
    if type(HDG_DB.trackedReagents) == "table" then self.state.trackedReagents = HDG_DB.trackedReagents end
    if type(HDG_DB.lumberCoordinates) == "table" then self.state.lumberCoordinates = HDG_DB.lumberCoordinates end
    if type(HDG_DB.layout) == "table" then self.state.layout = HDG_DB.layout end
    if type(HDG_DB.userNotes) == "table" then self.state.userNotes = HDG_DB.userNotes end
    if type(HDG_DB.vendorNotes) == "table" then self.state.vendorNotes = HDG_DB.vendorNotes end
    if type(HDG_DB.vendorNameCache) == "table" then self.state.vendorNameCache = HDG_DB.vendorNameCache end
    if type(HDG_DB.vendorCurrencyCache) == "table" then self.state.vendorCurrencyCache = HDG_DB.vendorCurrencyCache end
    if type(HDG_DB.projects) == "table" then MergeTable(self.state.projects, HDG_DB.projects) end
    if type(HDG_DB.collections) == "table" then self.state.collections = HDG_DB.collections end
    if type(HDG_DB.migrations) == "table" then self.state.migrations = HDG_DB.migrations end

    -- Legacy customStyles + customStyleOrder are preserved through saves (see
    -- the matching block in SaveToSavedVariables) so the v1-collections
    -- migration has source data to copy. Load them into state on every
    -- restore — without this line, a user who hasn't yet run v1-collections
    -- (e.g. someone restoring a save from a pre-migration version) gets a
    -- silent no-op migration and zero styles in state.collections.
    if type(HDG_DB.customStyles) == "table" then self.state.customStyles = HDG_DB.customStyles end
    if type(HDG_DB.customStyleOrder) == "table" then self.state.customStyleOrder = HDG_DB.customStyleOrder end

    -- Module-owned state (formerly direct-to-HDG_DB). recentActivity is
    -- deferred — still owned by HDG_RecentActivity.lua until that module
    -- is migrated.
    if type(HDG_DB.collection) == "table" then self.state.collection = HDG_DB.collection end
    if type(HDG_DB.directPriceCache) == "table" then self.state.directPriceCache = HDG_DB.directPriceCache end
    if type(HDG_DB.directPriceCacheTime) == "number" then self.state.directPriceCacheTime = HDG_DB.directPriceCacheTime end
    if type(HDG_DB.ownedAuctions) == "table" then self.state.ownedAuctions = HDG_DB.ownedAuctions end

    -- Run any pending data migrations BEFORE pruning. Migrations may move
    -- data between sections (e.g. customStyles → collections); they're
    -- idempotent and version-flagged inside HDG_Migration.lua.
    local Migration = privateDB and privateDB.HDG_Migration
    if Migration and Migration.RunAll then Migration.RunAll(self.state) end

    -- Prune bloated SavedVariables (prevent unbounded growth)
    self:PruneSavedVariables()

    if self.state.config.debug then
        print("|cFF14b8a6[HDG Store]|r Loaded state from SavedVariables")
    end
end

function HDG.Store:QueueSave()
    if self.saveTimer then self.saveTimer:Cancel() end
    self.saveTimer = C_Timer.NewTimer(1, function() self:SaveToSavedVariables() end)
end

function HDG.Store:SaveToSavedVariables()
    HDG_DB = {
        schemaVersion = 2,
        -- data keys (unchanged)
        minimap = self.state.minimap,
        craftingQueue = self.state.craftingQueue,
        characters = self.state.characters,
        craftingHistory = self.state.craftingHistory,
        trackedReagents = self.state.trackedReagents,
        lumberCoordinates = self.state.lumberCoordinates,
        farmingHistory = self.state.farmingHistory,
        vendorShoppingLists = self.state.vendorShoppingLists,
        activeShoppingListId = self.state.activeShoppingListId,
        -- TODO(v2-customstyles-cleanup): preserve legacy customStyles +
        -- customStyleOrder through save so the v1-collections migration's
        -- source data survives until the cleanup migration deletes it.
        -- Once that migration runs (after a release of soak time), remove
        -- these two lines and the migration entry.
        customStyles = HDG_DB and HDG_DB.customStyles or nil,
        customStyleOrder = HDG_DB and HDG_DB.customStyleOrder or nil,
        collections = self.state.collections,
        migrations = self.state.migrations,
        layout = self.state.layout,
        userNotes = self.state.userNotes,
        vendorNotes = self.state.vendorNotes,
        vendorNameCache = self.state.vendorNameCache,
        vendorCurrencyCache = self.state.vendorCurrencyCache,
        projects = self.state.projects,
        collection = self.state.collection,
        directPriceCache = self.state.directPriceCache,
        directPriceCacheTime = self.state.directPriceCacheTime,
        ownedAuctions = self.state.ownedAuctions,
        -- recentActivity is still owned by HDG_RecentActivity.lua (migration
        -- deferred). Until that module is refactored, preserve through save
        -- by reading from HDG_DB directly.
        recentActivity = HDG_DB and HDG_DB.recentActivity or nil,
        -- House-tab cockpit state -- owned by widgets, not the Store.
        account  = HDG_DB and HDG_DB.account  or nil,
        houseTab = self.state.houseTab,
        records  = HDG_DB and HDG_DB.records  or nil,
        favorites = HDG_DB and HDG_DB.favorites or nil,
        -- new flat scalars
        altsViewMode = self.state.altsViewMode,
        recentActivityCap = self.state.recentActivityCap,
        mogul = {
            lumberBudget = self.state.mogul.lumberBudget,
            viewMode = self.state.mogul.viewMode,
            optimizeBy = self.state.mogul.optimizeBy,
            mode = self.state.mogul.mode,
            algorithm = self.state.mogul.algorithm,
            showCandidates = self.state.mogul.showCandidates,
            supplyImpact = {
                mode  = self.state.mogul.supplyImpact.mode,
                cap   = self.state.mogul.supplyImpact.cap,
                decay = self.state.mogul.supplyImpact.decay,
            },
        },
        -- Frozen rollback snapshot: config/ui sub-tables ossify post-migration.
        -- No production code writes to them anymore (all 48 call sites moved to
        -- state.config/state.ui in v2.41 phases 4-7). Pass-through preserves
        -- the user's pre-v2.41 values verbatim so /hdgrollback can restore them.
        config = HDG_DB and HDG_DB.config or nil,
        ui     = HDG_DB and HDG_DB.ui     or nil,
    }
    -- Flatten config keys to HDG_DB top level (iterate explicit list to handle nil values)
    for _, k in ipairs(CONFIG_KEYS) do HDG_DB[k] = self.state.config[k] end
    -- Flatten ui keys to HDG_DB top level
    for _, k in ipairs(UI_KEYS) do HDG_DB[k] = self.state.ui[k] end
    if self.state.config.debug then
        print("|cFF14b8a6[HDG Store]|r Saved state to SavedVariables")
    end
end

function HDG.Store:Flush()
    if self.saveTimer then
        self.saveTimer:Cancel()
        self.saveTimer = nil
    end
    self:SaveToSavedVariables()
end

-- ============================================================================
-- SAVEDVARIABLES PRUNING (prevent unbounded growth)
-- ============================================================================

function HDG.Store:PruneSavedVariables()
    local CONFIG = privateDB.CONFIG
    if not CONFIG then return end

    -- Prune userNotes (oldest by timestamp)
    if self.state.userNotes then
        local count = 0
        for _ in pairs(self.state.userNotes) do count = count + 1 end
        if count > CONFIG.MAX_USER_NOTES then
            local sorted = {}
            for itemID, note in pairs(self.state.userNotes) do
                table.insert(sorted, { itemID = itemID, ts = note.ts or 0 })
            end
            table.sort(sorted, function(a, b) return a.ts < b.ts end)
            local toRemove = count - CONFIG.MAX_USER_NOTES
            for i = 1, toRemove do
                self.state.userNotes[sorted[i].itemID] = nil
            end
        end
    end

    -- Prune vendorNotes (oldest by timestamp)
    if self.state.vendorNotes then
        local count = 0
        for _ in pairs(self.state.vendorNotes) do count = count + 1 end
        if count > CONFIG.MAX_VENDOR_NOTES then
            local sorted = {}
            for npcID, note in pairs(self.state.vendorNotes) do
                table.insert(sorted, { npcID = npcID, ts = note.ts or 0 })
            end
            table.sort(sorted, function(a, b) return a.ts < b.ts end)
            local toRemove = count - CONFIG.MAX_VENDOR_NOTES
            for i = 1, toRemove do
                self.state.vendorNotes[sorted[i].npcID] = nil
            end
        end
    end

    -- Prune farmingHistory (oldest sessions)
    if self.state.farmingHistory and #self.state.farmingHistory > CONFIG.MAX_FARMING_HISTORY then
        local newHistory = {}
        for i = 1, CONFIG.MAX_FARMING_HISTORY do
            newHistory[i] = self.state.farmingHistory[i]
        end
        self.state.farmingHistory = newHistory
    end

    -- Prune characters (least recently seen)
    if self.state.characters then
        local count = 0
        for _ in pairs(self.state.characters) do count = count + 1 end
        if count > CONFIG.MAX_CHARACTERS then
            local sorted = {}
            for charKey, data in pairs(self.state.characters) do
                table.insert(sorted, { charKey = charKey, lastUpdated = data.lastUpdated or 0 })
            end
            table.sort(sorted, function(a, b) return a.lastUpdated < b.lastUpdated end)
            local toRemove = count - CONFIG.MAX_CHARACTERS
            for i = 1, toRemove do
                self.state.characters[sorted[i].charKey] = nil
            end
        end
    end
end

-- ============================================================================
-- DIRECT STATE METHODS (replaces reducers for simple operations)
-- ============================================================================

function HDG.Store:SetConfig(key, value)
    self.state.config[key] = value
    self:QueueSave()
end

function HDG.Store:SetTheme(themeName)
    self.state.config.theme = themeName
    if self.onThemeChange then self.onThemeChange(themeName) end
    self:QueueSave()
end

function HDG.Store:SetUI(key, value)
    self.state.ui[key] = value
    self:QueueSave()
end

function HDG.Store:SetMinimap(key, value)
    self.state.minimap[key] = value
    self:QueueSave()
end

function HDG.Store:SetWidgetEnabled(widgetID, enabled)
    if type(widgetID) ~= "string" then return end
    self.state.houseTab.enabled[widgetID] = enabled and true or false
    self:QueueSave()
    if HDG.EventBus and HDG.EventBus.Trigger then
        HDG.EventBus:Trigger("HDG_HT_LAYOUT_CHANGED", { kind = "enabled", widgetID = widgetID })
    end
end

function HDG.Store:SetWidgetOrder(orderArray)
    if type(orderArray) ~= "table" then return end
    self.state.houseTab.order = orderArray
    self:QueueSave()
    if HDG.EventBus and HDG.EventBus.Trigger then
        HDG.EventBus:Trigger("HDG_HT_LAYOUT_CHANGED", { kind = "order" })
    end
end

function HDG.Store:SetWidgetWidth(widgetID, width)
    if type(widgetID) ~= "string" then return end
    self.state.houseTab.width[widgetID] = width
    self:QueueSave()
    if HDG.EventBus and HDG.EventBus.Trigger then
        HDG.EventBus:Trigger("HDG_HT_LAYOUT_CHANGED", { kind = "width", widgetID = widgetID })
    end
end

function HDG.Store:SetWidgetOverride(widgetID, key, value)
    if type(widgetID) ~= "string" or type(key) ~= "string" then return end
    self.state.houseTab.layoutOverrides[widgetID] = self.state.houseTab.layoutOverrides[widgetID] or {}
    self.state.houseTab.layoutOverrides[widgetID][key] = value
    self:QueueSave()
    if HDG.EventBus and HDG.EventBus.Trigger then
        HDG.EventBus:Trigger("HDG_HT_LAYOUT_CHANGED", { kind = "override", widgetID = widgetID, key = key })
    end
end

function HDG.Store:ResetWidgetOverrides(widgetID)
    if type(widgetID) ~= "string" then return end
    self.state.houseTab.layoutOverrides[widgetID] = nil
    self:QueueSave()
    if HDG.EventBus and HDG.EventBus.Trigger then
        HDG.EventBus:Trigger("HDG_HT_LAYOUT_CHANGED", { kind = "override-reset", widgetID = widgetID })
    end
end

function HDG.Store:ResetHouseTabLayout()
    self.state.houseTab.enabled         = {}
    self.state.houseTab.order           = {}
    self.state.houseTab.width           = {}
    self.state.houseTab.layoutOverrides = {}
    self:QueueSave()
    if HDG.EventBus and HDG.EventBus.Trigger then
        HDG.EventBus:Trigger("HDG_HT_LAYOUT_CHANGED", { kind = "reset-all" })
    end
end

function HDG.Store:UpdateLayout(frameName, layoutData)
    self.state.layout[frameName] = layoutData
    self:QueueSave()
end

-- ============================================================================
-- CRAFTING QUEUE METHODS
-- ============================================================================

function HDG.Store:AddToCraftingQueue(item)
    if not item or not item.itemID then
        if HDG.Config and HDG.Config.Get("debug") then
            print("|cFFdc322f[HDG Store]|r Rejected non-craftable item from queue:", item and item.name or "nil")
        end
        return
    end
    for i, existing in ipairs(self.state.craftingQueue) do
        if existing.itemID == item.itemID then
            self.state.craftingQueue[i].quantity = (existing.quantity or 1) + (item.quantity or 1)
            self:QueueSave()
            return
        end
    end
    table.insert(self.state.craftingQueue, {
        itemID = item.itemID,
        name = item.name,
        spellID = item.spellID,
        profession = item.profession,
        reagents = item.reagents,
        expansion = item.expansion,
        quantity = item.quantity or 1,
    })
    self:QueueSave()
end

function HDG.Store:RemoveFromCraftingQueue(itemID)
    for i, item in ipairs(self.state.craftingQueue) do
        if item.itemID == itemID then
            table.remove(self.state.craftingQueue, i)
            self:QueueSave()
            return
        end
    end
end

function HDG.Store:UpdateCraftingQueueItemQty(itemID, quantity)
    for i, item in ipairs(self.state.craftingQueue) do
        if item.itemID == itemID then
            if quantity <= 0 then
                table.remove(self.state.craftingQueue, i)
            else
                self.state.craftingQueue[i].quantity = quantity
            end
            self:QueueSave()
            return
        end
    end
end

function HDG.Store:ClearCraftingQueue()
    self.state.craftingQueue = {}
    self:QueueSave()
end

-- ============================================================================
-- CHARACTER DATA METHODS
-- ============================================================================

function HDG.Store:UpdateCharacterData(charKey, data)
    if not self.state.characters[charKey] then
        self.state.characters[charKey] = { professions = {}, lastUpdated = 0 }
    end
    local char = self.state.characters[charKey]

    if data.professions then
        -- Replace entire professions table so dropped professions are removed
        char.professions = {}
        for profName, profData in pairs(data.professions) do
            char.professions[profName] = profData
        end
    end

    if data.knowsFindLumber ~= nil then char.knowsFindLumber = data.knowsFindLumber end
    if data.lumberInventory then char.lumberInventory = data.lumberInventory end
    if data.lumberUpdated then char.lumberUpdated = data.lumberUpdated end
    char.lastUpdated = data.lastUpdated or time()

    self:QueueSave()
end

function HDG.Store:RemoveCharacter(charKey)
    self.state.characters[charKey] = nil
    self:QueueSave()
end

-- ============================================================================
-- CRAFTING HISTORY METHODS
-- ============================================================================

function HDG.Store:AddCraftingHistory(entry)
    entry.timestamp = entry.timestamp or time()
    table.insert(self.state.craftingHistory, 1, entry)
    local maxHistory = privateDB.CONFIG and privateDB.CONFIG.MAX_CRAFTING_HISTORY or 100
    while #self.state.craftingHistory > maxHistory do
        table.remove(self.state.craftingHistory)
    end
    self:QueueSave()
end

function HDG.Store:ClearCraftingHistory()
    self.state.craftingHistory = {}
    self:QueueSave()
end

-- ============================================================================
-- TRACKED REAGENTS METHODS
-- ============================================================================

function HDG.Store:AddTrackedReagent(itemID, name, targetQty)
    local CONFIG = privateDB.CONFIG
    if CONFIG then
        local count = 0
        for _ in pairs(self.state.trackedReagents) do count = count + 1 end
        if count >= CONFIG.MAX_TRACKED_REAGENTS then
            print("|cFFFF0000[HDG]|r Reagent tracking limit reached (max " .. CONFIG.MAX_TRACKED_REAGENTS .. "). Remove some items first.")
            return false
        end
    end
    self.state.trackedReagents[itemID] = {
        name = name,
        targetQty = targetQty or 0,
        addedTime = time(),
    }
    self:QueueSave()
    return true
end

function HDG.Store:RemoveTrackedReagent(itemID)
    self.state.trackedReagents[itemID] = nil
    self:QueueSave()
end

function HDG.Store:UpdateTrackedReagent(itemID, targetQty)
    if self.state.trackedReagents[itemID] then
        self.state.trackedReagents[itemID].targetQty = targetQty
        self:QueueSave()
    end
end

function HDG.Store:ClearTrackedReagents()
    self.state.trackedReagents = {}
    self:QueueSave()
end

-- ============================================================================
-- USER NOTES METHODS
-- ============================================================================

function HDG.Store:SetUserNote(itemID, text)
    self.state.userNotes[itemID] = { text = text, ts = time() }
    self:QueueSave()
end

function HDG.Store:GetUserNote(itemID)
    return self.state.userNotes[itemID]
end

function HDG.Store:RemoveUserNote(itemID)
    self.state.userNotes[itemID] = nil
    self:QueueSave()
end

function HDG.Store:GetAllUserNotes()
    return self.state.userNotes
end

-- ============================================================================
-- VENDOR NOTES METHODS
-- ============================================================================

function HDG.Store:SetVendorNote(npcID, text)
    self.state.vendorNotes[npcID] = { text = text, ts = time() }
    self:QueueSave()
end

function HDG.Store:GetVendorNote(npcID)
    return self.state.vendorNotes[npcID]
end

function HDG.Store:RemoveVendorNote(npcID)
    self.state.vendorNotes[npcID] = nil
    self:QueueSave()
end

function HDG.Store:GetAllVendorNotes()
    return self.state.vendorNotes
end

-- ============================================================================
-- VENDOR NAME CACHE METHODS
-- ============================================================================

function HDG.Store:SetVendorNameCache(cache)
    self.state.vendorNameCache = cache
    self:QueueSave()
end

-- ============================================================================
-- EXPORT ALL NOTES
-- ============================================================================

function HDG.Store:ExportUserNotes()
    local itemNotes = self.state.userNotes
    local vendorNotes = self.state.vendorNotes
    local itemCount, vendorCount = 0, 0
    for _ in pairs(itemNotes) do itemCount = itemCount + 1 end
    for _ in pairs(vendorNotes) do vendorCount = vendorCount + 1 end
    if itemCount == 0 and vendorCount == 0 then return "No notes to export." end

    local lines = {}
    table.insert(lines, "=== HDG Notes Export ===")
    table.insert(lines, "Date: " .. date("%Y-%m-%d"))
    table.insert(lines, string.format("Total: %d item notes, %d vendor notes", itemCount, vendorCount))
    table.insert(lines, "")

    if vendorCount > 0 then
        table.insert(lines, "--- Vendor Notes ---")
        table.insert(lines, "")
        local sorted = {}
        for npcID, note in pairs(vendorNotes) do
            table.insert(sorted, { npcID = npcID, text = note.text, ts = note.ts })
        end
        table.sort(sorted, function(a, b) return a.ts > b.ts end)
        for _, entry in ipairs(sorted) do
            local ve = HDG_VendorDB and HDG_VendorDB[entry.npcID]
            local name = ve and ve[1] or ("NPC #" .. entry.npcID)
            local zone = ve and ve[2] or "Unknown Zone"
            table.insert(lines, string.format("[NPC %d] %s — %s", entry.npcID, name, zone))
            table.insert(lines, "  Note: " .. entry.text)
            if entry.ts and entry.ts > 0 then
                table.insert(lines, "  Added: " .. date("%Y-%m-%d %H:%M", entry.ts))
            end
            table.insert(lines, "")
        end
    end

    if itemCount > 0 then
        table.insert(lines, "--- Item Notes ---")
        table.insert(lines, "")
        local sorted = {}
        for itemID, note in pairs(itemNotes) do
            table.insert(sorted, { itemID = itemID, text = note.text, ts = note.ts })
        end
        table.sort(sorted, function(a, b) return a.ts > b.ts end)
        for _, entry in ipairs(sorted) do
            local name = HDG_Data and HDG_Data.GetCachedItemName(entry.itemID) or ("Unknown Item")
            table.insert(lines, string.format("[%d] %s", entry.itemID, name))
            table.insert(lines, "  Note: " .. entry.text)
            if entry.ts and entry.ts > 0 then
                table.insert(lines, "  Added: " .. date("%Y-%m-%d %H:%M", entry.ts))
            end
            table.insert(lines, "")
        end
    end

    return table.concat(lines, "\n")
end

-- ============================================================================
-- PROJECTS — house/room CRUD + UI state
-- ============================================================================

function HDG.Store:UpsertProjectHouse(houseID, fields)
    if type(houseID) ~= "string" or type(fields) ~= "table" then return nil end
    local houses = self.state.projects.houses
    houses[houseID] = houses[houseID] or {}
    for k, v in pairs(fields) do
        houses[houseID][k] = v
    end
    self:QueueSave()
    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_PROJECT_HOUSE_UPDATED", { houseID = houseID })
    end
    return houses[houseID]
end

function HDG.Store:UpsertProjectRoom(roomID, fields)
    if type(roomID) ~= "string" or type(fields) ~= "table" then return nil end
    local rooms = self.state.projects.rooms
    rooms[roomID] = rooms[roomID] or { cell = { x = 0, y = 0, rotation = 0 } }
    for k, v in pairs(fields) do
        rooms[roomID][k] = v
    end
    self:QueueSave()
    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_PROJECT_ROOM_UPDATED", { roomID = roomID })
    end
    return rooms[roomID]
end

-- ---------------------------------------------------------------------------
-- Manual connection assertions — let the player override the cardinal-
-- matching algorithm's guesses. Stored per-floor as paired endpoints.
-- ---------------------------------------------------------------------------
local function _normFloorID(floorID)
    return type(floorID) == "string" and floorID or nil
end

function HDG.Store:Connection_ListForFloor(floorID)
    local fid = _normFloorID(floorID)
    if not fid then return {} end
    self.state.projects.connections = self.state.projects.connections or {}
    return self.state.projects.connections[fid] or {}
end

local function _matchesEndpoint(c, roomID, dir)
    return (c.fromID == roomID and c.fromDir == dir)
        or (c.toID   == roomID and c.toDir   == dir)
end

-- Drop any existing assertion that touches the given (roomID, dir) endpoint
-- on this floor. Both ends are released so a stale connection doesn't linger.
local function _dropAtEndpoint(list, roomID, dir)
    if not list then return end
    for i = #list, 1, -1 do
        if _matchesEndpoint(list[i], roomID, dir) then
            table.remove(list, i)
        end
    end
end

function HDG.Store:Connection_Assert(floorID, fromID, fromDir, toID, toDir)
    local fid = _normFloorID(floorID)
    if not fid or not fromID or not toID or not fromDir or not toDir then return false end
    self.state.projects.connections = self.state.projects.connections or {}
    self.state.projects.connections[fid] = self.state.projects.connections[fid] or {}
    local list = self.state.projects.connections[fid]

    -- Free both endpoints before inserting. A door can connect to exactly one other.
    _dropAtEndpoint(list, fromID, fromDir)
    _dropAtEndpoint(list, toID,   toDir)

    list[#list + 1] = {
        fromID = fromID, fromDir = fromDir,
        toID   = toID,   toDir   = toDir,
    }
    self:QueueSave()
    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_PROJECT_CONNECTIONS_CHANGED", { floorID = fid })
    end
    return true
end

-- Block a single door from being matched by the cardinal-matching algorithm.
-- Used to "disconnect" a wrong algorithm pick so the player can assert a
-- different partner. Stored as a blocked-flag record; both ends of any
-- existing assertion involving this endpoint are dropped first.
function HDG.Store:Connection_Block(floorID, roomID, dir)
    local fid = _normFloorID(floorID)
    if not fid or not roomID or not dir then return false end
    self.state.projects.connections = self.state.projects.connections or {}
    self.state.projects.connections[fid] = self.state.projects.connections[fid] or {}
    local list = self.state.projects.connections[fid]
    _dropAtEndpoint(list, roomID, dir)
    list[#list + 1] = { fromID = roomID, fromDir = dir, blocked = true }
    self:QueueSave()
    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_PROJECT_CONNECTIONS_CHANGED", { floorID = fid })
    end
    return true
end

function HDG.Store:Connection_Unassert(floorID, roomID, dir)
    local fid = _normFloorID(floorID)
    if not fid then return false end
    local list = self.state.projects.connections and self.state.projects.connections[fid]
    if not list then return false end
    local before = #list
    _dropAtEndpoint(list, roomID, dir)
    if #list ~= before then
        self:QueueSave()
        if HDG.EventBus then
            HDG.EventBus:Trigger("HDG_PROJECT_CONNECTIONS_CHANGED", { floorID = fid })
        end
        return true
    end
    return false
end

function HDG.Store:DeleteProjectRoom(roomID)
    if type(roomID) ~= "string" then return end
    self.state.projects.rooms[roomID] = nil
    -- Detach (don't cascade-delete) any crates parented to this room. They
    -- become orphans and surface in the RoomSidePanel "Unrooted crates"
    -- section so the user can re-home or delete them deliberately.
    if self.state.collections then
        for collID, coll in pairs(self.state.collections) do
            if coll.type == "crate" and coll.parent == roomID then
                coll.parent = nil
                if HDG.EventBus then
                    HDG.EventBus:Trigger("HDG_COLLECTION_FIELD_CHANGED", {
                        collID = collID, type = "crate", field = "parent"
                    })
                end
            end
        end
    end
    self:QueueSave()
    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_PROJECT_ROOM_UPDATED", { roomID = roomID, deleted = true })
    end
end

function HDG.Store:SetProjectUI(field, value)
    if type(field) ~= "string" then return end
    self.state.projects.ui[field] = value
    self:QueueSave()
end

-- ============================================================================
-- LUMBER COORDINATES METHODS
-- ============================================================================

function HDG.Store:AddLumberCoordinate(node)
    table.insert(self.state.lumberCoordinates, node)
    self:QueueSave()
end

function HDG.Store:SetLumberCoordinates(coordinates)
    self.state.lumberCoordinates = coordinates or {}
    self:QueueSave()
end

function HDG.Store:ClearLumberCoordinates()
    self.state.lumberCoordinates = {}
    self:QueueSave()
end

-- ============================================================================
-- FARMING HISTORY METHODS
-- ============================================================================

function HDG.Store:UpsertFarmingSession(session)
    local sessionId = session.sessionId
    if sessionId then
        for _, entry in ipairs(self.state.farmingHistory) do
            if entry.sessionId == sessionId then
                entry.collected = session.collected or entry.collected
                entry.duration = session.duration or entry.duration
                entry.perHour = session.perHour or entry.perHour
                entry.timestamp = session.timestamp or entry.timestamp
                entry.zone = session.zone or entry.zone
                self:QueueSave()
                return
            end
        end
    end
    table.insert(self.state.farmingHistory, 1, session)
    local maxFarming = privateDB.CONFIG and privateDB.CONFIG.MAX_FARMING_HISTORY or 100
    while #self.state.farmingHistory > maxFarming do
        table.remove(self.state.farmingHistory)
    end
    self:QueueSave()
end

function HDG.Store:ClearFarmingHistory()
    self.state.farmingHistory = {}
    self:QueueSave()
end

function HDG.Store:DeleteMostRecentFarmingSession()
    if #self.state.farmingHistory > 0 then
        table.remove(self.state.farmingHistory, 1)  -- Most recent is at index 1
        self:QueueSave()
        return true
    end
    return false
end

-- ============================================================================
-- VENDOR SHOPPING LIST METHODS (Multi-List)
-- ============================================================================

local function GenerateListId()
    local chars = "abcdefghijklmnopqrstuvwxyz0123456789"
    local id = {}
    for i = 1, 6 do
        local r = math.random(1, #chars)
        id[i] = chars:sub(r, r)
    end
    return table.concat(id)
end

function HDG.Store:GetActiveShoppingList()
    local id = self.state.activeShoppingListId or "default"
    local list = self.state.vendorShoppingLists[id]
    if not list then
        list = { name = "Shopping List", items = {}, meta = {}, createdAt = time() }
        self.state.vendorShoppingLists[id] = list
    end
    return list, id
end

function HDG.Store:AddToVendorShoppingList(itemID, npcID, qty)
    if not itemID then return false end
    local amount = qty or 1
    local items = self:GetActiveShoppingList().items
    for _, entry in ipairs(items) do
        local match = npcID and (entry.itemID == itemID and entry.npcID == npcID)
                           or (entry.itemID == itemID and not entry.npcID)
        if match then
            entry.qty = (entry.qty or 1) + amount
            self:QueueSave()
            return true
        end
    end
    table.insert(items, { itemID = itemID, npcID = npcID, qty = amount, addedAt = time() })
    self:QueueSave()
    return true
end

function HDG.Store:RemoveFromVendorShoppingList(itemID, npcID)
    local items = self:GetActiveShoppingList().items
    for i = #items, 1, -1 do
        local entry = items[i]
        local match = npcID and (entry.itemID == itemID and entry.npcID == npcID)
                           or (entry.itemID == itemID and not entry.npcID)
        if match then
            local newQty = (entry.qty or 1) - 1
            if newQty <= 0 then
                table.remove(items, i)
            else
                entry.qty = newQty
            end
            self:QueueSave()
            return true
        end
    end
    return false
end

function HDG.Store:ClearVendorShoppingList()
    local list = self:GetActiveShoppingList()
    wipe(list.items)
    wipe(list.meta)
    self:QueueSave()
end

function HDG.Store:SetVendorShoppingListMeta(meta)
    self:GetActiveShoppingList().meta = meta or {}
    self:QueueSave()
end

function HDG.Store:IsOnVendorShoppingList(itemID, npcID)
    local items = self:GetActiveShoppingList().items
    for _, entry in ipairs(items) do
        local match = npcID and (entry.itemID == itemID and entry.npcID == npcID)
                           or (entry.itemID == itemID and not entry.npcID)
        if match then return true end
    end
    return false
end

function HDG.Store:PruneCollectedFromVendorShoppingList()
    if not HDG_Data or not HDG_Data.IsItemCollected then return end
    local items = self:GetActiveShoppingList().items
    local changed = false
    for i = #items, 1, -1 do
        if HDG_Data.IsItemCollected(items[i].itemID) then
            table.remove(items, i)
            changed = true
        end
    end
    if changed then self:QueueSave() end
end

function HDG.Store:GetVendorShoppingListCount()
    local items = self:GetActiveShoppingList().items
    local total = 0
    for _, entry in ipairs(items) do
        total = total + (entry.qty or 1)
    end
    return total
end

-- Multi-list management methods

function HDG.Store:CreateShoppingList(name)
    local id = GenerateListId()
    local count = 0
    for _ in pairs(self.state.vendorShoppingLists) do count = count + 1 end
    self.state.vendorShoppingLists[id] = {
        name = name or ("List " .. (count + 1)),
        items = {},
        meta = {},
        createdAt = time(),
    }
    self:QueueSave()
    return id
end

function HDG.Store:SetActiveShoppingList(id)
    if self.state.vendorShoppingLists[id] then
        self.state.activeShoppingListId = id
        self:QueueSave()
        return true
    end
    return false
end

function HDG.Store:GetShoppingLists()
    local result = {}
    for id, list in pairs(self.state.vendorShoppingLists) do
        local count = 0
        for _, entry in ipairs(list.items) do count = count + (entry.qty or 1) end
        result[#result + 1] = { id = id, name = list.name, count = count, createdAt = list.createdAt or 0 }
    end
    table.sort(result, function(a, b) return a.createdAt < b.createdAt end)
    return result
end

function HDG.Store:RenameShoppingList(id, newName)
    local list = self.state.vendorShoppingLists[id]
    if list and newName and newName ~= "" then
        list.name = newName
        self:QueueSave()
        return true
    end
    return false
end

function HDG.Store:DeleteShoppingList(id)
    -- Can't delete last list
    local count = 0
    for _ in pairs(self.state.vendorShoppingLists) do count = count + 1 end
    if count <= 1 then return false end

    self.state.vendorShoppingLists[id] = nil

    -- If deleting active list, switch to another
    if self.state.activeShoppingListId == id then
        for otherId in pairs(self.state.vendorShoppingLists) do
            self.state.activeShoppingListId = otherId
            break
        end
    end

    self:QueueSave()
    return true
end

-- ============================================================================
-- LEGACY DISPATCH SUPPORT (for backward compatibility during transition)
-- ============================================================================

HDG.Store.reducers = {}

function HDG.Store:RegisterReducer(action, reducerFn)
    self.reducers[action] = reducerFn
end

function HDG.Store:Dispatch(action, payload)
    if self.state.config.debug then
        print("|cFF14b8a6[HDG Store]|r Dispatching:", action)
    end

    -- Map dispatch calls to new methods
    if action == "SET_CONFIG" and payload.key then
        self:SetConfig(payload.key, payload.value)
    elseif action == "SET_THEME" then
        self:SetTheme(payload.theme)
    elseif action == "SET_FRAME_POSITION" then
        self:SetUI("framePosition", payload.position)
    elseif action == "SET_ACTIVE_TAB" then
        self:SetUI("activeTab", payload.tab)
    elseif action == "SET_PROFESSION_FILTER" then
        self:SetUI("professionFilter", payload.filter)
    elseif action == "SET_CHARACTER_PROFESSION_FILTER" then
        if payload.charKey then
            self.state.ui.characterProfessionFilter = self.state.ui.characterProfessionFilter or {}
            self.state.ui.characterProfessionFilter[payload.charKey] = payload.filter
        end
        self:SetUI("professionFilter", payload.filter)
    elseif action == "SET_COLLAPSED_MATERIAL" then
        self.state.ui.collapsedMaterials[payload.category] = payload.collapsed
        self:QueueSave()
    elseif action == "SET_TRAINER_COLLAPSED" then
        self.state.ui.trainersCollapsed[payload.profession] = payload.collapsed
        self:QueueSave()
    elseif action == "SET_LUMBER_PINNED" then
        self:SetUI("lumberPinned", payload.pinned)
    elseif action == "SET_LUMBER_POSITION" then
        self:SetUI("pinnedLumberPosition", payload.position)
    elseif action == "SET_MINIMAP" and payload.key then
        self:SetMinimap(payload.key, payload.value)
    elseif action == "UPDATE_LAYOUT" then
        self:UpdateLayout(payload.frame, {
            width = payload.width, height = payload.height,
            point = payload.point, relPoint = payload.relPoint,
            x = payload.x, y = payload.y,
        })
    elseif action == "ADD_TO_CRAFTING_QUEUE" then
        self:AddToCraftingQueue(payload.item)
        if HDG_LumberTracker and HDG_LumberTracker.InvalidateLumberNeeds then HDG_LumberTracker.InvalidateLumberNeeds() end
        if HDG_MaterialsList and HDG_MaterialsList.IsShown and HDG_MaterialsList.IsShown() then HDG_MaterialsList.Refresh() end
    elseif action == "REMOVE_FROM_CRAFTING_QUEUE" then
        self:RemoveFromCraftingQueue(payload.itemID)
        if HDG_LumberTracker and HDG_LumberTracker.InvalidateLumberNeeds then HDG_LumberTracker.InvalidateLumberNeeds() end
        if HDG_MaterialsList and HDG_MaterialsList.IsShown and HDG_MaterialsList.IsShown() then HDG_MaterialsList.Refresh() end
    elseif action == "UPDATE_CRAFTING_QUEUE_ITEM_QTY" then
        self:UpdateCraftingQueueItemQty(payload.itemID, payload.quantity)
        if HDG_LumberTracker and HDG_LumberTracker.InvalidateLumberNeeds then HDG_LumberTracker.InvalidateLumberNeeds() end
        if HDG_MaterialsList and HDG_MaterialsList.IsShown and HDG_MaterialsList.IsShown() then HDG_MaterialsList.Refresh() end
    elseif action == "CLEAR_CRAFTING_QUEUE" then
        self:ClearCraftingQueue()
        if HDG_LumberTracker and HDG_LumberTracker.InvalidateLumberNeeds then HDG_LumberTracker.InvalidateLumberNeeds() end
        if HDG_MaterialsList and HDG_MaterialsList.IsShown and HDG_MaterialsList.IsShown() then HDG_MaterialsList.Refresh() end
    elseif action == "UPDATE_CHARACTER_DATA" then
        self:UpdateCharacterData(payload.charKey, payload)
    elseif action == "REMOVE_CHARACTER" then
        self:RemoveCharacter(payload.charKey)
    elseif action == "ADD_CRAFTING_HISTORY" then
        self:AddCraftingHistory(payload)
    elseif action == "CLEAR_CRAFTING_HISTORY" then
        self:ClearCraftingHistory()
    elseif action == "ADD_TRACKED_REAGENT" then
        self:AddTrackedReagent(payload.itemID, payload.name, payload.targetQty)
    elseif action == "REMOVE_TRACKED_REAGENT" then
        self:RemoveTrackedReagent(payload.itemID)
    elseif action == "UPDATE_TRACKED_REAGENT" then
        self:UpdateTrackedReagent(payload.itemID, payload.targetQty)
    elseif action == "CLEAR_TRACKED_REAGENTS" then
        self:ClearTrackedReagents()
    elseif action == "ADD_LUMBER_COORDINATE" then
        self:AddLumberCoordinate(payload.node)
    elseif action == "SET_LUMBER_COORDINATES" then
        self:SetLumberCoordinates(payload.coordinates)
    elseif action == "CLEAR_LUMBER_COORDINATES" then
        self:ClearLumberCoordinates()
    elseif action == "UPSERT_FARMING_SESSION" then
        self:UpsertFarmingSession(payload.session)
    elseif action == "CLEAR_FARMING_HISTORY" then
        self:ClearFarmingHistory()
    elseif action == "DELETE_MOST_RECENT_FARMING_SESSION" then
        self:DeleteMostRecentFarmingSession()
    elseif action == "ADD_TO_VENDOR_SHOPPING_LIST" then
        self:AddToVendorShoppingList(payload.itemID, payload.npcID)
    elseif action == "REMOVE_FROM_VENDOR_SHOPPING_LIST" then
        self:RemoveFromVendorShoppingList(payload.itemID, payload.npcID)
    elseif action == "CLEAR_VENDOR_SHOPPING_LIST" then
        self:ClearVendorShoppingList()
    elseif action == "MOGUL_SET_MODE" then
        self.state.mogul.mode = payload.mode
    elseif action == "MOGUL_SET_VIEW" then
        self.state.mogul.viewMode = payload.viewMode
    elseif action == "MOGUL_SET_OPTIMIZE_BY" then
        self.state.mogul.optimizeBy = payload.optimizeBy
    elseif action == "MOGUL_SET_ALGORITHM" then
        self.state.mogul.algorithm = payload.algorithm
    elseif action == "MOGUL_SET_LUMBER_BUDGET" then
        self.state.mogul.lumberBudget = payload.lumberBudget   -- nil restores live count
    elseif action == "MOGUL_SET_SHOW_CANDIDATES" then
        self.state.mogul.showCandidates = payload.showCandidates
    elseif action == "MOGUL_SET_SUPPLY_IMPACT" then
        local si = self.state.mogul.supplyImpact
        if payload.mode == "off" or payload.mode == "smooth" or payload.mode == "cap" then
            si.mode = payload.mode
        end
        if type(payload.cap) == "number" and payload.cap >= 1 then
            si.cap = math.floor(payload.cap)
        end
        if type(payload.decay) == "number" and payload.decay >= 0 and payload.decay <= 0.99 then
            si.decay = payload.decay
        end
    else
        -- Fallback to legacy reducer if exists
        local reducer = self.reducers[action]
        if reducer then
            local newState = reducer(self.state, payload)
            if newState then self.state = newState end
            self:QueueSave()
        elseif self.state.config.debug then
            print("|cFFdc322f[HDG Store]|r Unknown action:", action)
        end
    end

    -- Trigger EventBus for listeners (backward compat)
    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_STATE_CHANGED", { action = action, state = self.state })
    end
end

-- Expose DeepCopy for other modules
HDG.Store.DeepCopy = DeepCopy

function HDG.Store:SetAltsViewMode(mode)
    self.state.altsViewMode = mode
    self:QueueSave()
end

function HDG.Store:SetRecentActivityCap(cap)
    self.state.recentActivityCap = math.max(10, math.floor(cap or 10))
    self:QueueSave()
end

-- =====================================================================
-- Collection API — single surface for Styles, Crates, Library, Shipping
-- Crates and (eventually) Plans. All decor-collection state goes through
-- here. Each mutation queues a save AND fires an HDG_COLLECTION_* event
-- so derived caches (StyleEngine, CrateEngine) can invalidate explicitly
-- instead of relying on callers to remember.
--
-- Collection record shape:
--   {
--     id      = "<type>:<localID>"  -- e.g. "style:bedroom_blue", "crate:house:..."
--     type    = "style" | "crate" | "library" | "shipping_crate"
--     name    = string
--     members = { [decorID] = true | { addedAt=..., priority=..., ... } }
--     parent  = optional string — roomID for crates, etc.
--     ...    -- type-specific metadata (tier, tags, etc.)
--   }
-- =====================================================================

function HDG.Store:Collection_Get(collID)
    if type(collID) ~= "string" then return nil end
    return self.state.collections[collID]
end

function HDG.Store:Collection_ListAll()
    return self.state.collections
end

function HDG.Store:Collection_ListByType(collType)
    local out = {}
    for id, coll in pairs(self.state.collections) do
        if coll.type == collType then out[id] = coll end
    end
    return out
end

function HDG.Store:Collection_ListByParent(parentID)
    if type(parentID) ~= "string" then return {} end
    local out = {}
    for id, coll in pairs(self.state.collections) do
        if coll.parent == parentID then out[id] = coll end
    end
    return out
end

-- Create-or-update. Caller provides the full record. For partial updates
-- use the field-specific helpers below.
function HDG.Store:Collection_Upsert(coll)
    if type(coll) ~= "table" or type(coll.id) ~= "string" or type(coll.type) ~= "string" then
        return nil
    end
    coll.members = coll.members or {}
    self.state.collections[coll.id] = coll
    self:QueueSave()
    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_COLLECTION_UPSERTED", { collID = coll.id, type = coll.type })
    end
    return coll
end

function HDG.Store:Collection_Delete(collID)
    if type(collID) ~= "string" then return end
    local coll = self.state.collections[collID]
    if not coll then return end
    self.state.collections[collID] = nil
    self:QueueSave()
    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_COLLECTION_DELETED", { collID = collID, type = coll.type })
    end
end

function HDG.Store:Collection_AddDecor(collID, decorID, value)
    if type(collID) ~= "string" or decorID == nil then return false end
    local coll = self.state.collections[collID]
    if not coll then return false end
    coll.members = coll.members or {}
    coll.members[decorID] = (value == nil) and true or value
    self:QueueSave()
    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_COLLECTION_DECOR_ADDED", {
            collID = collID, type = coll.type, decorID = decorID
        })
    end
    return true
end

function HDG.Store:Collection_RemoveDecor(collID, decorID)
    if type(collID) ~= "string" or decorID == nil then return false end
    local coll = self.state.collections[collID]
    if not coll or not coll.members or coll.members[decorID] == nil then return false end
    coll.members[decorID] = nil
    self:QueueSave()
    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_COLLECTION_DECOR_REMOVED", {
            collID = collID, type = coll.type, decorID = decorID
        })
    end
    return true
end

function HDG.Store:Collection_SetField(collID, field, value)
    if type(collID) ~= "string" or type(field) ~= "string" then return false end
    -- Reserved fields the caller cannot stomp through SetField
    if field == "id" or field == "type" or field == "members" then return false end
    local coll = self.state.collections[collID]
    if not coll then return false end
    coll[field] = value
    self:QueueSave()
    if HDG.EventBus then
        HDG.EventBus:Trigger("HDG_COLLECTION_FIELD_CHANGED", {
            collID = collID, type = coll.type, field = field
        })
    end
    return true
end

-- =====================================================================
-- Crate API — thin helpers on top of Collection_*. Crates are room-
-- scoped decor groupings (e.g. "Lounge accents — bedroom Cozy crate").
-- The crate's collID embeds the parent roomID so it sorts/parses
-- consistently with other path-keyed IDs.
--
-- Crate record shape:
--   {
--     id      = "crate:<roomID>:c<short>"
--     type    = "crate"
--     parent  = "<roomID>"            -- house:alliance:f1:rXXXX
--     name    = string                -- user-facing label
--     members = { [decorID] = true | metadata-table }
--     createdAt = epoch
--   }
-- =====================================================================

function HDG.Store:Crate_Create(roomID, name)
    if type(roomID) ~= "string" or roomID == "" then return nil end
    local IDs = privateDB and privateDB.HDG_ProjectsIDs
    if not IDs or not IDs.makeCrateID or not IDs.shortUUID then return nil end

    -- Collision-retry mint (cheap; 4-char hex space ~16k per room).
    local collID
    for _ = 1, 8 do
        local short = IDs.shortUUID(4)
        local crateID = short and IDs.makeCrateID(roomID, short)
        if crateID and not self.state.collections["crate:" .. crateID] then
            collID = "crate:" .. crateID
            break
        end
    end
    if not collID then return nil end

    return self:Collection_Upsert({
        id        = collID,
        type      = "crate",
        parent    = roomID,
        name      = (type(name) == "string" and name ~= "") and name or "New crate",
        members   = {},
        createdAt = time(),
    })
end

function HDG.Store:Crate_ListForRoom(roomID)
    if type(roomID) ~= "string" then return {} end
    local out = {}
    for id, coll in pairs(self.state.collections) do
        if coll.type == "crate" and coll.parent == roomID then
            out[id] = coll
        end
    end
    return out
end

function HDG.Store:Crate_Delete(crateID)
    return self:Collection_Delete(crateID)
end

function HDG.Store:Crate_AddDecor(crateID, decorID)
    return self:Collection_AddDecor(crateID, decorID)
end

function HDG.Store:Crate_RemoveDecor(crateID, decorID)
    return self:Collection_RemoveDecor(crateID, decorID)
end

function HDG.Store:Crate_Rename(crateID, name)
    return self:Collection_SetField(crateID, "name", name)
end

-- An orphan crate is one whose parent is nil OR whose parent roomID no
-- longer resolves to a real room (e.g. after a re-capture pruned the room).
-- Both states are reachable: nil-parent crates come from DeleteProjectRoom's
-- detach pass; stale-parent crates can appear if collection state drifts
-- ahead of room state for any reason. Treat them uniformly.
function HDG.Store:Crate_ListOrphans()
    local rooms = self.state.projects and self.state.projects.rooms or {}
    local out = {}
    for collID, coll in pairs(self.state.collections) do
        if coll.type == "crate" then
            if not coll.parent or not rooms[coll.parent] then
                out[collID] = coll
            end
        end
    end
    return out
end

function HDG.Store:Crate_Reparent(crateID, newRoomID)
    return self:Collection_SetField(crateID, "parent", newRoomID)
end

-- =====================================================================
-- Library API — faction-/room-agnostic crate templates. Curated content
-- ships under "lib_curated:<key>" IDs so player saves and curated entries
-- live in the same Collections table. Stamp clones a library entry into
-- a new crate inside the target room.
--
-- Library record shape:
--   {
--     id        = "lib_<uuid>" | "lib_curated:<key>"
--     type      = "library"
--     name      = string
--     members   = { [decorID] = true }
--     hint      = "cross_shape" | nil   -- preferred room shape
--     source    = "curated" | "player_save" | "imported"
--     origin    = "vamoose:v1.0" | nil  -- curator tag
--     createdAt = epoch
--   }
-- =====================================================================

local function _libraryUUID()
    -- 8-hex random; collide-retry handled by Library_Create.
    local IDs = privateDB and privateDB.HDG_ProjectsIDs
    if IDs and IDs.shortUUID then return IDs.shortUUID(8) end
    return string.format("%08x", math.random(0, 2^31 - 1))
end

function HDG.Store:Library_Create(name, members, opts)
    opts = opts or {}
    local collID
    for _ = 1, 8 do
        local uuid = _libraryUUID()
        if uuid and not self.state.collections["lib_" .. uuid] then
            collID = "lib_" .. uuid
            break
        end
    end
    if not collID then return nil end

    local memberMap = {}
    if type(members) == "table" then
        for k, v in pairs(members) do
            -- Accept either { [decorID] = true } map or { decorID, decorID, ... } array
            if type(k) == "number" and type(v) == "number" then
                memberMap[v] = true
            else
                memberMap[k] = v or true
            end
        end
    end

    return self:Collection_Upsert({
        id        = collID,
        type      = "library",
        name      = (type(name) == "string" and name ~= "") and name or "New template",
        members   = memberMap,
        hint      = opts.hint,
        source    = opts.source or "player_save",
        origin    = opts.origin,
        createdAt = time(),
    })
end

function HDG.Store:Library_ListAll()
    local out = {}
    for id, coll in pairs(self.state.collections) do
        if coll.type == "library" then out[id] = coll end
    end
    return out
end

function HDG.Store:Library_Delete(libID)
    return self:Collection_Delete(libID)
end

function HDG.Store:Library_AddDecor(libID, decorID)
    return self:Collection_AddDecor(libID, decorID)
end

function HDG.Store:Library_RemoveDecor(libID, decorID)
    return self:Collection_RemoveDecor(libID, decorID)
end

-- Stamp a library entry into a room as a fresh crate (clone, not reference).
-- Returns the new crate record on success.
function HDG.Store:Library_Stamp(libID, roomID)
    local lib = self:Collection_Get(libID)
    if not lib or lib.type ~= "library" then return nil end
    local crate = self:Crate_Create(roomID, lib.name)
    if not crate then return nil end
    -- Clone members (deep copy of values to avoid shared metadata refs).
    for decorID, value in pairs(lib.members or {}) do
        if type(value) == "table" then
            local copy = {}
            for k, v in pairs(value) do copy[k] = v end
            self:Collection_AddDecor(crate.id, decorID, copy)
        else
            self:Collection_AddDecor(crate.id, decorID, value)
        end
    end
    -- Stamp the lineage so future "find all crates derived from X" queries work.
    self:Collection_SetField(crate.id, "derivedFrom", libID)
    return self:Collection_Get(crate.id)
end
