-- test_lumber_selectors.lua
-- ============================================================================
-- Phase 6 lumber selector smoke: end-to-end pulls from each of the 6 lumber
-- selectors with synthetic state. Covers:
--   - atomic config reads (windowVisible, windowPosition, radarCollapsed,
--     radarScale, activeFarmingID)
--   - counterRows: per-lumber-type rows with held / session stats / active flag
--   - blipsForRadar: map + age filtering, newest-first sort
--   - sessionStats: nil when idle, populated when active

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_Memo.lua")
dofile("../Core/HDGR_TableUtils.lua")
dofile("../Core/HDGR_Modules.lua")
dofile("../Core/HDGR_Middleware.lua")
dofile("../Core/HDGR_StoreFurnishings.lua")
dofile("../Core/HDGR_Store.lua")
dofile("../UI/HDGR_Selectors.lua")
dofile("../UI/HDGR_Selectors_Lumber.lua")
dofile("../Modules/HDGR_BagObserver.lua")

-- StaticData stub. The lumber.queueNeed selector (called transitively from
-- lumber.counterRows) reads HDG.StaticData.Recipes:Get / VisitReagents to compute
-- per-lumber craft-queue demand. An empty stub yields an empty need map, which is
-- the right behaviour for an idle queue (the case this test exercises).
HDG.StaticData = HDG.StaticData or {}
HDG.StaticData.Recipes = HDG.StaticData.Recipes or {
    Get = function() return nil end,
    GetAll = function() return {} end,
    VisitReagents = function() end,
}

-- warehouse.lumberRequired (the shared uncollected-decor need) is registered
-- in Selectors_Recipes, which this harness doesn't load -- stub the same
-- contract (sparse map; empty = nothing uncollected resolves).
HDG.Selectors:Register("warehouse.lumberRequired", {
    reads = { "account.collection.ownedDecorIDs", "session.resolvers.catalog.tick", "session.resolvers.staticData.tick" },
    fn = function() return {} end,
})

local Selectors = HDG.Selectors
local IRONWOOD  = 245586

local function assertTrue(v, label) if not v then error(label, 2) end end
local function assertEq(a, b, label)
    if a ~= b then error(label .. ": expected " .. tostring(b) .. ", got " .. tostring(a), 2) end
end
local function near(a, b, eps, label)
    if math.abs(a - b) > (eps or 0.01) then
        error(label .. ": expected ~" .. tostring(b) .. ", got " .. tostring(a), 2)
    end
end

HDG_DB = {}
HDG.Store:LoadFromSavedVariables()
local state = HDG.Store:GetState()
state.session.identity.charKey = "Vamoose"

-- Stub BagObserver counts. GetTotal (bag+bank+warband) drives the stock column;
-- GetBagCount (bag-only) drives the session "+N gathered" delta. They're stubbed
-- from SEPARATE tables so the session math can't silently regress to counting
-- bank/warband stock (the +68-with-67-in-bank bug).
local fakeBag     = {}   -- GetTotal: total owned (stock view)
local fakeBagOnly = {}   -- GetBagCount: bags only (session view)
HDG.BagObserver.GetTotal    = function(self, itemID) return fakeBag[itemID]     or 0 end
HDG.BagObserver.GetBagCount = function(self, itemID) return fakeBagOnly[itemID] or 0 end

-- Stub GetTime so session-duration math is deterministic.
local mockTime = 5000
_G.GetTime = function() return mockTime end

-- ===== Atomic config reads =================================================
assertEq(Selectors:Call("lumber.windowVisible",  state, {}), false, "windowVisible default false")
assertEq(Selectors:Call("lumber.radarCollapsed", state, {}), false, "radarCollapsed default false")
assertEq(Selectors:Call("lumber.radarScale",     state, {}), 1.0,   "radarScale default 1.0")

state.account.lumber.config.windowVisible  = true
state.account.lumber.config.radarCollapsed = true
state.account.lumber.config.radarScale     = 1.5
assertEq(Selectors:Call("lumber.windowVisible",  state, {}), true,  "windowVisible reflects state")
assertEq(Selectors:Call("lumber.radarCollapsed", state, {}), true,  "radarCollapsed reflects state")
assertEq(Selectors:Call("lumber.radarScale",     state, {}), 1.5,   "radarScale reflects state")

local pos = Selectors:Call("lumber.windowPosition", state, {})
assertEq(pos.x, 100,  "windowPosition.x default")
assertEq(pos.y, -150, "windowPosition.y default")

-- ===== activeFarmingID =====================================================
assertEq(Selectors:Call("lumber.activeFarmingID", state, {}), nil, "idle activeFarmingID nil")
state.session.lumber.activeFarmingID = IRONWOOD
assertEq(Selectors:Call("lumber.activeFarmingID", state, {}), IRONWOOD,
         "activeFarmingID returns IRONWOOD when active")

-- ===== counterRows =========================================================
-- Seed an active Ironwood session started at t=4000 with startCount=10 (bag-only).
-- Stock = 90 owned (22 in bags + 68 in bank/warband); bags hold 22. At mockTime=5000
-- (duration=1000s = 16.67 min) the SESSION gathered = 22(bag) - 10 = 12 (NOT 90 - 10);
-- perHour = (12 / 1000) * 3600 = 43.2 / hr. Stock column shows the full 90.
fakeBag[IRONWOOD]     = 90   -- bag+bank+warband (stock)
fakeBagOnly[IRONWOOD] = 22   -- bags only (session delta)
state.account.lumber.sessions["Vamoose"] = {
    [IRONWOOD] = { startedAt = 4000, startCount = 10, lastHarvestAt = 4900, finalizedAt = nil },
}

local rows = Selectors:Call("lumber.counterRows", state, {})
assertEq(#rows, #HDG.Constants.LUMBER_DATA, "one row per LUMBER_DATA entry")

-- Find the IRONWOOD row + verify LIST fields (stock + active flag). Session
-- rate / this-session total moved OUT of counterRows into lumber.sessionStats
-- (verified below) -- list rows now carry only stock + queue-need.
local ironRow
for _, r in ipairs(rows) do if r.lumberID == IRONWOOD then ironRow = r; break end end
assertTrue(ironRow, "IRONWOOD row exists")
assertEq(ironRow.held, 90, "IRONWOOD held = total owned (bag+bank+warband)")
assertEq(ironRow.isActive, true, "IRONWOOD marked active")
assertEq(ironRow.sessionTotal, nil, "counterRows no longer carries sessionTotal")
assertEq(ironRow.perHour, nil, "counterRows no longer carries perHour")

-- Non-active row (Olemba): held = 0, not active.
local olembaRow
for _, r in ipairs(rows) do if r.lumberID == 242691 then olembaRow = r; break end end
assertEq(olembaRow.held, 0, "Olemba held = 0 (no bag)")
assertEq(olembaRow.isActive, false, "Olemba not active")

-- ===== blipsForRadar =======================================================
-- Selector returns ALL blips (no zone/age filter -- both bug-prone state-
-- side dependencies, dropped 2026-05-24). Radar paint filters per-frame
-- via live C_Map.GetBestMapForUnit + distance check. Sort is newest-first
-- so z-order layers newer blips on top of older ones.
state.session.lumber.blips = {
    { x = 0.5, y = 0.5, mapID = 2552, ts = 4990, lumberID = IRONWOOD },  -- 10s old
    { x = 0.6, y = 0.4, mapID = 2552, ts = 4500, lumberID = IRONWOOD },  -- 500s old
    { x = 0.7, y = 0.3, mapID = 999,  ts = 4990, lumberID = IRONWOOD },  -- different map; selector keeps, paint filters
    { x = 0.8, y = 0.2, mapID = 2552, ts = 1000, lumberID = IRONWOOD },  -- 4000s old; selector keeps, paint may render grey
}

local blips = Selectors:Call("lumber.blipsForRadar", state, {})
assertEq(#blips, 4, "all blips returned (paint owns zone/age filtering)")
-- Newest-first sort: ages 10, 10, 500, 4000
near(blips[1].ageSec, 10,   1, "blips[1] = newest (ts=4990)")
near(blips[2].ageSec, 10,   1, "blips[2] = next-newest (ts=4990)")
near(blips[3].ageSec, 500,  1, "blips[3] = 500s old")
near(blips[4].ageSec, 4000, 1, "blips[4] = oldest (4000s)")

-- ===== sessionStats ========================================================
local stats = Selectors:Call("lumber.sessionStats", state, {})
assertTrue(stats, "sessionStats present when active session exists")
assertEq(stats.lumberID, IRONWOOD, "stats.lumberID matches active")
assertEq(stats.lumberName, "Ironwood", "stats.lumberName resolved from LUMBER_DATA")
assertEq(stats.totalGathered, 12, "stats.totalGathered matches")
near(stats.duration, 1000, 0.1, "stats.duration = now - startedAt")
near(stats.perHour, 43.2, 0.5, "stats.perHour matches")

-- idle session = nil stats. lumber.sessionStats is memoized + dispatch-invalidated;
-- this test mutates activeFarmingID directly (no dispatch), so clear the memo the
-- way a real session-clear dispatch would before re-reading.
state.session.lumber.activeFarmingID = nil
HDG.Selectors:InvalidateMemos("*")
assertEq(Selectors:Call("lumber.sessionStats", state, {}), nil,
         "sessionStats nil when no active session")

-- ===== Multi-harvest repro (2026-05-24 bug investigation) =================
-- User reports: dispatching multiple LUMBER_HARVESTED events at different
-- positions yields only the FIRST blip rendering on the in-game radar.
-- Confirm here that the data-side path (reducer append + selector return)
-- correctly accumulates -- isolating the bug to the radar paint code.
local A = HDG.Constants.ACTIONS
-- Fresh state (previous tests mutated blips array directly; reset)
state.session.lumber.blips = {}
state.session.lumber.tick = 0
state.session.lumber.activeFarmingID = IRONWOOD
mockTime = 6000
HDG.Store:Dispatch({ type = A.LUMBER_HARVESTED,
    payload = { lumberID = IRONWOOD, qty = 1, x = 0.10, y = 0.20, mapID = 2552, timestamp = 6000 } })
HDG.Store:Dispatch({ type = A.LUMBER_HARVESTED,
    payload = { lumberID = IRONWOOD, qty = 1, x = 0.30, y = 0.40, mapID = 2552, timestamp = 6001 } })
HDG.Store:Dispatch({ type = A.LUMBER_HARVESTED,
    payload = { lumberID = IRONWOOD, qty = 1, x = 0.50, y = 0.60, mapID = 2552, timestamp = 6002 } })
assertEq(#state.session.lumber.blips, 3, "reducer appends 3 distinct blips")
local multi = Selectors:Call("lumber.blipsForRadar", state, {})
assertEq(#multi, 3, "selector returns all 3 blips (no zone/age filter)")
assertEq(multi[1].x, 0.50, "newest blip first")
assertEq(multi[2].x, 0.30, "middle blip second")
assertEq(multi[3].x, 0.10, "oldest blip third")
-- Nil-coord harvest: tick bumps, blip count stays 3
local tickBefore = state.session.lumber.tick
HDG.Store:Dispatch({ type = A.LUMBER_HARVESTED,
    payload = { lumberID = IRONWOOD, qty = 1, x = nil, y = nil, mapID = nil, timestamp = 6003 } })
assertEq(#state.session.lumber.blips, 3, "nil-coord harvest does not append")
assertEq(state.session.lumber.tick, tickBefore + 1, "nil-coord harvest still bumps tick")

print("test_lumber_selectors.lua passed")
