-- HousingObserver Phase 2 capture-channel smoke test.
-- Verifies the IMPURE glue (pin-frame ingest, finalize wiring, house tick, and
-- the Module Declare) routes through the tested pure HDG.Projects.* modules and
-- dispatches the right Store actions. The pure transform (Capture/Fingerprint/
-- AutoLayout) + the reducer cases are covered by their own tests; this test
-- covers the seam between them that only exists inside the observer.
-- Skill ref: ~/.claude/skills/redux-style-wow-addon/testing.md.
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_ProjectsShapeAtlas.lua")
dofile("../Core/HDGR_ProjectsFloorMap.lua")
dofile("../Core/HDGR_ProjectsFingerprint.lua")
dofile("../Core/HDGR_ProjectsAutoLayout.lua")
dofile("../Core/HDGR_ProjectsCapture.lua")
dofile("../Core/HDGR_Store.lua")
dofile("../UI/HDGR_Selectors.lua")   -- _RawDispatch -> Selectors:InvalidateMemos

local PI = math.pi

-- ===== WoW housing boundary stubs (the observer reads these at capture time) =====
_G.UnitFactionGroup = function() return "Alliance" end
_G.time             = function() return 12345 end
_G.C_HousingLayout  = {
    IsBaseRoom              = function(guid) return guid == "gBase" end,
    GetRoomPlacementBudget  = function() return 30 end,
    GetSpentPlacementBudget = function() return 7 end,
    GetNumFloors            = function() return 2 end,
    GetViewedFloor          = function() return 1 end,
}
_G.C_HousingDecor   = {
    GetMaxPlacementBudget   = function() return 2000 end,
    GetSpentPlacementBudget = function() return 450 end,
    GetNumDecorPlaced       = function() return 88 end,
}
_G.C_HouseEditor    = { IsHouseEditorActive = function() return true end }

-- Capture the Module declaration rather than running the real engine (the
-- event wiring is in-game-only; here we assert the declaration is correct).
HDG.Modules = { Declare = function(self, def) HDG.__hoDef = def end }

dofile("../Modules/HDGR_HousingObserver.lua")

local HO  = HDG.HousingObserver
local def = HDG.__hoDef
local IDs = HDG.Projects.IDs

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

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

local function roomPin(guid, name)
    return {
        GetPinType  = function() return 1 end,
        GetRoomGUID = function() return guid end,
        GetRoomName = function() return name end,
        CanMove     = function() return true end,
        CanRemove   = function() return false end,
        CanRotate   = function() return true end,
    }
end
local function doorPin(guid, doorID, connType, facing, occupied)
    return {
        GetPinType            = function() return 0 end,
        GetRoomGUID           = function() return guid end,
        GetDoorConnectionInfo = function() return { doorID = doorID, connectionType = connType, doorFacing = facing } end,
        IsOccupiedDoor        = function() return occupied end,
    }
end

-- Rooms live under the house's CURRENT version now (house -> version -> room).
local function _versionRooms(s, houseID)
    local h = s.account.projects.houses[houseID]
    local v = h and h.currentVersionID and s.account.projects.versions[h.currentVersionID]
    return (v and v.rooms) or {}
end
local function roomsOnFloor(s, floorID)
    local houseID = IDs.parsePath(floorID).houseID
    local count, sample = 0, nil
    for rid, room in pairs(_versionRooms(s, houseID)) do
        local p = IDs.parsePath(rid)
        if p and p.floorID == floorID then count = count + 1; sample = room end
    end
    return count, sample
end

local HOUSE = "house:alliance"
local FLOOR = "house:alliance:f1"

-- ===== Module declaration: C_HousingLayout owned + capture events wired =====
do
    assertTrue(def, "HousingObserver declared a module")
    local owned = {}
    for _, ns in ipairs(def.ownsBlizzardNamespaces) do owned[ns] = true end
    assertTrue(owned.C_HousingLayout, "observer owns C_HousingLayout")
    assertEq(def.blizzardEvents.HOUSING_LAYOUT_PIN_FRAME_ADDED.handler, "OnPinFrameAddedEvent", "pin event wired")
    assertEq(def.blizzardEvents.HOUSING_LAYOUT_VIEWED_FLOOR_CHANGED.handler, "OnLayoutFloorChangedEvent", "floor event wired")
    assertEq(def.blizzardEvents.HOUSING_NUM_DECOR_PLACED_CHANGED.handler, "OnHousingBudgetEvent", "budget event wired")
    assertEq(def.blizzardEvents.HOUSING_NUM_DECOR_PLACED_CHANGED.debounce, 0.3, "budget event debounced")
    assertTrue(type(def.OnPinFrameAddedEvent) == "function", "OnPinFrameAddedEvent handler exists")
    assertTrue(type(def.OnLayoutFloorChangedEvent) == "function", "OnLayoutFloorChangedEvent handler exists")
    assertTrue(type(def.OnHousingBudgetEvent) == "function", "OnHousingBudgetEvent handler exists")
end

-- ===== Capture two NEW rooms -> ingest -> finalize -> CAPTURE_COMMIT =====
do
    local s = freshState()
    HO:_BeginCapture(1)
    assertTrue(HO:IsCapturing(), "capturing after BeginCapture")
    HO:OnPinFrameAdded(roomPin("g1", "Square Room (Small)"))
    HO:OnPinFrameAdded(doorPin("g1", 1, 7, PI, true))   -- data-S -> player N (occupied lateral)
    HO:OnPinFrameAdded(roomPin("g2", "Square Room (Small)"))
    HO:OnPinFrameAdded(doorPin("g2", 1, 7, 0,  true))   -- data-N -> player S (occupied lateral)
    local snap = HO:_EndCapture()
    assertTrue(not HO:IsCapturing(), "not capturing after EndCapture")
    assertEq(snap.floor, 1, "snapshot carries floor")

    HO:_FinalizeCapture(snap, HOUSE)

    local count, sample = roomsOnFloor(s, FLOOR)
    assertEq(count, 2, "two NEW rooms committed on f1")
    assertEq(sample.shape, "square_s", "localized room name mapped to shape via NAME_TO_SHAPE")
    assertTrue(sample.captureIndex == 1 or sample.captureIndex == 2, "room carries capture-order index (1 or 2)")
    assertTrue(sample.cell ~= nil, "committed room carries a cell (AutoLayout-seeded)")
    -- (fingerprint + canRemove dropped -- not persisted; doors/connectivity derive from cell)
    assertEq(s.account.projects.houses[HOUSE].lastCapturedAt, 12345, "house lastCapturedAt stamped from time()")
    -- (connectivity no longer persisted -- derived from cell-adjacency at render)
end

-- ===== Re-capture REPLACES the live floor (no duplication, no stale rooms) =====
do
    local s = HDG.Store:GetState()
    assertEq(select(1, roomsOnFloor(s, FLOOR)), 2, "precondition: two rooms present")

    HO:_BeginCapture(1)
    HO:OnPinFrameAdded(roomPin("h1", "Square Room (Small)"))   -- new GUIDs
    HO:OnPinFrameAdded(doorPin("h1", 1, 7, PI, true))
    HO:OnPinFrameAdded(roomPin("h2", "Square Room (Small)"))
    HO:OnPinFrameAdded(doorPin("h2", 1, 7, 0,  true))
    HO:_FinalizeCapture(HO:_EndCapture(), HOUSE)

    assertEq(select(1, roomsOnFloor(s, FLOOR)), 2, "re-capture replaces the floor -- still two rooms, not four")
end

-- ===== Replace handles shrink: 3 identical Hallways -> re-capture 2 -> exactly 2 =====
-- (Identical fingerprints used to land in matchRooms' ambiguous bucket and were
-- never re-committed, leaving stale rooms. REPLACE wipes the floor each capture.)
do
    local s = freshState()
    HO:_BeginCapture(1)
    for _, g in ipairs({ "ha", "hb", "hc" }) do
        HO:OnPinFrameAdded(roomPin(g, "Hallway")); HO:OnPinFrameAdded(doorPin(g, 1, 7, PI, true))
    end
    HO:_FinalizeCapture(HO:_EndCapture(), HOUSE)
    assertEq(select(1, roomsOnFloor(s, FLOOR)), 3, "three identical Hallways captured")

    HO:_BeginCapture(1)
    for _, g in ipairs({ "hd", "he" }) do
        HO:OnPinFrameAdded(roomPin(g, "Hallway")); HO:OnPinFrameAdded(doorPin(g, 1, 7, PI, true))
    end
    HO:_FinalizeCapture(HO:_EndCapture(), HOUSE)
    local count, sample = roomsOnFloor(s, FLOOR)
    assertEq(count, 2, "re-capture REPLACES -- exactly 2, not 3 (stale) or 5 (accumulated)")
    assertTrue(sample.captureIndex, "replaced identical room still carries a capture index")
end

-- ===== Deterministic IDs: recapture preserves room IDs -> crates stay attached =====
do
    local A = HDG.Constants.ACTIONS
    local s = freshState()
    HO:_BeginCapture(1)
    HO:OnPinFrameAdded(roomPin("r1g", "Square Room (Small)")); HO:OnPinFrameAdded(doorPin("r1g", 1, 7, PI, true))
    HO:OnPinFrameAdded(roomPin("r2g", "Square Room (Small)")); HO:OnPinFrameAdded(doorPin("r2g", 1, 7, 0,  true))
    HO:_FinalizeCapture(HO:_EndCapture(), HOUSE)
    local r1 = IDs.makeRoomID(FLOOR, "1")   -- deterministic: floor + captureIndex
    local r2 = IDs.makeRoomID(FLOOR, "2")
    assertTrue(_versionRooms(s, HOUSE)[r1], "room minted at deterministic floor:r1 id")

    -- attach crates to both rooms (scoped to the house's current version)
    local vid    = s.account.projects.houses[HOUSE].currentVersionID
    local c1, c2 = "crate:" .. r1, "crate:" .. r2
    HDG.Store:Dispatch({ type = A.CRATE_UPSERT, payload = { crateID = c1, fields = { parent = r1, versionID = vid, name = "Keepme" } } })
    HDG.Store:Dispatch({ type = A.CRATE_UPSERT, payload = { crateID = c2, fields = { parent = r2, versionID = vid, name = "Dropme" } } })

    -- recapture SAME 2 rooms (new GUIDs, same placement order) -> same deterministic IDs
    HO:_BeginCapture(1)
    HO:OnPinFrameAdded(roomPin("r1g2", "Square Room (Small)")); HO:OnPinFrameAdded(doorPin("r1g2", 1, 7, PI, true))
    HO:OnPinFrameAdded(roomPin("r2g2", "Square Room (Small)")); HO:OnPinFrameAdded(doorPin("r2g2", 1, 7, 0,  true))
    HO:_FinalizeCapture(HO:_EndCapture(), HOUSE)
    assertTrue(_versionRooms(s, HOUSE)[r1], "recapture preserves deterministic room id r1")
    assertTrue(s.account.collections[c1].parent == r1, "crate on r1 stays attached across recapture (NOT orphaned)")
    assertTrue(s.account.collections[c2].parent == r2, "crate on r2 stays attached across unchanged recapture")

    -- recapture with only 1 room -> r2 genuinely removed -> its crate orphans, r1 survives
    HO:_BeginCapture(1)
    HO:OnPinFrameAdded(roomPin("r1g3", "Square Room (Small)")); HO:OnPinFrameAdded(doorPin("r1g3", 1, 7, PI, true))
    HO:_FinalizeCapture(HO:_EndCapture(), HOUSE)
    assertEq(select(1, roomsOnFloor(s, FLOOR)), 1, "recapture with 1 room -> floor has exactly 1 room")
    assertTrue(_versionRooms(s, HOUSE)[r1], "kept room r1 still present")
    assertTrue(s.account.collections[c1].parent == r1, "r1 crate survives the partial recapture")
    assertTrue(s.account.collections[c2].parent == nil, "removed room's crate orphaned (parent cleared)")
end

-- ===== _PushHouseTick: live budget/floor/editor reads -> session.house =====
do
    local s = freshState()
    HO:_PushHouseTick()
    -- caps (roomMax/decorMax) are reward-derived now (projects.placementCaps), no longer
    -- captured from the layout-state-dependent live API -- the tick carries decor SPEND only.
    assertEq(s.session.house.budget.decorSpent, 450, "decorSpent from C_HousingDecor")
    assertEq(s.session.house.budget.decorCount, 88, "decorCount from C_HousingDecor")
    assertEq(s.session.house.numFloors, 2, "numFloors from C_HousingLayout")
    assertEq(s.session.house.editorActive, true, "editorActive from C_HouseEditor")
end

print("test_housing_observer_capture.lua passed")
