-- test_shopping_catalog.lua
-- Phase 3f: shopping.activeListEntries reads HousingCatalogObserver +
-- VendorAugment instead of VendorDB / decor.allItemsByID.
--
-- Covers:
--   1. shopping.activeListEntries emits empty when no active list
--   2. shopping.activeListEntries emits correct rows when observer cold (no flags set)
--   3. shopping.activeListEntries emits expected entries (101/102 vendor,
--      103 wishlist); source chips render via observer.sourceTags downstream
--   (4. removed 2026-05-29 -- augment->source classification is observer-level)
--   5. Vendor coords from VendorAugment:Get(npcID) land on vendor block
--   6. Wishlist entries (npcID=nil) carry vendor=nil
--   7. shopping.activeListEntries reads declaration has session.resolvers.catalog.tick
--   8. shopping.activeListEntries reads declaration does NOT have session.resolvers.staticData.tick
--   9. shopping.entriesByZone reads declaration has session.resolvers.catalog.tick

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

-- ============================================================================
-- Stubs
-- ============================================================================

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

-- Bind type stub (mirrors production: decor with a recipe = crafted = BoE/tradeable).
function obs:GetBindTypeForItem(itemID)
    local r = self.byItemID[itemID]
    return (r and r.recipe ~= nil) and "BoE" or "BoP"
end

-- Source-flag stub removed 2026-05-29: populateSourceFlags was retired in the
-- 2026-05-28 sourceTags unification (gateFlagsForItem -> observer.sourceTags
-- via UI.GateChips(itemID)). Shopping entries no longer carry per-source flags;
-- source classification is covered by test_observer_row_schema.

-- Pre-populate data globals so StaticData:Get() doesn't error.
_G.HDGR_ItemAugment   = _G.HDGR_ItemAugment   or {}
_G.HDGR_VendorAugment = _G.HDGR_VendorAugment or {}

-- Stub StaticData sub-domains.
HDG.StaticData = HDG.StaticData or {}
HDG.StaticData.ItemAugment = HDG.StaticData.ItemAugment or {}

local _itemAugData = {}
function HDG.StaticData.ItemAugment:Get(itemID)
    return _itemAugData[itemID]
end

HDG.StaticData.VendorAugment = HDG.StaticData.VendorAugment or {}
local _vendorAugData = {}
function HDG.StaticData.VendorAugment:Get(npcID)
    return _vendorAugData[npcID]
end

-- Decor selectors needed by shopping chain.
dofile("../UI/HDGR_Selectors_Decor.lua")
dofile("../Core/HDGR_Codec.lua")          -- shared b64/url/ascii primitives (ShoppingCodec destructures these)
dofile("../Modules/HDGR_ShoppingCodec.lua")
dofile("../UI/HDGR_Selectors_Shopping.lua")

-- ============================================================================
-- Helpers
-- ============================================================================

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
local function assertFalse(v, label)
    if v then error("expected false: " .. (label or ""), 2) end
end

local A = HDG.Constants.ACTIONS
local function dispatch(t, p) HDG.Store:Dispatch({ type = t, payload = p }) end

local function resetObs()
    obs._ready   = false
    obs.byItemID = {}
    _itemAugData   = {}
    _vendorAugData = {}
end

local function getState() return HDG.Store:GetState() end

HDG.Store:LoadFromSavedVariables()

-- Create a list and populate it for the tests below.
dispatch(A.SHOPPING_LIST_CREATE, { name = "Catalog Test" })   -- L1
local sID = "L1"
dispatch(A.SHOPPING_LIST_ACTIVATE, { id = sID })

-- itemID 101 @ npcID 501 (vendor item)
-- itemID 102 @ npcID 501 (same vendor, quest-gated per catalog)
-- itemID 103            (wishlist, no npcID)
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 101, npcID = 501, qty = 2 })
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 102, npcID = 501, qty = 1 })
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 103,              qty = 3 })

-- ============================================================================
-- 1. Empty list -> empty output
-- ============================================================================

dispatch(A.SHOPPING_LIST_CREATE, { name = "Empty List" })  -- L2
dispatch(A.SHOPPING_LIST_ACTIVATE, { id = "L2" })
local entries = HDG.Selectors:Call("shopping.activeListEntries", getState(), {})
assertEq(#entries, 0, "1. empty list -> zero entries")
-- reset to test list
dispatch(A.SHOPPING_LIST_ACTIVATE, { id = sID })

-- ============================================================================
-- 2. Observer cold -> entries emitted but vendor=nil, flags sparse (hasDrop fallback)
-- ============================================================================

resetObs()  -- _ready = false
entries = HDG.Selectors:Call("shopping.activeListEntries", getState(), {})
assertEq(#entries, 3, "2. cold observer: 3 entries emitted (list shape preserved)")
-- When the observer is cold the entries still emit (list shape preserved);
-- source chips are simply empty downstream (GateChips finds no row).
-- Vendor should be nil (npcID 501 not in augment data yet).
assertEq(entries[1].npcID, 501, "2. entry[1] npcID preserved")
assertEq(entries[1].vendor, nil, "2. cold: vendor nil when augment has no entry for npcID")

-- ============================================================================
-- 3. Catalog signals -> gate flags from vendor/quest/achievement rows
-- ============================================================================

resetObs()
obs._ready = true
-- 101: vendor-sold item (vendors list non-empty)
obs.byItemID[101] = {
    itemID   = 101,
    vendors  = { { name = "Test Vendor", zone = "Testzone", cost = "100g",
                   faction = "", standing = "" } },
    quest    = nil,
    achievement = nil,
    category = nil,
    factionGate = nil,
}
-- 102: quest-gated item
obs.byItemID[102] = {
    itemID   = 102,
    vendors  = {},
    quest    = "A Great Quest",
    achievement = nil,
    category = nil,
    factionGate = nil,
}

entries = HDG.Selectors:Call("shopping.activeListEntries", getState(), {})
assertEq(#entries, 3, "3. three entries emitted")

local e101, e102, e103
for _, e in ipairs(entries) do
    if e.itemID == 101 then e101 = e end
    if e.itemID == 102 then e102 = e end
    if e.itemID == 103 then e103 = e end
end
assertTrue(e101 ~= nil, "3. entry for 101 present")
assertTrue(e102 ~= nil, "3. entry for 102 present")
assertTrue(e103 ~= nil, "3. wishlist entry for 103 present")
assertEq(e103.npcID, nil, "3. wishlist entry has nil npcID")
-- (Source-flag assertions removed 2026-05-29: shopping entries carry no
--  per-source flags; chips render via observer.sourceTags. Source-tag
--  classification is covered by test_observer_row_schema.)

-- (Section 4 "ItemAugment sourceType override flows through" removed 2026-05-29:
--  it asserted augment->hasX on shopping entries, a retired path. Augment
--  sourceType now feeds observer.sourceTags via _bakeSourceTags' fromAug branch
--  -- covered by test_item_augment + test_observer_row_schema.)

-- ============================================================================
-- 5. Vendor coords from VendorAugment:Get(npcID)
-- ============================================================================

resetObs()
obs._ready = true
obs.byItemID[101] = {
    itemID  = 101, vendors = {}, quest = nil, achievement = nil,
    category = nil, factionGate = nil,
}
_vendorAugData[501] = {
    name    = "Vendor McVendorface",
    zone    = "Durotar",
    mapID   = 1,
    x       = 42.0,
    y       = 55.5,
    faction = "H",
}

entries = HDG.Selectors:Call("shopping.activeListEntries", getState(), {})
e101 = nil
for _, e in ipairs(entries) do
    if e.itemID == 101 then e101 = e end
end
assertTrue(e101 ~= nil, "5. entry for 101 present")
assertTrue(e101.vendor ~= nil, "5. vendor block populated when VendorAugment has npcID")
assertEq(e101.vendor.name,   "Vendor McVendorface", "5. vendor.name from VendorAugment")
assertEq(e101.vendor.zone,   "Durotar",             "5. vendor.zone from VendorAugment")
assertEq(e101.vendor.mapID,  1,                     "5. vendor.mapID from VendorAugment")
assertEq(e101.vendor.x,      42.0,                  "5. vendor.x from VendorAugment")
assertEq(e101.vendor.y,      55.5,                  "5. vendor.y from VendorAugment")
assertEq(e101.vendor.faction,"H",                   "5. vendor.faction from VendorAugment")

-- ============================================================================
-- 6. Wishlist entry: vendor=nil regardless of augment data; flags still computed
-- ============================================================================

resetObs()
obs._ready = true
obs.byItemID[103] = {
    itemID   = 103,
    vendors  = {},
    quest    = "Wishlist Quest",
    achievement = nil,
    category = nil,
    factionGate = nil,
}
-- Even if npcID 501 has augment data, wishlist entry (npcID=nil) should not resolve it.
_vendorAugData[501] = { name = "SomeVendor", zone = "Elwynn", mapID = 40, x = 1, y = 2, faction = "A" }

entries = HDG.Selectors:Call("shopping.activeListEntries", getState(), {})
e103 = nil
for _, e in ipairs(entries) do
    if e.itemID == 103 then e103 = e end
end
assertTrue(e103 ~= nil, "6. wishlist entry for 103 present")
assertEq(e103.npcID,  nil, "6. wishlist entry npcID=nil")
assertEq(e103.vendor, nil, "6. wishlist entry vendor=nil (no npcID -> no augment lookup)")

-- ============================================================================
-- 7. Reads declaration includes session.resolvers.catalog.tick
-- ============================================================================

local reg = HDG.Selectors:GetRegistry()
local entriesSel = reg["shopping.activeListEntries"]
assertTrue(entriesSel ~= nil, "7. shopping.activeListEntries selector registered")
local hasSweepGen = false
for _, r in ipairs(entriesSel.reads or {}) do
    if r == "session.resolvers.catalog.tick" then hasSweepGen = true end
end
assertTrue(hasSweepGen, "7. reads includes session.resolvers.catalog.tick")

-- ============================================================================
-- 8. Reads declaration includes the staticData marker (ADR-003c). The fn
-- calls HDG.StaticData.VendorAugment:Get, so the resolver-facade rule
-- (semantic_sweep 4c, contracts from Resolver:Register) REQUIRES the marker
-- read. This assertion was inverted pre-registry; the registry made the
-- convention mandatory and mechanical.
-- ============================================================================

local hasStaticDataTick = false
for _, r in ipairs(entriesSel.reads or {}) do
    if r == "session.resolvers.staticData.tick" then hasStaticDataTick = true end
end
assertTrue(hasStaticDataTick, "8. reads includes session.resolvers.staticData.tick (ADR-003c marker)")

-- ============================================================================
-- 9. shopping.entriesByZone reads includes session.resolvers.catalog.tick
-- ============================================================================

local byZoneSel = reg["shopping.entriesByZone"]
assertTrue(byZoneSel ~= nil, "9. shopping.entriesByZone selector registered")
local byZoneHasSweep = false
for _, r in ipairs(byZoneSel.reads or {}) do
    if r == "session.resolvers.catalog.tick" then byZoneHasSweep = true end
end
assertTrue(byZoneHasSweep, "9. shopping.entriesByZone reads includes session.resolvers.catalog.tick")

-- ============================================================================
-- 10. shopping.summary buckets into THREE lanes: vendor / AH / wish. AH-lane
--     items (no npcID + tradeable BoE/crafted) must NOT be counted as wish.
-- ============================================================================

resetObs()
obs._ready = true
-- 201 = vendor (npcID); 202 = AH (no npcID, has .recipe -> BoE/tradeable);
-- 203 = wishlist (no npcID, no .recipe -> BoP).
obs.byItemID[201] = { itemID = 201, vendors = {} }
obs.byItemID[202] = { itemID = 202, vendors = {}, recipe = { profession = "Engineering" } }
obs.byItemID[203] = { itemID = 203, vendors = {} }

dispatch(A.SHOPPING_LIST_CREATE, { name = "Lanes" })   -- L3
local laneID = "L3"
dispatch(A.SHOPPING_LIST_ACTIVATE, { id = laneID })
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 201, npcID = 700, qty = 2 })  -- vendor
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 202,              qty = 4 })  -- AH (crafted)
dispatch(A.SHOPPING_ITEM_ADD, { itemID = 203,              qty = 1 })  -- wish (drop)

local summary = HDG.Selectors:Call("shopping.summary", getState(), {})
assertEq(summary.itemCount, 7, "10. itemCount sums all qty (2+4+1)")
assertEq(summary.vendorCount, 1, "10. vendorCount = 1 distinct npcID")
assertEq(summary.ahCount, 4, "10. ahCount = the crafted/BoE item qty, NOT folded into wish")
assertEq(summary.wishCount, 1, "10. wishCount = only the true drop (203), excludes AH")

print("test_shopping_catalog.lua: ALL PASS")
