-- test_house_aggregator_catalog.lua
-- Phase 3d: HouseAggregator bySource/byExp buckets derive from
-- HousingCatalogObserver (not AllDecorDB).
--
-- Covers:
--   1. bySource + byExp emit empty buckets when observer is cold (IsReady=false)
--   2. After observer ready, buckets reflect catalog item categorization
--   3. ItemAugment sourceType override is bucketed via deriveSourcePrimary
--   4. Expansion tags drive byExp keys (not allEntry.exp from AllDecorDB)
--   5. Total counts add up (sanity: sum of bySource.total == totalAll)
--   6. snapshotChangeSeq invalidation path: CATALOG_LOAD_COMPLETED triggers rebuild

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")
dofile("../UI/HDGR_Selectors.lua")
dofile("../UI/HDGR_Selectors_Status.lua")

-- Stub ItemNameResolver.
HDG.ItemNameResolver = HDG.ItemNameResolver or {
    ResolveIcon = function() return nil end,
    ResolveName = function() return nil end,
}

-- Stub HousingCatalogObserver.
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.byDecorID    = obs.byDecorID    or {}  -- Phase 4.4: BuildSnapshot reads this
obs.byVendor     = obs.byVendor     or {}  -- _topVendorsSnapshot iterates this (prod inits {})
obs.tagIDToGroup = obs.tagIDToGroup or {}
obs._ready       = false

function obs:IsReady()
    return self._ready
end

function obs:GetRow(itemID)
    if not self:IsReady() then return nil end
    return self.byItemID[itemID]
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:IterateRows(fn)
    if not self:IsReady() then return end
    for itemID, row in pairs(self.byItemID) do fn(itemID, row) end
end

-- populateSourceFlags stub removed 2026-05-29: the function was retired in the
-- 2026-05-28 sourceTags unification, and the aggregator derives
-- primarySourceCode from observer.sourceTags (not per-source flags). The
-- ControllerHelpers.UI table is kept in case a dofile'd module expects it.
HDG.ControllerHelpers = HDG.ControllerHelpers or {}
HDG.ControllerHelpers.UI = HDG.ControllerHelpers.UI or {}

-- Pre-populate the data globals that StaticData.lua validates at Get() time.
-- Empty tables = no augments; tests inject per-item data below via patched Get.
_G.HDGR_ItemAugment  = _G.HDGR_ItemAugment  or {}
_G.HDGR_VendorAugment = _G.HDGR_VendorAugment or {}

-- Minimal selectors needed by HouseAggregator.
dofile("../UI/HDGR_Selectors_Decor.lua")
dofile("../UI/HDGR_Selectors_Acquisition.lua")
dofile("../UI/HDGR_Selectors_Recipes.lua")
dofile("../UI/HDGR_Selectors_House.lua")
HDGR_AllDecorDB = {}  -- stub: deprecated DB archived; test does not assert on its content
dofile("../data/HDGR_HouseTab_TitleTiers.lua")
dofile("../data/HDGR_HouseTab_WidgetDefaults.lua")
dofile("../Modules/HDGR_StaticData.lua")

HDG.Modules = HDG.Modules or { Declare = function() end }
dofile("../Modules/HDGR_HouseAggregator.lua")

-- Patch StaticData.ItemAugment:Get to use a test-local data table so
-- individual tests can inject augments without touching the global DB.
local _itemAugData = {}
HDG.StaticData.ItemAugment.Get = function(_, itemID)
    return _itemAugData[itemID]
end
-- Helper to clear/set augments from test cases.
local function setItemAugments(data) _itemAugData = data or {} end

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

-- ============================================================================
-- 1. Observer cold (IsReady=false): bySource + byExp are empty
-- ============================================================================

obs._ready = false
obs.byItemID  = {}
-- Phase 4.4: BuildSnapshot walks obs.byDecorID (not state.account.collection.decorCatalog).
-- Seed one row so the loop has something; srcType falls back to 0 "Unknown"
-- because IsReady()=false so no catalogRow (hence no sourceTags) is found.
obs.byDecorID = {
    [1] = { decorID = 1, itemID = 5001, name = "Lamp", iconTexture = "icon1" },
}
state.account.collection.ownedDecorIDs = {}

local snap = HDG.HouseAggregator:BuildSnapshot(state)
-- allDB is empty -> srcType=0 "Unknown source", bySource[0] should exist
-- but byExp["Unknown"] should exist (fallback "Unknown").
-- The key check: byExp must have a key (may be "Unknown" from allDB fallback).
assertTrue(type(snap.bySource) == "table", "cold observer: bySource is a table")
assertTrue(type(snap.byExp)    == "table", "cold observer: byExp is a table")
-- With allDB empty, allEntry=nil -> srcType=0, expName="Unknown" (old path).
assertTrue(snap.bySource[0] ~= nil, "cold observer: srcType=0 fallback bucket exists")
assertEq(snap.bySource[0].total, 1, "cold observer: fallback bucket total=1")
assertTrue(snap.byExp["Unknown"] ~= nil, "cold observer: Unknown expansion bucket exists")

-- ============================================================================
-- 2. Observer ready: buckets derive from catalog rows
-- ============================================================================

obs._ready = true
obs.tagIDToGroup = { [100] = "Expansion", [200] = "Style" }
obs.byItemID = {
    [5001] = {
        itemID   = 5001,
        vendors  = { { npcName = "Trader Zeb" } },
        primarySourceCode = 5,                       -- baked by _bakeSourceTags -> srcType=5 (vendor)
        dataTagsByID = { [100] = "Midnight", [200] = "Gothic" },
    },
    [5002] = {
        itemID      = 5002,
        achievement = "Decor Connoisseur",
        primarySourceCode = 1,                       -- baked -> srcType=1 (achievement)
        dataTagsByID = { [100] = "The War Within" },
    },
    [5003] = {
        itemID   = 5003,
        quest    = "Hearth and Home",
        primarySourceCode = 2,                       -- baked -> srcType=2 (quest)
        dataTagsByID = {},                           -- no expansion tag -> "Unknown"
    },
}

-- Phase 4.4: BuildSnapshot walks obs.byDecorID; state.account.collection.decorCatalog deleted.
-- 2026-05-26: ownership now flows through R:IsOwned which reads row counters
-- (quantity / numPlaced / remainingRedeemable). Set quantity=1 on owned rows
-- to fake ownership through the canonical formula.
obs.byDecorID = {
    [10] = { decorID = 10, itemID = 5001, name = "Lamp",   iconTexture = "i1", quantity = 1 },  -- owned
    [11] = { decorID = 11, itemID = 5002, name = "Trophy", iconTexture = "i2" },                -- not owned
    [12] = { decorID = 12, itemID = 5003, name = "Chest",  iconTexture = "i3", quantity = 1 },  -- owned
}
state.account.collection.ownedDecorIDs = { [10] = true, [11] = false, [12] = true }
setItemAugments({})

snap = HDG.HouseAggregator:BuildSnapshot(state)

-- bySource buckets: 5001->vendor(5), 5002->ach(1), 5003->quest(2)
assertTrue(snap.bySource[5] ~= nil, "bySource: vendor bucket (srcType=5) present")
assertEq(snap.bySource[5].total, 1, "bySource: vendor bucket total=1")
assertEq(snap.bySource[5].collected, 1, "bySource: vendor bucket collected=1 (owned)")

assertTrue(snap.bySource[1] ~= nil, "bySource: achievement bucket (srcType=1) present")
assertEq(snap.bySource[1].total, 1, "bySource: ach bucket total=1")
assertEq(snap.bySource[1].collected, 0, "bySource: ach bucket collected=0 (not owned)")

assertTrue(snap.bySource[2] ~= nil, "bySource: quest bucket (srcType=2) present")
assertEq(snap.bySource[2].total, 1, "bySource: quest bucket total=1")
assertEq(snap.bySource[2].collected, 1, "bySource: quest bucket collected=1 (owned)")

-- byExp buckets: 5001->"Midnight", 5002->"The War Within", 5003->"Unknown"
assertTrue(snap.byExp["Midnight"] ~= nil, "byExp: Midnight bucket present")
assertEq(snap.byExp["Midnight"].total, 1, "byExp: Midnight total=1")
assertEq(snap.byExp["Midnight"].collected, 1, "byExp: Midnight collected=1")

assertTrue(snap.byExp["The War Within"] ~= nil, "byExp: The War Within bucket present")
assertEq(snap.byExp["The War Within"].total, 1, "byExp: The War Within total=1")
assertEq(snap.byExp["The War Within"].collected, 0, "byExp: TWW collected=0")

assertTrue(snap.byExp["Unknown"] ~= nil, "byExp: Unknown bucket present (no expansion tag)")
assertEq(snap.byExp["Unknown"].total, 1, "byExp: Unknown total=1")

-- ============================================================================
-- 3. Craft-classified row is bucketed via deriveSourcePrimary
-- ============================================================================

-- Source classification is baked at BuildRow (_bakeSourceTags reads ItemAugment
-- + catalog signals in SOURCE_KIND_PRIORITY order) into row.primarySourceCode;
-- deriveSourcePrimary just reads that code. So an ItemAugment CRAFTED override
-- surfaces as row.primarySourceCode=6 after the (re)bake. Simulate that here
-- (the ItemAugment->primarySourceCode bake itself is covered at the observer
-- layer). 5003 moves from quest(2) to craft(6).
obs.byItemID[5003].primarySourceCode = 6  -- CRAFTED (re-baked over the quest catalog signal)

snap = HDG.HouseAggregator:BuildSnapshot(state)

assertTrue(snap.bySource[6] ~= nil, "craft-baked row: craft bucket (6) present")
assertEq(snap.bySource[6].total, 1, "ItemAugment override: craft bucket total=1")
-- quest bucket should be gone now (5003 moved to craft).
assertEq(snap.bySource[2] == nil or snap.bySource[2].total == 0, true,
    "ItemAugment override: quest bucket empty after craft override")

-- ============================================================================
-- 4. Total counts add up (bySource totals == totalAll)
-- ============================================================================

setItemAugments({})
snap = HDG.HouseAggregator:BuildSnapshot(state)

local srcSum = 0
for _, b in pairs(snap.bySource) do srcSum = srcSum + b.total end
assertEq(srcSum, snap.totalAll, "sanity: sum of bySource totals == totalAll")

local expSum = 0
for _, b in pairs(snap.byExp) do expSum = expSum + b.total end
assertEq(expSum, snap.totalAll, "sanity: sum of byExp totals == totalAll")

local srcCollectedSum = 0
for _, b in pairs(snap.bySource) do srcCollectedSum = srcCollectedSum + b.collected end
assertEq(srcCollectedSum, snap.collectedAll,
    "sanity: sum of bySource collected == collectedAll")

-- ============================================================================
-- 5. CATALOG_LOAD_COMPLETED is in SNAPSHOT_TRIGGERING_ACTIONS
-- ============================================================================

-- The aggregator should schedule a snapshot rebuild when the catalog sweeps.
-- Verify by checking OnStoreNotify accepts the action type without error
-- (actual C_Timer.After is no-op in test mock).
local ok, err = pcall(function()
    HDG.HouseAggregator:OnStoreNotify("HDGR_CATALOG_LOAD_COMPLETED")
end)
assertTrue(ok, "OnStoreNotify(CATALOG_LOAD_COMPLETED): no error -- " .. tostring(err))

local ok2, err2 = pcall(function()
    HDG.HouseAggregator:OnStoreNotify("HDGR_CATALOG_REFRESH_COMPLETED")
end)
assertTrue(ok2, "OnStoreNotify(CATALOG_REFRESH_COMPLETED): no error -- " .. tostring(err2))

-- ============================================================================
-- 6. house.sourceDonutData / house.expansionDonutData reads include
--    session.resolvers.catalog.tick (selector registration correctness).
-- ============================================================================

local srcSel = HDG.Selectors._registry and HDG.Selectors._registry["house.sourceDonutData"]
local expSel = HDG.Selectors._registry and HDG.Selectors._registry["house.expansionDonutData"]

if srcSel then
    local hasSweep = false
    for _, r in ipairs(srcSel.reads or {}) do
        if r == "session.resolvers.catalog.tick" then hasSweep = true end
    end
    assertTrue(hasSweep, "house.sourceDonutData reads includes session.resolvers.catalog.tick")
end

if expSel then
    local hasSweep = false
    for _, r in ipairs(expSel.reads or {}) do
        if r == "session.resolvers.catalog.tick" then hasSweep = true end
    end
    assertTrue(hasSweep, "house.expansionDonutData reads includes session.resolvers.catalog.tick")
end

print("test_house_aggregator_catalog.lua: ALL PASS")
