-- Test: Phase 4.4 reducer + observer tests for collection actions.
--
-- Phase 4.4 moved catalog row storage from state.account.collection to the
-- observer (HousingCatalogObserver). These tests verify:
--   1. COLLECTION_CATALOG_ROW_COUNTS_UPDATED -> observer PatchCounts (no state write)
--   2. COLLECTION_CATALOG_ROW_REMOVED -> ownedDecorIDs cleaned, observer RemoveRow
--   3. COLLECTION_OWNERSHIP_DELTA -> ownedDecorIDs bulk mutations
--
-- Skill ref: ~/.claude/skills/redux-style-wow-addon/testing.md §Layer 1.5

dofile("wow_mock.lua")
dofile("../Core/HDGR_SchemeConstants.lua")
dofile("../Core/HDGR_Constants.lua")
dofile("../Core/HDGR_Actions.lua")
dofile("../Core/HDGR_Resolvers.lua")
dofile("../Core/HDGR_Format.lua")
dofile("../Core/HDGR_Paths.lua")
dofile("../Core/HDGR_Log.lua")
dofile("../Core/HDGR_StoreFurnishings.lua")
dofile("../Core/HDGR_Store.lua")
-- Store._RawDispatch calls HDG.Selectors:InvalidateMemos unconditionally;
-- Selectors must be loaded even though this test only exercises the reducer.
dofile("../UI/HDGR_Selectors.lua")

-- Stub observer with PatchCounts / RemoveRow / UpsertRow so the
-- Store.Subscribe handler can call them.
HDG.HousingCatalogObserver = HDG.HousingCatalogObserver or {}
-- Canonical IsOwned helper stub (mirrors production formula
-- quantity + remainingRedeemable + numPlaced > 0).
HDG.HousingCatalogObserver.IsOwned = HDG.HousingCatalogObserver.IsOwned
    or function(self, rowOrID)
        local row
        if type(rowOrID) == "table" then row = rowOrID
        elseif type(rowOrID) == "number" then
            row = HDG.HousingCatalogObserver.byItemID and HDG.HousingCatalogObserver.byItemID[rowOrID]
        end
        if not row then return false end
        return ((row.quantity or 0)
              + (row.remainingRedeemable or 0)
              + (row.numPlaced or 0)) > 0
    end

local obs = HDG.HousingCatalogObserver
obs.byDecorID = obs.byDecorID or {}
obs.byItemID  = obs.byItemID  or {}
obs._catalogSchemaVersion = 0

function obs:UpsertRow(decorID, entry)
    if not (decorID and entry) then return end
    obs.byDecorID[decorID] = entry
    if entry.itemID then obs.byItemID[entry.itemID] = entry end
end
function obs:RemoveRow(decorID)
    if not decorID then return end
    local row = obs.byDecorID[decorID]
    if row and row.itemID then obs.byItemID[row.itemID] = nil end
    obs.byDecorID[decorID] = nil
end
function obs:PatchCounts(decorID, counts)
    if not (decorID and counts) then return end
    local row = obs.byDecorID[decorID]
    if not row then return end
    row.quantity                 = counts.quantity                 or 0
    row.numPlaced                = counts.numPlaced                or 0
    row.remainingRedeemable      = counts.remainingRedeemable      or 0
    row.destroyableInstanceCount = counts.destroyableInstanceCount or 0
    row.firstAcquisitionBonus    = counts.firstAcquisitionBonus    or 0
    row.isOwned = (row.quantity + row.numPlaced) > 0
    row.collected = row.isOwned
end
function obs:ClearStore()
    obs.byDecorID = {}
    obs.byItemID  = {}
    obs._catalogSchemaVersion = 0
end

local function assertEq(a, e, label)
    if a ~= e then error(label .. ": expected " .. tostring(e) .. ", got " .. tostring(a), 2) end
end
local function assertTrue(v, label) if not v then error(label, 2) end end
local function assertNil(v, label) if v ~= nil then error(label .. ": expected nil, got " .. tostring(v), 2) end end

local A = HDG.Constants.ACTIONS

local function freshState()
    HDG_DB = {}
    HDG.Store:LoadFromSavedVariables()
    obs.byDecorID = {}
    obs.byItemID  = {}
    return HDG.Store:GetState()
end

-- Register the Store.Subscribe handler (normally done by observer onEnable).
-- Re-register each freshState() since the store re-initializes.
local function registerObserverSubscriber()
    obs._storeToken = HDG.Store:Subscribe(function(actionType, invalidation, action)
        if not action then return end
        local p = action.payload
        if actionType == A.COLLECTION_CATALOG_ROW_ADDED then
            if p then obs:UpsertRow(p.decorID, p.entry) end
        elseif actionType == A.COLLECTION_CATALOG_ROW_REMOVED then
            if p then obs:RemoveRow(p.decorID) end
        elseif actionType == A.COLLECTION_CATALOG_ROW_COUNTS_UPDATED then
            if p then obs:PatchCounts(p.decorID, p.counts) end
        elseif actionType == A.COLLECTION_RESET then
            obs:ClearStore()
        end
    end)
end

-- ============================================================================
-- COLLECTION_CATALOG_ROW_COUNTS_UPDATED -> observer PatchCounts
-- ============================================================================
-- Phase 4.4: reducer is a no-op for this action; the observer's PatchCounts
-- mutates its own in-memory row store via Store.Subscribe.

-- ----- Case 1: all-fields-set update against an existing row ---------------
do
    freshState()
    registerObserverSubscriber()
    obs.byDecorID[42] = {
        name = "Test Decor", itemID = 1234, decorID = 42, category = "Walls",
        quantity = 5, numPlaced = 1, remainingRedeemable = 0,
        destroyableInstanceCount = 0, firstAcquisitionBonus = 0,
    }

    HDG.Store:Dispatch({
        type    = A.COLLECTION_CATALOG_ROW_COUNTS_UPDATED,
        payload = {
            decorID = 42,
            counts  = { quantity = 8, numPlaced = 2, remainingRedeemable = 3,
                        destroyableInstanceCount = 1, firstAcquisitionBonus = 1 },
        },
    })

    local row = obs.byDecorID[42]
    assertEq(row.quantity,                 8, "Case 1: quantity patched")
    assertEq(row.numPlaced,                2, "Case 1: numPlaced patched")
    assertEq(row.remainingRedeemable,      3, "Case 1: remainingRedeemable patched")
    assertEq(row.destroyableInstanceCount, 1, "Case 1: destroyableInstanceCount patched")
    assertEq(row.firstAcquisitionBonus,    1, "Case 1: firstAcquisitionBonus patched")
    -- Static fields preserved (PatchCounts only touches count fields)
    assertEq(row.name,     "Test Decor", "Case 1: static name preserved")
    assertEq(row.itemID,   1234,         "Case 1: static itemID preserved")
    assertEq(row.category, "Walls",      "Case 1: static category preserved")
    -- State catalog no longer exists (deleted in Phase 4.4)
    assertNil(HDG.Store:GetState().account.collection.decorCatalog,
        "Case 1: state.account.collection.decorCatalog absent post-4.4")
end

-- ----- Case 2: partial counts payload (only quantity) ----------------------
-- PatchCounts uses `or 0` for missing fields -- partial payload zeroes unspecified.
do
    freshState()
    registerObserverSubscriber()
    obs.byDecorID[43] = {
        name = "Partial Test", decorID = 43, quantity = 10, numPlaced = 4,
        remainingRedeemable = 2, destroyableInstanceCount = 1, firstAcquisitionBonus = 1,
    }

    HDG.Store:Dispatch({
        type    = A.COLLECTION_CATALOG_ROW_COUNTS_UPDATED,
        payload = { decorID = 43, counts = { quantity = 15 } },  -- ONLY quantity
    })

    local row = obs.byDecorID[43]
    assertEq(row.quantity,                 15, "Case 2: quantity patched")
    assertEq(row.numPlaced,                0,  "Case 2: numPlaced zeroed (PatchCounts uses 'or 0')")
    assertEq(row.remainingRedeemable,      0,  "Case 2: remainingRedeemable zeroed")
    assertEq(row.destroyableInstanceCount, 0,  "Case 2: destroyableInstanceCount zeroed")
    assertEq(row.firstAcquisitionBonus,    0,  "Case 2: firstAcquisitionBonus zeroed")
end

-- ----- Case 3: no-op on unknown decorID ------------------------------------
-- PatchCounts returns early when the decorID isn't in byDecorID.
do
    freshState()
    registerObserverSubscriber()
    local sizeBefore = 0
    for _ in pairs(obs.byDecorID) do sizeBefore = sizeBefore + 1 end

    HDG.Store:Dispatch({
        type    = A.COLLECTION_CATALOG_ROW_COUNTS_UPDATED,
        payload = { decorID = 999999, counts = { quantity = 1 } },
    })

    local sizeAfter = 0
    for _ in pairs(obs.byDecorID) do sizeAfter = sizeAfter + 1 end
    assertEq(sizeAfter, sizeBefore, "Case 3: unknown decorID is a no-op (observer size unchanged)")
    assertNil(obs.byDecorID[999999], "Case 3: unknown decorID NOT inserted into observer")
end

-- ----- Case 4: all-zero counts payload (explicitly zero, not nil) ----------
do
    freshState()
    registerObserverSubscriber()
    obs.byDecorID[44] = {
        name = "Zeroed", decorID = 44, quantity = 5, numPlaced = 2,
        remainingRedeemable = 1, destroyableInstanceCount = 1, firstAcquisitionBonus = 0,
    }

    HDG.Store:Dispatch({
        type    = A.COLLECTION_CATALOG_ROW_COUNTS_UPDATED,
        payload = {
            decorID = 44,
            counts  = { quantity = 0, numPlaced = 0, remainingRedeemable = 0,
                        destroyableInstanceCount = 0, firstAcquisitionBonus = 0 },
        },
    })

    local row = obs.byDecorID[44]
    assertEq(row.quantity,                 0, "Case 4: explicit zero quantity written")
    assertEq(row.numPlaced,                0, "Case 4: explicit zero numPlaced written")
    assertEq(row.remainingRedeemable,      0, "Case 4: explicit zero remainingRedeemable written")
    assertEq(row.destroyableInstanceCount, 0, "Case 4: explicit zero destroyableInstanceCount written")
    assertEq(row.firstAcquisitionBonus,    0, "Case 4: explicit zero firstAcquisitionBonus written")
end

-- ----- Case 5: counts independent of ownership -----------------------------
do
    freshState()
    registerObserverSubscriber()
    obs.byDecorID[45] = { name = "Non-owned", decorID = 45, quantity = 0 }
    -- Don't set ownedDecorIDs[45] -- the row exists in observer but is NOT owned.

    HDG.Store:Dispatch({
        type    = A.COLLECTION_CATALOG_ROW_COUNTS_UPDATED,
        payload = { decorID = 45, counts = { quantity = 5 } },
    })

    local owned = HDG.Store:GetState().account.collection.ownedDecorIDs
    assertNil(owned[45], "Case 5: counts update did NOT mutate ownership")
    assertEq(obs.byDecorID[45].quantity, 5, "Case 5: count still updated on non-owned row")
end

-- ----- Case 6: nil counts payload -----------------------------------------
-- PatchCounts has `if not (decorID and counts) then return end` guard.
do
    freshState()
    registerObserverSubscriber()
    obs.byDecorID[46] = { name = "Preserved", decorID = 46, quantity = 7, numPlaced = 3 }

    local ok = pcall(function()
        HDG.Store:Dispatch({
            type    = A.COLLECTION_CATALOG_ROW_COUNTS_UPDATED,
            payload = { decorID = 46 },  -- no `counts` field
        })
    end)
    assertTrue(ok, "Case 6: missing counts payload must not crash")

    assertEq(obs.byDecorID[46].quantity,  7, "Case 6: original quantity preserved on no-op")
    assertEq(obs.byDecorID[46].numPlaced, 3, "Case 6: original numPlaced preserved on no-op")
end

-- (COLLECTION_OWNERSHIP_DELTA test section deleted 2026-05-27 alongside the
-- VerifyOwnedIDsAsync warm-start drift sweep that dispatched it. Action +
-- reducer case retired; tests for them removed.)

-- ============================================================================
-- COLLECTION_CATALOG_ROW_REMOVED
-- ============================================================================
-- Phase 4.4: reducer cleans ownedDecorIDs[decorID]; observer RemoveRow removes
-- the row from byDecorID/byItemID via Store.Subscribe.

do
    freshState()
    registerObserverSubscriber()
    obs.byDecorID[50] = { name = "ToRemove", decorID = 50, itemID = 9999, quantity = 3 }
    obs.byItemID[9999] = obs.byDecorID[50]
    HDG.Store:GetState().account.collection.ownedDecorIDs[50] = true

    HDG.Store:Dispatch({
        type    = A.COLLECTION_CATALOG_ROW_REMOVED,
        payload = { decorID = 50 },
    })

    assertNil(obs.byDecorID[50], "ROW_REMOVED: observer byDecorID entry deleted")
    assertNil(obs.byItemID[9999], "ROW_REMOVED: observer byItemID entry deleted")
    assertNil(HDG.Store:GetState().account.collection.ownedDecorIDs[50],
        "ROW_REMOVED: ownedDecorIDs[50] cleaned by reducer")
end

print("test_reducers_collection.lua passed")
