-- HouseEditor Companion smoke (Phase 17).
--
-- Pure-Lua surface only: state shape + action reducers + selector outputs.
-- The launcher button + window frame creation can't be tested headlessly
-- (requires HouseEditorFrame from Blizzard_HouseEditor); those paths land
-- in in-game smoke when we deploy. This file covers:
--   session.ui.companion shape (windowShown / mode / search / selectedItemID)
--   account.ui.companion.window persistent position shape
--   COMPANION_TOGGLE flips windowShown
--   COMPANION_SET_MODE switches mode + clears selectedItemID
--   COMPANION_SELECT_ITEM writes selectedItemID
--   COMPANION_SET_POSITION persists window position to account
--   companion.* selectors return expected shapes per mode

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")

-- Stub HousingCatalogObserver (Phase 3h: companion selectors read observer,
-- not AllDecorDB). Empty byItemID -- companion smoke only verifies shape/type,
-- not content. Observer cold (IsReady=false) until CATALOG_LOAD_COMPLETED.
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.byItemID     = obs.byItemID     or {}
obs.tagIDToGroup = obs.tagIDToGroup or {}
function obs:IsReady()
    local s = HDG.Store:GetState().session.catalog
    return s and s.status == "ready"
end
function obs:GetRow(itemID)
    if not self:IsReady() then return nil end
    return self.byItemID[itemID]
end
function obs:IterateRows(fn)
    if not self:IsReady() then return end
    for itemID, row in pairs(self.byItemID) do fn(itemID, row) end
end
function obs:GetExpansionForItem(itemID)
    local row = self:GetRow(itemID)
    if not (row and row.dataTagsByID) then return nil end
    for tagID, displayName in pairs(row.dataTagsByID) do
        if self.tagIDToGroup[tagID] == "Expansion" then return displayName end
    end
    return nil
end
function obs:GetAllVendorNames() return {} end
function obs:GetItemsByVendor() return {} end
function obs:GetVendorsForItem() return {} end
function obs:HasRow(itemID) return self.byItemID[itemID] ~= nil end

dofile("../UI/HDGR_Selectors.lua")
dofile("../UI/HDGR_CategoryNav.lua")
dofile("../UI/HDGR_Selectors_Status.lua")
dofile("../UI/HDGR_Selectors_Decor.lua")
dofile("../UI/HDGR_Selectors_Acquisition.lua")
dofile("../UI/HDGR_Selectors_Recipes.lua")
dofile("../UI/HDGR_Selectors_Styles.lua")
dofile("../UI/HDGR_Selectors_Companion.lua")
dofile("../data/HDGR_DecorDB.lua")
HDGR_AllDecorDB = {}  -- stub: deprecated DB archived; test does not assert on its content
dofile("../data/HDGR_StyleDefinitions.lua")
dofile("../data/HDGR_CollectionDefinitions.lua")
dofile("../Modules/HDGR_StaticData.lua")

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

HDG_DB = {}
HDG.Store:LoadFromSavedVariables()
local state = HDG.Store:GetState()
local A     = HDG.Constants.ACTIONS

-- ============================================================================
-- 1. Default state shape
-- ============================================================================

assertTrue(type(state.session.ui.companion) == "table", "session.ui.companion present")
assertEq(state.session.ui.companion.windowShown, false, "default windowShown false")
assertEq(state.session.ui.companion.mode, "styles", "default mode styles")
assertEq(state.session.ui.companion.search, "", "default search empty")
assertEq(state.session.ui.companion.selectedItemID, nil, "default selectedItemID nil")

assertTrue(type(state.account.ui.companion) == "table", "account.ui.companion present")
assertTrue(type(state.account.ui.companion.window) == "table", "window slot present")
assertEq(state.account.ui.companion.window.x, nil, "default window.x nil (use HouseEditorFrame anchor)")
assertEq(state.account.ui.companion.window.y, nil, "default window.y nil")

-- ============================================================================
-- 2. Bare-path selectors
-- ============================================================================

assertEq(HDG.Selectors:Call("companion.windowShown", state, {}), false,
    "companion.windowShown false at default")
assertEq(HDG.Selectors:Call("companion.mode", state, {}), "styles",
    "companion.mode styles at default")
assertEq(HDG.Selectors:Call("companion.search", state, {}), "",
    "companion.search empty at default")
assertEq(HDG.Selectors:Call("companion.selectedItemID", state, {}), nil,
    "companion.selectedItemID nil at default")
assertEq(HDG.Selectors:Call("companion.headerLabel", state, {}), "Your Styles",
    "companion.headerLabel for styles mode")

local pos = HDG.Selectors:Call("companion.windowPosition", state, {})
assertTrue(type(pos) == "table", "companion.windowPosition returns table")
assertEq(pos.x, nil, "default windowPosition.x nil")
assertEq(pos.y, nil, "default windowPosition.y nil")

-- ============================================================================
-- 3. COMPANION_TOGGLE
-- ============================================================================

HDG.Store:Dispatch({ type = A.COMPANION_TOGGLE, payload = {} })
state = HDG.Store:GetState()
assertEq(state.session.ui.companion.windowShown, true, "TOGGLE flips windowShown to true")

HDG.Store:Dispatch({ type = A.COMPANION_TOGGLE, payload = {} })
state = HDG.Store:GetState()
assertEq(state.session.ui.companion.windowShown, false, "TOGGLE again flips back to false")

-- COMPANION_SET_POSITION persists to account
HDG.Store:Dispatch({ type = A.COMPANION_SET_POSITION, payload = { x = 250, y = 400 } })
state = HDG.Store:GetState()
assertEq(state.account.ui.companion.window.x, 250, "SET_POSITION persists x")
assertEq(state.account.ui.companion.window.y, 400, "SET_POSITION persists y")
local pos2 = HDG.Selectors:Call("companion.windowPosition", state, {})
assertEq(pos2.x, 250, "windowPosition selector reflects persisted x")
assertEq(pos2.y, 400, "windowPosition selector reflects persisted y")

-- ============================================================================
-- 4. COMPANION_SET_MODE -- switching mode clears selectedItemID
-- ============================================================================

-- Pre-seed selectedItemID via SELECT_ITEM
HDG.Store:Dispatch({ type = A.COMPANION_SELECT_ITEM, payload = { itemID = "style:test123" } })
state = HDG.Store:GetState()
assertEq(state.session.ui.companion.selectedItemID, "style:test123",
    "SELECT_ITEM writes selectedItemID")

HDG.Store:Dispatch({ type = A.COMPANION_SET_MODE, payload = { mode = "rooms" } })
state = HDG.Store:GetState()
assertEq(state.session.ui.companion.mode, "rooms", "SET_MODE writes mode")
assertEq(state.session.ui.companion.selectedItemID, nil,
    "SET_MODE clears selectedItemID (different collection set per mode)")
assertEq(HDG.Selectors:Call("companion.headerLabel", state, {}), "My Rooms",
    "headerLabel updates after mode switch")

-- ============================================================================
-- 5. Sidebar rows per mode
-- ============================================================================

-- Switch back to styles + verify sidebar surfaces user-created collections.
HDG.Store:Dispatch({ type = A.COMPANION_SET_MODE, payload = { mode = "styles" } })
state = HDG.Store:GetState()

-- No user collections yet at fresh boot -- sidebar empty in styles/rooms/
-- snapshots modes. Themes/collections modes pull from StaticData, so they
-- should always have some rows.
local stylesSidebar = HDG.Selectors:Call("companion.sidebarRows", state, {})
assertTrue(type(stylesSidebar) == "table", "sidebarRows returns table")
-- (No assertion on stylesSidebar length -- fresh state has no user collections.)

HDG.Store:Dispatch({ type = A.COMPANION_SET_MODE, payload = { mode = "themes" } })
state = HDG.Store:GetState()
local themesSidebar = HDG.Selectors:Call("companion.sidebarRows", state, {})
assertTrue(type(themesSidebar) == "table", "themes sidebarRows returns table")

HDG.Store:Dispatch({ type = A.COMPANION_SET_MODE, payload = { mode = "collections" } })
state = HDG.Store:GetState()
local colsSidebar = HDG.Selectors:Call("companion.sidebarRows", state, {})
assertTrue(type(colsSidebar) == "table", "collections sidebarRows returns table")

-- Recent mode: sidebar always empty (no collections list to show).
HDG.Store:Dispatch({ type = A.COMPANION_SET_MODE, payload = { mode = "recent" } })
state = HDG.Store:GetState()
local recentSidebar = HDG.Selectors:Call("companion.sidebarRows", state, {})
assertEq(#recentSidebar, 0, "recent mode sidebar empty (grid takes full pane)")
assertEq(HDG.Selectors:Call("companion.headerLabel", state, {}), "Recent Placements",
    "headerLabel for recent mode")

-- ============================================================================
-- 6. Grid items + recent strip return arrays
-- ============================================================================

local grid = HDG.Selectors:Call("companion.gridItems", state, {})
assertTrue(type(grid) == "table", "gridItems returns table")
-- (No length assertion -- fresh session has no placedDecor.)

local strip = HDG.Selectors:Call("companion.recentStrip", state, {})
assertTrue(type(strip) == "table", "recentStrip returns table")
assertTrue(#strip <= 20, "recentStrip capped at 20 entries (got " .. #strip .. ")")

-- ============================================================================
-- 7. Placement payload + cost-bucket mode + dye expansion (in-editor placement)
-- ============================================================================
-- Seed the stub observer with two owned rows and mark the catalog ready, then
-- exercise the placementcost mode + dye-variant grid expansion that drive
-- in-editor decor placement (C_HousingBasicMode.StartPlacingNewDecor consumes
-- the ed.entryID this section asserts is present).

-- Direct-set catalog status (test fixture): enables the stub observer's
-- IsReady() so GetRow / IterateRows return seeded rows.
HDG.Store:GetState().session.catalog.status = "ready"

obs.byItemID[5001] = {
    itemID = 5001, name = "Oak Chair",
    entryID = { recordID = 9001, entryType = 1 },
    iconTexture = 123,
    quantity = 2, remainingRedeemable = 0, numPlaced = 0, isOwned = true,
    isAllowedIndoors = true, isAllowedOutdoors = false,  -- indoor-only
    placementCost = 1, isUniqueTrophy = false,
}
obs.byItemID[5002] = {
    itemID = 5002, name = "Velvet Sofa",
    entryID = { recordID = 9002, entryType = 1 },
    iconTexture = 124,
    quantity = 3, remainingRedeemable = 0, numPlaced = 0, isOwned = true,
    isAllowedIndoors = true, isAllowedOutdoors = true,
    placementCost = 5, isUniqueTrophy = false,
    dyedVariants = {
        { variantIdentifier = 7, numStored = 1, label = "Crimson",
          entryID = { recordID = 9002, entryType = 1, variantIdentifier = 7 } },
    },
}

-- Cost buckets live INSIDE the Collections sidebar (a "By Placement Cost"
-- header + 4 bucket rows after the collection rows), not a separate mode.
HDG.Store:Dispatch({ type = A.COMPANION_SET_MODE, payload = { mode = "collections" } })
state = HDG.Store:GetState()
local collSidebar = HDG.Selectors:Call("companion.sidebarRows", state, {})
local sawHeader, bucketRows = false, 0
for _, r in ipairs(collSidebar) do
    if r.isHeader and r.displayName == "By Placement Cost" then sawHeader = true end
    if r.type == "placementcost" then bucketRows = bucketRows + 1 end
end
assertTrue(sawHeader, "Collections sidebar has a 'By Placement Cost' header")
assertEq(bucketRows, 4, "Collections sidebar nests the 4 cost buckets")

-- Selecting a bucket id (from Collections) drives the grid via the cost band.
-- Small (1-2) -> the cost-1 chair, carrying full placement payload.
HDG.Store:Dispatch({ type = A.COMPANION_SELECT_ITEM, payload = { itemID = "placementcost:small" } })
state = HDG.Store:GetState()
local small = HDG.Selectors:Call("companion.gridItems", state, {})
assertEq(#small, 1, "Small bucket returns the cost-1 item")
assertTrue(type(small[1].entryID) == "table", "grid ed carries placeable entryID table")
assertEq(small[1].entryID.recordID, 9001, "entryID is the chair's catalog entry")
assertEq(small[1].ownedQty, 2, "grid ed carries ownedQty")
assertEq(small[1].allowIndoors, true, "grid ed carries allowIndoors")
assertEq(small[1].allowOutdoors, false, "grid ed carries allowOutdoors (indoor-only)")

-- Large (5) bucket -> the dyed sofa expands into base (undyed) + dyed cells
HDG.Store:Dispatch({ type = A.COMPANION_SELECT_ITEM, payload = { itemID = "placementcost:large" } })
state = HDG.Store:GetState()
local large = HDG.Selectors:Call("companion.gridItems", state, {})
assertEq(#large, 2, "customizable item expands into base + 1 dyed cell")
local baseCell, dyedCell
for _, c in ipairs(large) do
    if tostring(c.name):find("Undyed") then baseCell = c else dyedCell = c end
end
assertTrue(baseCell ~= nil, "base (undyed) cell present")
assertTrue(dyedCell ~= nil, "dyed cell present")
assertEq(baseCell.ownedQty, 2, "base undyed qty = aggregate(3) - dyed stored(1)")
assertEq(dyedCell.ownedQty, 1, "dyed cell qty = variant numStored")
assertEq(dyedCell.entryID.variantIdentifier, 7, "dyed cell carries the variant entryID")

-- ============================================================================
-- 8. Recent Activity: persisted per-house edit-session history (#1)
-- ============================================================================
local HKEY = "house:alliance"

-- A session opens; placements aggregate by itemID into its event map.
HDG.Store:Dispatch({ type = A.RECENT_SESSION_START, payload = { houseKey = HKEY } })
HDG.Store:Dispatch({ type = A.RECENT_DECOR_PLACED, payload = { houseKey = HKEY, itemID = 5001 } })
HDG.Store:Dispatch({ type = A.RECENT_DECOR_PLACED, payload = { houseKey = HKEY, itemID = 5001 } })
HDG.Store:Dispatch({ type = A.RECENT_DECOR_PLACED, payload = { houseKey = HKEY, itemID = 5002 } })

local ra = HDG.Store:GetState().account.recentActivity
assertEq(ra.lastHouseKey, HKEY, "lastHouseKey tracks the active house")
local house = ra.houses[HKEY]
assertTrue(house ~= nil, "house bucket created")
assertEq(#house.sessionOrder, 1, "one session open")
local sess = house.sessions[house.sessionOrder[1]]
assertEq(sess.events[5001].placed, 2, "two placements of 5001 aggregate")
assertEq(sess.events[5002].placed, 1, "one placement of 5002")
assertEq(sess.eventCount, 3, "session eventCount totals all events")

-- Removal records a "removed" event (itemID looked up from placedDecor).
HDG.Store:Dispatch({ type = A.STYLES_PLACED_DECOR_OBSERVED,
    payload = { decorGUID = "g-5001", itemID = 5001 } })
HDG.Store:Dispatch({ type = A.STYLES_PLACED_DECOR_REMOVED, payload = { decorGUID = "g-5001" } })
ra   = HDG.Store:GetState().account.recentActivity
sess = ra.houses[HKEY].sessions[ra.houses[HKEY].sessionOrder[1]]
assertEq(sess.events[5001].removed, 1, "removal recorded against itemID 5001")

-- recentSessions selector exposes the session newest-first + active flag.
state = HDG.Store:GetState()
local sessions = HDG.Selectors:Call("companion.recentSessions", state, {})
assertEq(#sessions, 1, "recentSessions returns the open session")
assertTrue(sessions[1].isActive, "newest unsealed session is active")
assertEq(sessions[1].eventCount, 4, "eventCount includes the removal")

-- Recent grid = the selected session's event groups (carry placed/removed).
HDG.Store:Dispatch({ type = A.COMPANION_SET_MODE, payload = { mode = "recent" } })
HDG.Store:Dispatch({ type = A.COMPANION_SELECT_ITEM, payload = { itemID = sessions[1].id } })
state = HDG.Store:GetState()
local recentGrid = HDG.Selectors:Call("companion.gridItems", state, {})
assertEq(#recentGrid, 2, "recent grid shows the 2 distinct items touched this session")
local g5001
for _, g in ipairs(recentGrid) do if g.itemID == 5001 then g5001 = g end end
assertTrue(g5001 ~= nil, "5001 present in recent grid")
assertEq(g5001.placedCount, 2, "recent cell carries placedCount")
assertEq(g5001.removedCount, 1, "recent cell carries removedCount")

-- Recent-mode sidebar now flows through companion.sidebarRows (LayoutConfig
-- conversion 2026-06-03): one bound selector drives the sidebar in every mode.
-- With a session present, it returns the session row tagged isRecentSession;
-- the companionSidebarRow factory formats the time label at paint.
local recentSidebar2 = HDG.Selectors:Call("companion.sidebarRows", state, {})
assertEq(#recentSidebar2, 1, "recent-mode sidebarRows returns the open session row")
assertTrue(recentSidebar2[1].isRecentSession, "recent sidebar row tagged isRecentSession")
assertEq(recentSidebar2[1].id, sessions[1].id, "recent sidebar row carries the session id")
assertTrue(recentSidebar2[1].isSelected, "selected session highlighted in the sidebar")

-- The active session HAS events, so SESSION_START seals it + opens a fresh one.
HDG.Store:Dispatch({ type = A.RECENT_SESSION_START, payload = { houseKey = HKEY } })
assertEq(#HDG.Store:GetState().account.recentActivity.houses[HKEY].sessionOrder, 2,
    "session with events seals + a fresh session opens")
-- The fresh session is empty, so opening again REUSES it (no empty-session spam).
HDG.Store:Dispatch({ type = A.RECENT_SESSION_START, payload = { houseKey = HKEY } })
assertEq(#HDG.Store:GetState().account.recentActivity.houses[HKEY].sessionOrder, 2,
    "empty active session is reused (no empty-session spam)")

-- ============================================================================
-- 9. Collection grid resolves through the shared StyleResolve surface.
-- ============================================================================
-- A membership collection (items[]) populates the companion grid -- the same
-- HDG.StyleResolve.ItemsFor path that lets rule-based collections resolve too.
HDG.Store:GetState().account.collections["style:resolvetest"] = {
    type = "style", displayName = "Resolve Test", items = { 5001, 5002 },
}
HDG.Store:Dispatch({ type = A.COMPANION_SET_MODE, payload = { mode = "styles" } })
HDG.Store:Dispatch({ type = A.COMPANION_SELECT_ITEM, payload = { itemID = "style:resolvetest" } })
state = HDG.Store:GetState()
local resolved = HDG.Selectors:Call("companion.gridItems", state, {})
local haveChair, haveSofa = false, false
for _, c in ipairs(resolved) do
    if c.itemID == 5001 then haveChair = true end
    if c.itemID == 5002 then haveSofa = true end
end
assertTrue(haveChair and haveSofa,
    "membership collection populates the grid via StyleResolve.ItemsFor")
assertTrue(HDG.StyleResolve and HDG.StyleResolve.ItemsFor ~= nil,
    "StyleResolve shared surface is exposed")

-- ============================================================================
-- 10. Cost toggle + Indoor/Outdoor filter (header controls)
-- ============================================================================
-- showCost flips.
local function showCost() return HDG.Store:GetState().session.ui.companion.showCost end
assertTrue(showCost(), "showCost defaults true")
HDG.Store:Dispatch({ type = A.COMPANION_TOGGLE_COST, payload = {} })
assertEq(showCost(), false, "COMPANION_TOGGLE_COST flips showCost off")
HDG.Store:Dispatch({ type = A.COMPANION_TOGGLE_COST, payload = {} })
assertEq(showCost(), true, "COMPANION_TOGGLE_COST flips back on")

-- ioFilter cycles all -> indoor -> outdoor -> all.
local function ioFilter() return HDG.Store:GetState().session.ui.companion.ioFilter end
assertEq(ioFilter(), "all", "ioFilter defaults all")
HDG.Store:Dispatch({ type = A.COMPANION_CYCLE_IO, payload = {} })
assertEq(ioFilter(), "indoor", "cycle -> indoor")
HDG.Store:Dispatch({ type = A.COMPANION_CYCLE_IO, payload = {} })
assertEq(ioFilter(), "outdoor", "cycle -> outdoor")
HDG.Store:Dispatch({ type = A.COMPANION_CYCLE_IO, payload = {} })
assertEq(ioFilter(), "all", "cycle -> all (wraps)")

-- ioFilter excludes non-matching rows from the grid. Seed an outdoor-only item
-- alongside the indoor-only chair (5001) in a collection, then filter.
obs.byItemID[5003] = {
    itemID = 5003, name = "Garden Bench",
    entryID = { recordID = 9003, entryType = 1 },
    iconTexture = 125,
    quantity = 1, remainingRedeemable = 0, numPlaced = 0, isOwned = true,
    isAllowedIndoors = false, isAllowedOutdoors = true,   -- outdoor only
    placementCost = 1, isUniqueTrophy = false,
}
HDG.Store:GetState().account.collections["style:iotest"] = {
    type = "style", displayName = "IO Test", items = { 5001, 5003 },
}
HDG.Store:Dispatch({ type = A.COMPANION_SET_MODE, payload = { mode = "styles" } })
HDG.Store:Dispatch({ type = A.COMPANION_SELECT_ITEM, payload = { itemID = "style:iotest" } })

local function gridHas(itemID)
    for _, c in ipairs(HDG.Selectors:Call("companion.gridItems", HDG.Store:GetState(), {})) do
        if c.itemID == itemID then return true end
    end
    return false
end

-- all: both present (ioFilter is "all" after the wrap above).
assertTrue(gridHas(5001) and gridHas(5003), "ioFilter all shows indoor + outdoor items")
-- indoor: only the indoor-only chair.
HDG.Store:Dispatch({ type = A.COMPANION_CYCLE_IO, payload = {} })  -- all -> indoor
assertTrue(gridHas(5001), "indoor keeps the indoor-allowed item")
assertTrue(not gridHas(5003), "indoor excludes the outdoor-only item")
-- outdoor: only the outdoor-only bench.
HDG.Store:Dispatch({ type = A.COMPANION_CYCLE_IO, payload = {} })  -- indoor -> outdoor
assertTrue(gridHas(5003), "outdoor keeps the outdoor-allowed item")
assertTrue(not gridHas(5001), "outdoor excludes the indoor-only item")

print("test_companion_smoke.lua: ALL PASS")
