-- 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_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_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_StoreFurnishings.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

-- canRemove = HousingLayoutRestriction enum (0=None/removable; 4=IsBaseRoom=the Entry).
local function roomPin(guid, name, canRemove)
    return {
        GetPinType  = function() return 1 end,
        GetRoomGUID = function() return guid end,
        GetRoomName = function() return name end,
        CanMove     = function() return canRemove or 0 end,
        CanRemove   = function() return canRemove or 0 end,
        CanRotate   = function() return 0 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

-- v8: capture writes the live LAYOUT's placements (slot-keyed; lineage =
-- pl.capturedID; curated placements carry a roomID TAG that survives
-- recapture via the in-place capturedID match).
local function _placements(s, houseID)
    local h      = s.account.projects.houses[houseID]
    local lid    = h and h.currentVersionID
    local layout = lid and s.account.projects.layouts[lid]
    return (layout and layout.placements) or {}, lid
end
local function roomsOnFloor(s, floorID)
    local parsed = IDs.parsePath(floorID)
    local count, sample = 0, nil
    for _, pl in pairs(_placements(s, parsed.houseID)) do
        if pl.floor == parsed.floor then count = count + 1; sample = pl 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")
    -- Deterministic captured identity (floor + capture order) rides on the slot.
    local r1, r2 = IDs.makeRoomID(FLOOR, "1"), IDs.makeRoomID(FLOOR, "2")
    assertTrue(sample.capturedID == r1 or sample.capturedID == r2, "slot carries deterministic capturedID")
    assertTrue(sample.x ~= nil and sample.y ~= nil, "committed placement 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.capturedID, "replaced identical room still carries its captured identity")
end

-- ===== Deterministic capture IDs: curated rooms re-match across recaptures =====
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")
    local pls, lid = _placements(s, HOUSE)
    local function slotFor(cid)
        for k, pl in pairs(pls) do if pl.capturedID == cid then return k end end
    end
    local k1 = slotFor(r1)
    assertTrue(k1, "slot minted with deterministic capturedID r1")

    -- Curate slot r1 into a persistent room -> TAGS the slot in place (v8:
    -- the key is stable; lineage stays on the placement).
    HDG.Store:Dispatch({ type = A.FURN_ROOM_CREATE, payload = {
        name = "Keepme", shape = "square_s", layoutID = lid, slotKey = k1 } })
    local roomID = s.session.furn.lastRoomID
    assertEq(pls[k1].roomID, roomID, "curation tags the slot (key survives)")
    assertEq(pls[k1].capturedID, r1, "capture lineage stays on the placement")

    -- recapture SAME 2 rooms (new GUIDs, same order) -> in-place re-match, tag survives
    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)
    assertEq(select(1, roomsOnFloor(s, FLOOR)), 2, "recapture re-matches -- still 2 placements, not 3")
    assertEq(slotFor(r1), k1, "lineage re-match: same slot key, no duplicate")
    assertEq(pls[k1].roomID, roomID, "the roomID tag SURVIVES recapture")

    -- recapture with only 1 room -> r2's placement removed (per-floor diff);
    -- the persistent ROOM (and the curated tag's room) survive
    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 -> exactly 1 placement")
    assertEq(pls[k1].roomID, roomID, "kept room r1 still placed (tag intact)")
    assertTrue(slotFor(r2) == nil, "removed room's placement pruned by the floor diff")
    assertEq(s.account.rooms[roomID].name, "Keepme", "persistent room record survives recapture untouched")
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

-- ===== Locale-independent resolution: catalog bridge (non-English) + Entry via CanRemove =====
-- The bug this guards: capture used to name-match an ENGLISH table, so non-English clients
-- got generic boxes + zero cost. Resolution must come from the live catalog (locale-correct)
-- and, for the Entry, from the pin's IsBaseRoom restriction -- never from the localized name.
-- (ASCII test strings; the only contract is catalog-name == pin-name, which holds per locale.)
do
    local A = HDG.Constants.ACTIONS
    local s = freshState()
    HDG.Store:Dispatch({ type = A.PROJECTS_ROOM_CATALOG_UPDATED, payload = { byShapeID = {}, entries = {
        { recordID = 11,  shapeID = "square_m",    name = "Quadratischer Raum (mittel)" },
        { recordID = 13,  shapeID = "cross_shape", name = "Kreuzfoermiger Raum" },
        { recordID = 2,   shapeID = "hallway",     name = "Flur" },
        { recordID = 151, shapeID = nil,           name = "Full_Layout_Rugged_Prefab_S01" },  -- prefab: nil shapeID -> skipped
    } } })

    HO:_BeginCapture(1)
    HO:OnPinFrameAdded(roomPin("gq", "Quadratischer Raum (mittel)")); HO:OnPinFrameAdded(doorPin("gq", 1, 7, PI, true))
    HO:OnPinFrameAdded(roomPin("gx", "Kreuzfoermiger Raum"));         HO:OnPinFrameAdded(doorPin("gx", 1, 7, 0,  true))
    HO:OnPinFrameAdded(roomPin("gE", "Eingang", 4));                  HO:OnPinFrameAdded(doorPin("gE", 1, 7, 0, true))  -- CanRemove==IsBaseRoom -> Entry
    HO:_FinalizeCapture(HO:_EndCapture(), HOUSE)

    local byShape = {}
    for _, pl in pairs(_placements(s, HOUSE)) do byShape[pl.shape] = (byShape[pl.shape] or 0) + 1 end
    assertEq(byShape["square_m"],    1, "localized 'Quadratischer Raum (mittel)' -> square_m via live catalog")
    assertEq(byShape["cross_shape"], 1, "localized 'Kreuzfoermiger Raum' -> cross_shape via live catalog")
    assertEq(byShape["entry"],       1, "Eingang resolves to entry via CanRemove==IsBaseRoom, NOT its localized name")
end

-- ===== ResolveShape precedence (pure) =====
do
    local cat = { ["Flur"] = "hallway", ["Closet"] = "square_l" }  -- 2nd entry deliberately disagrees with the static table
    assertEq(HO.ResolveShape("Flur", false, cat),                  "hallway",  "live catalog resolves a localized name")
    assertEq(HO.ResolveShape("Eingang", true, cat),                "entry",    "base room -> entry regardless of name")
    assertEq(HO.ResolveShape("Square Room (Small)", false, {}),    "square_s", "empty catalog -> static English fallback")
    assertEq(HO.ResolveShape("Closet", false, cat),                "square_l", "live catalog WINS over static fallback")
    assertEq(HO.ResolveShape("Unbekannt", false, {}),              "Unbekannt","unknown name -> raw (generic cell, never crashes)")
end

print("test_housing_observer_capture.lua passed")
