-- test_furnishings_actions.lua
-- Phase 1.3 of the Furnishings build: lifecycle smoke for the FURN_*/LAYOUT_*
-- reducer cases (docs/crate-redesign/10-FINAL-MODEL.md + 14-BUILD-PLAN.md).
-- Exercises: set CRUD + item count gestures, room CRUD (+ create-in-place from
-- a slot), equip/unequip, v8 tagged slots (multi-assign, reassign, unassign),
-- room-delete untag + local-set demotion, set-delete cascade, promote, ID
-- echoes, and reverse-index (spot count) maintenance.
--
-- NOTE: print() is a no-op in wow_mock.lua; use io.write() for output.

dofile("wow_mock.lua")
dofile("../Core/HDGR_SchemeConstants.lua")
dofile("../Core/HDGR_Constants.lua")
dofile("../Core/HDGR_Format.lua")
dofile("../Core/HDGR_Paths.lua")
dofile("../Core/HDGR_Log.lua")
dofile("../Core/HDGR_ProjectsIDs.lua")
dofile("../Core/HDGR_Memo.lua")
dofile("../Core/HDGR_TableUtils.lua")
dofile("../Core/HDGR_StoreFurnishings.lua")
dofile("../Core/HDGR_Store.lua")
dofile("../UI/HDGR_Selectors.lua")   -- _RawDispatch invalidation path requires the selector registry

local failures = 0
local function fail(msg) failures = failures + 1; io.write("FAIL: " .. msg .. "\n") end
local function assertEq(actual, expected, label)
    if actual ~= expected then
        fail(label .. ": expected " .. tostring(expected) .. " got " .. tostring(actual))
    end
end
local function assertTrue(v, label) if not v then fail(label) end end
local function assertNil(v, label)
    if v ~= nil then fail(label .. " (expected nil, got " .. tostring(v) .. ")") end
end

_G.HDG_DB = {}
HDG.Store:LoadFromSavedVariables()
local A = HDG.Constants.ACTIONS
local function d(t, payload) HDG.Store:_RawDispatch({ type = t, payload = payload or {} }) end
local function st() return HDG.Store:GetState() end

-- Test layout (layout creation itself is rewired from PROJECTS_CREATE_VERSION
-- in Phase 1.7; until then the test seeds one).
st().account.projects.layouts["layout:test"] = {
    houseID = "house:test", name = "Test Layout", createdAt = 0, placements = {},
}
local L = "layout:test"

-- ===== Sets: create + item gestures =========================================

d(A.FURN_SET_CREATE, { name = "Dining for Six", items = { { id = 100, count = 4 } } })
local sid1 = st().session.furn.lastSetID
assertEq(sid1, "set:1", "first set minted as set:1")
local set1 = st().account.furnishingSets[sid1]
assertEq(set1.name, "Dining for Six", "set name")
assertEq(set1.items[1].id, 100, "seed item id")
assertEq(set1.items[1].count, 4, "seed item count")

d(A.FURN_SET_ITEM_ADD, { setID = sid1, itemID = 200 })           -- card click: insert at 1
assertEq(set1.items[2].count, 1, "click-add inserts at count 1")
d(A.FURN_SET_ITEM_ADD, { setID = sid1, itemID = 200 })           -- click again: increment
assertEq(set1.items[2].count, 2, "click-add increments")
d(A.FURN_SET_ITEM_ADD, { setID = sid1, itemID = 200, count = 5 }) -- explicit count SETS
assertEq(set1.items[2].count, 5, "explicit count sets")
d(A.FURN_SET_ITEM_ADD, { setID = sid1, itemID = 200, count = 0 }) -- explicit 0 removes
assertEq(#set1.items, 1, "count 0 removes the entry")
d(A.FURN_SET_ITEM_REMOVE, { setID = sid1, itemID = 100 })        -- right-click: decrement
assertEq(set1.items[1].count, 3, "remove decrements")
d(A.FURN_SET_ITEM_REMOVE, { setID = sid1, itemID = 100, all = true })
assertEq(#set1.items, 0, "remove-all clears the entry")
d(A.FURN_SET_ITEM_ADD, { setID = sid1, itemID = 100, count = 4 })  -- restore for later

d(A.FURN_SET_CREATE, { name = "Candle Lighting", items = { { id = 300, count = 2 } } })
local sid2 = st().session.furn.lastSetID
assertEq(sid2, "set:2", "second set minted as set:2")

-- ===== Rooms: create, equip, once-only dedupe ===============================

d(A.FURN_ROOM_CREATE, { shape = "squareM", name = "Bedroom" })
local rid1 = st().session.furn.lastRoomID
assertEq(rid1, "room:1", "first room minted as room:1")
d(A.FURN_ROOM_EQUIP, { roomID = rid1, setID = sid1 })
d(A.FURN_ROOM_EQUIP, { roomID = rid1, setID = sid2 })
d(A.FURN_ROOM_EQUIP, { roomID = rid1, setID = sid1 })            -- duplicate: no-op
local room1 = st().account.rooms[rid1]
assertEq(#room1.furnishingSetIDs, 2, "equip dedupes (2 sets)")
assertTrue(st().session.furnIndex.setRooms[sid1][rid1], "reverse index: set1 -> room1")
assertTrue(st().session.furnIndex.setRooms[sid2][rid1], "reverse index: set2 -> room1")

-- ===== Placements: tagged slots, MULTI-assign, reassign, unassign (v8) ======

local function _tagged(lay2, rid)
    local keys = {}
    for k, pl in pairs(lay2.placements) do
        if pl.roomID == rid then keys[#keys + 1] = k end
    end
    table.sort(keys)
    return keys
end
local function _bareWithShape(lay2, shape)
    for k, pl in pairs(lay2.placements) do
        if pl.shape == shape and not pl.roomID then return k end
    end
end

d(A.LAYOUT_PLACE, { layoutID = L, roomID = rid1, floor = 1, x = 4, y = 6 })
local lay = st().account.projects.layouts[L]
local k1 = _tagged(lay, rid1)[1]
assertTrue(k1, "room placed (tagged slot)")
assertEq(lay.placements[k1].x, 4, "placement x")
assertEq(lay.placements[k1].shape, "squareM", "tagged slot carries the room's shape")
d(A.LAYOUT_PLACE, { layoutID = L, roomID = rid1, floor = 2, x = 9, y = 9 })
assertEq(#_tagged(lay, rid1), 2, "v8: a room may hold MULTIPLE spots per layout")
assertEq(st().session.furnIndex.roomLayouts[rid1][L], 2, "reverse index counts spots")
d(A.LAYOUT_REMOVE_PLACEMENT, { layoutID = L, key = _tagged(lay, rid1)[2] })
assertEq(#_tagged(lay, rid1), 1, "second spot removed (back to 1)")
k1 = _tagged(lay, rid1)[1]

d(A.LAYOUT_PLACE, { layoutID = L, shape = "squareL", floor = 1, x = 10, y = 2 })
assertTrue(_bareWithShape(lay, "squareL"), "doodle slot persisted")

-- Create-in-place from a slot: TAGS the slot (key is stable in v8).
d(A.LAYOUT_PLACE, { layoutID = L, shape = "octagonM", floor = 2, x = 1, y = 1 })
local kOct = _bareWithShape(lay, "octagonM")
d(A.FURN_ROOM_CREATE, { shape = "octagonM", name = "Gallery", layoutID = L, slotKey = kOct })
local rid2 = st().session.furn.lastRoomID
assertEq(rid2, "room:2", "in-place room minted")
assertEq(lay.placements[kOct].roomID, rid2, "in-place create TAGS the slot (key survives)")
assertEq(lay.placements[kOct].floor, 2, "slot geometry untouched")

-- Lazy curation: assign, REASSIGN, UNASSIGN.
d(A.LAYOUT_PLACE, { layoutID = L, shape = "squareS", floor = 3, x = 5, y = 5 })
local kS = _bareWithShape(lay, "squareS")
d(A.FURN_ROOM_CREATE, { shape = "squareS", name = "Kitchen" })
local rid3 = st().session.furn.lastRoomID
d(A.LAYOUT_ASSIGN, { layoutID = L, slotKey = kS, roomID = rid3 })
assertEq(lay.placements[kS].roomID, rid3, "assign tags the slot")
assertEq(lay.placements[kS].floor, 3, "slot geometry unchanged by assignment")
d(A.LAYOUT_ASSIGN, { layoutID = L, slotKey = kS, roomID = rid2 })
assertEq(lay.placements[kS].roomID, rid2, "REASSIGN retags freely")
assertNil((st().session.furnIndex.roomLayouts[rid3] or {})[L], "index follows the reassign")
d(A.LAYOUT_UNASSIGN, { layoutID = L, key = kS })
assertNil(lay.placements[kS].roomID, "UNASSIGN clears the tag; the bare shape survives")
d(A.LAYOUT_ASSIGN, { layoutID = L, slotKey = kS, roomID = rid3 })   -- restore for the blocks below

-- v8 invariant: a shapeless design cannot be PLACED directly (it adopts a
-- shape via assignment) -- LAYOUT_PLACE{roomID} of one is a loud no-op.
d(A.FURN_ROOM_CREATE, { name = "Shapeless" })
do
    local ridS, before = st().session.furn.lastRoomID, 0
    for _ in pairs(lay.placements) do before = before + 1 end
    d(A.LAYOUT_PLACE, { layoutID = L, roomID = ridS, floor = 1, x = 20, y = 20 })
    local after = 0
    for _ in pairs(lay.placements) do after = after + 1 end
    assertEq(after, before, "shapeless design place refused (no shape=nil record minted)")
end

-- Move + remove placement (slot keys).
d(A.LAYOUT_MOVE, { layoutID = L, key = k1, x = 7, rotation = 90 })
assertEq(lay.placements[k1].x, 7, "move updates x")
assertEq(lay.placements[k1].rotation, 90, "move updates rotation")
assertEq(lay.placements[k1].y, 6, "move preserves unspecified fields")
d(A.LAYOUT_REMOVE_PLACEMENT, { layoutID = L, key = kS })
assertNil(lay.placements[kS], "placement removed")
assertNil((st().session.furnIndex.roomLayouts[rid3] or {})[L], "reverse index cleared on remove")

-- ===== Unequip ==============================================================

d(A.FURN_ROOM_UNEQUIP, { roomID = rid1, setID = sid2 })
assertEq(#st().account.rooms[rid1].furnishingSetIDs, 1, "unequip removes one entry")
assertNil(st().session.furnIndex.setRooms[sid2][rid1], "reverse index cleared on unequip")

-- ===== Room delete: cascade + local-set demotion ============================

d(A.FURN_ROOM_CREATE, { shape = "squareXS" })                     -- unnamed room
local rid4 = st().session.furn.lastRoomID
d(A.FURN_SET_CREATE, { name = "Crate", isLocal = true, ownerRoom = rid4 })
local sid3 = st().session.furn.lastSetID
d(A.FURN_SET_ITEM_ADD, { setID = sid3, itemID = 400, count = 2 })
d(A.FURN_ROOM_EQUIP, { roomID = rid4, setID = sid3 })
d(A.LAYOUT_PLACE, { layoutID = L, roomID = rid4, floor = 1, x = 0, y = 0 })
local k4 = _tagged(lay, rid4)[1]
d(A.FURN_ROOM_DELETE, { roomID = rid4 })
assertNil(st().account.rooms[rid4], "room deleted")
assertTrue(lay.placements[k4], "v8: room delete keeps the spot...")
assertNil(lay.placements[k4].roomID, "...as a bare unassigned shape (untag)")
local demoted = st().account.furnishingSets[sid3]
assertTrue(demoted, "local set survives room delete")
assertNil(demoted.isLocal, "demoted set is no longer local")
assertNil(demoted.ownerRoom, "demoted set has no owner")
assertEq(demoted.name, "Design -- saved pieces", "demoted set named from its design")
assertEq(demoted.items[1].count, 2, "demoted set keeps items")

-- ===== Set delete: cascade out of equip lists ===============================

d(A.FURN_SET_DELETE, { setID = sid1 })
assertNil(st().account.furnishingSets[sid1], "set deleted")
assertEq(#st().account.rooms[rid1].furnishingSetIDs, 0, "set delete cascades out of rooms")
assertNil(st().session.furnIndex.setRooms[sid1], "reverse index entry dropped")

-- ===== Promote ==============================================================

d(A.FURN_SET_PROMOTE, { setID = sid3, name = "Spare Pieces" })
assertEq(st().account.furnishingSets[sid3].name, "Spare Pieces", "promote renames")

-- ===== Duplicate-as-variant + swap (build plan 2.2) =========================

do
    -- Source: a room with one library set + its own local pieces, placed in L.
    d(A.FURN_ROOM_CREATE, { name = "Den", shape = "squareM" })
    local src = st().session.furn.lastRoomID
    d(A.LAYOUT_PLACE, { layoutID = L, roomID = src, floor = 2, x = 4, y = 4 })
    d(A.FURN_SET_CREATE, { name = "Den Core", items = { { id = 901, count = 2 } } })
    local libSet = st().session.furn.lastSetID
    d(A.FURN_ROOM_EQUIP, { roomID = src, setID = libSet })
    d(A.FURN_SET_CREATE, { name = "Den bits", isLocal = true, ownerRoom = src,
                           items = { { id = 902, count = 1 } } })
    local localSet = st().session.furn.lastSetID
    d(A.FURN_ROOM_EQUIP, { roomID = src, setID = localSet })

    d(A.FURN_ROOM_DUPLICATE, { roomID = src, layoutID = L, swap = true })
    local variant = st().session.furn.lastRoomID
    local vroom   = st().account.rooms[variant]
    assertTrue(vroom and vroom.name == "Den (copy)", "copy minted with (copy) default name")
    assertEq(vroom.shape, "squareM", "variant shares the shape")
    -- (v8: capture lineage lives on placements as capturedID, not on rooms.)
    -- Library set shared; local set cloned.
    local sharedLib, clonedLocal
    for _, sid in ipairs(vroom.furnishingSetIDs) do
        local set = st().account.furnishingSets[sid]
        if sid == libSet then sharedLib = true end
        if set.isLocal and set.ownerRoom == variant then clonedLocal = sid end
    end
    assertTrue(sharedLib, "library set SHARED with the variant")
    assertTrue(clonedLocal and clonedLocal ~= localSet, "local pieces CLONED (variant owns its own)")
    assertEq(st().account.furnishingSets[clonedLocal].items[1].id, 902, "clone carries the items")
    -- Swap retagged the spot (v8: the tag moves, the slot key stays).
    local layout = st().account.projects.layouts[L]
    assertEq(#_tagged(layout, variant), 1, "swap=true: the variant holds the spot")
    assertEq(#_tagged(layout, src), 0, "swap=true: the source does not")
    assertEq(st().session.furnIndex.roomLayouts[variant][L], 1, "index follows the swap (variant)")
    assertNil((st().session.furnIndex.roomLayouts[src] or {})[L], "index follows the swap (source)")

    -- LAYOUT_SWAP_ROOM hands the spot back.
    d(A.LAYOUT_SWAP_ROOM, { layoutID = L, fromRoomID = variant, toRoomID = src })
    layout = st().account.projects.layouts[L]
    assertEq(#_tagged(layout, src), 1, "swap returns the spot")
    assertEq(#_tagged(layout, variant), 0, "variant relinquishes it")

    -- v8: swapping onto a room that already holds spots here is LEGAL (multi-spot).
    d(A.LAYOUT_PLACE, { layoutID = L, roomID = variant, floor = 3, x = 8, y = 8 })
    d(A.LAYOUT_SWAP_ROOM, { layoutID = L, fromRoomID = variant, toRoomID = src })
    layout = st().account.projects.layouts[L]
    assertEq(#_tagged(layout, src), 2, "swap onto an already-placed room retags (multi-spot legal)")
    assertEq(st().session.furnIndex.roomLayouts[src][L], 2, "index counts both spots")

    -- Shapeless incomer adopts the outgoing spots' shape.
    d(A.FURN_ROOM_CREATE, { name = "Future Den" })   -- no shape
    local blank = st().session.furn.lastRoomID
    d(A.LAYOUT_SWAP_ROOM, { layoutID = L, fromRoomID = src, toRoomID = blank })
    assertEq(st().account.rooms[blank].shape, "squareM", "shapeless incomer adopts the shape")
    assertEq(#_tagged(st().account.projects.layouts[L], blank), 2, "every spot follows the swap")

    -- Copy names number cleanly: duplicating the COPY strips its suffix and
    -- takes the next free number -- never "(copy) (copy)".
    d(A.FURN_ROOM_DUPLICATE, { roomID = variant })
    assertEq(st().account.rooms[st().session.furn.lastRoomID].name, "Den (copy 2)",
        "fork of a fork numbers (no stacked '(copy) (copy)')")

    -- Explicit name override (Rooms-list Duplicate) -- no swap, no tag.
    d(A.FURN_ROOM_DUPLICATE, { roomID = src, name = "Spare Den" })
    local named = st().account.rooms[st().session.furn.lastRoomID]
    assertEq(named.name, "Spare Den", "duplicate honors the prompted name")
    assertEq(#_tagged(st().account.projects.layouts[L], named.id), 0, "no-swap duplicate stays untagged")
end

-- ===== LayoutView (the spatial pipeline's read shape) =======================

local view = HDG.StoreFurnishings.LayoutView(st(), L)
assertTrue(view[k1], "view carries the tagged spot under its slot key")
assertEq(view[k1].roomID, rid1, "view record resolves the tag")
assertEq(view[k1].floor, 1, "view record carries floor")
assertEq(view[k1].cell.x, 7, "view cell reflects moved placement")
assertEq(view[k1].name, "Bedroom", "view resolves room name")
local kSL = _bareWithShape(lay, "squareL")
assertTrue(view[kSL], "view carries doodle slot")
assertEq(view[kSL].shape, "squareL", "slot shape from placement")
assertTrue(view[kSL].unassigned, "slot flagged unassigned")
assertEq(HDG.StoreFurnishings.LayoutView(st(), "layout:bogus")["x"], nil, "bogus layout -> empty view")

-- FloorMap consumes the view directly (record-carried floors).
dofile("../Core/HDGR_ProjectsShapeAtlas.lua")
dofile("../Core/HDGR_ProjectsFloorMap.lua")
local cells = HDG.Projects.FloorMap.OccupiedCells(view, 1)
assertTrue(next(cells) ~= nil, "FloorMap occupancy works off the view")
assertTrue(HDG.Projects.FloorMap.CanMoveTo(view, k1, 30, 30), "CanMoveTo works off the view (free cell)")

-- ===== Index rebuild parity (hydrate path) ==================================

HDG.Store:Flush()
HDG.Store:LoadFromSavedVariables()
local idx = HDG.Store:GetState().session.furnIndex
assertTrue(idx.roomLayouts["room:1"] and idx.roomLayouts["room:1"][L],
    "rebuilt index matches maintained index (room placements)")
local room2Layouts = idx.roomLayouts["room:2"]
assertTrue(room2Layouts and room2Layouts[L], "rebuilt index covers in-place-created room")

-- ============================================================================

if failures == 0 then
    io.write("test_furnishings_actions.lua passed\n")
else
    io.write(("test_furnishings_actions.lua FAILED (%d)\n"):format(failures))
    os.exit(1)
end
