-- test_middleware_chain.lua
-- Priority 4: Middleware chain ordering + CombatMiddleware queuing + PersistenceMiddleware.
--
-- Invariants tested:
--   a. Apply right-folds the list: middlewares[1] is outermost (sees action first).
--   b. ErrorBoundaryMiddleware catches downstream errors without propagating.
--   c. CombatMiddleware: combatUnsafe=true action during inLockdown -> queued,
--      NOT state-mutated. On COMBAT_EXIT, the queued action replays through
--      the public chain (full middleware traversal).
--   d. PersistenceMiddleware: QueueSave called iff persists=true in meta.
--      QueueSave NOT called for persists=false actions.
--
-- The tests build synthetic middleware and a minimal Store-like object so
-- the assertions are about the chain contract, not about HDG production code.
-- CombatMiddleware tests use the real Store + real CombatMiddleware because
-- the queuing logic reads from state and dispatches through the chain.

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")
-- Selectors: Store unconditionally calls HDG.Selectors:InvalidateMemos on every
-- _RawDispatch (no-guards policy). Test must load Selectors so the registry exists.
dofile("../UI/HDGR_Selectors.lua")
-- Config layer: reducer routes CONFIG_SET via HDG.Config:_GetSourceForScope.
dofile("../Core/HDGR_ConfigSchema.lua")
dofile("../Core/HDGR_Config.lua")
dofile("../Core/HDGR_Middleware.lua")
-- BlizzardEvents is needed by CombatMiddleware.MakeCombatMiddleware to wire
-- PLAYER_REGEN_ENABLED listeners (the replay trigger). Without it, Apply
-- skips listener registration and COMBAT_EXIT replay can't be tested.
dofile("../Core/HDGR_BlizzardEvents.lua")

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

-- ===== a. Chain ordering =====================================================
-- Build a 3-middleware synthetic chain. Each records its position in a log.
-- After Apply, dispatch an action and verify the log order matches "1 sees first."

local orderLog = {}

local function makeMW(id)
    return function(nextDispatch, _store)
        return function(action)
            orderLog[#orderLog + 1] = id .. ":pre"
            local result = nextDispatch(action)
            orderLog[#orderLog + 1] = id .. ":post"
            return result
        end
    end
end

-- Minimal store for ordering test.
local syntheticStore = {
    _rawCalls = 0,
    Dispatch  = nil,   -- will be replaced by Apply
}
function syntheticStore:_RawDispatch(_action)
    self._rawCalls = self._rawCalls + 1
end
function syntheticStore:GetState() return {} end

HDG.Middleware.Apply(syntheticStore, {
    makeMW("A"),   -- outermost (index 1)
    makeMW("B"),
    makeMW("C"),   -- innermost (index 3)
})

syntheticStore:Dispatch({ type = "probe" })

-- Expected order: A:pre -> B:pre -> C:pre -> C:post -> B:post -> A:post
local expected = { "A:pre", "B:pre", "C:pre", "C:post", "B:post", "A:post" }
assertEq(#orderLog, #expected, "chain ordering: log length")
for i, v in ipairs(expected) do
    assertEq(orderLog[i], v, "chain ordering: step " .. i)
end
assertEq(syntheticStore._rawCalls, 1, "chain ordering: _RawDispatch called exactly once")

-- ===== b. ErrorBoundaryMiddleware swallows downstream errors =================
-- A downstream middleware that throws. With ErrorBoundary as the outermost,
-- the error is caught and dispatch returns nil (no propagation).

local errorStore = {
    Dispatch = nil,
}
function errorStore:_RawDispatch(_action)
    error("boom from reducer")
end
function errorStore:GetState() return { account = { config = { debug = false } } } end

HDG.Middleware.Apply(errorStore, {
    HDG.Middleware.ErrorBoundaryMiddleware,  -- outermost
})

local ok, result = pcall(function()
    return errorStore:Dispatch({ type = "any" })
end)
-- The pcall here wraps the entire Dispatch call. Because ErrorBoundary
-- catches the error internally, Dispatch itself should NOT raise -- so
-- ok == true and result == nil.
assertTrue(ok, "ErrorBoundaryMiddleware: Dispatch must not propagate errors to caller")
assertEq(result, nil, "ErrorBoundaryMiddleware: result must be nil when downstream errors")

-- ===== c. CombatMiddleware: queuing + replay =================================
-- Use the real Store and MakeCombatMiddleware so the contract is tested
-- against the actual implementation.

HDG_DB = {}
HDG.Store:LoadFromSavedVariables()
HDG.Config:Initialize()
local state = HDG.Store:GetState()

-- Build action meta with one combatUnsafe action.
local fakeActionType = "HDGR_MAIN_WINDOW_TOGGLE"   -- real action; combatUnsafe=false by default
-- We test with a real action that IS combatUnsafe (none in HDG are currently),
-- so we build a synthetic action meta that marks COMBAT_ENTER as combatUnsafe=true.
-- That lets us test the queue without needing to invent a new action string.
-- We use a standalone fake store so we don't corrupt the global HDG.Store state.

local A = HDG.Constants.ACTIONS
local combatMeta = {
    -- Mark CONFIG_SET as combatUnsafe for this test only.
    [A.CONFIG_SET] = { persists = true, combatUnsafe = true, noop = false,
                       invalidates = { "account.config" } },
    [A.COMBAT_ENTER]       = { persists = false, combatUnsafe = false, noop = false,
                               invalidates = { "session.combat.inLockdown" } },
    [A.COMBAT_EXIT]        = { persists = false, combatUnsafe = false, noop = false,
                               invalidates = { "session.combat.inLockdown", "session.combat.queued" } },
    [A.COMBAT_QUEUE_ACTION]= { persists = false, combatUnsafe = false, noop = false,
                               invalidates = { "session.combat.queued" } },
}

-- Fresh store for isolation. Re-fetch state after reload because
-- LoadFromSavedVariables replaces the state table; the `state` variable
-- from the top of the test now points to stale data.
HDG_DB = {}
HDG.Store:LoadFromSavedVariables()
HDG.Config:Initialize()
local state = HDG.Store:GetState()   -- shadow the top-level `state`
HDG.Store._actionMeta = combatMeta

-- Apply the combat middleware (no BlizzardEvents wiring in tests -- skip that path).
-- We apply only CombatMiddleware + PersistenceMiddleware for this sub-test.
local saveCalls = 0
local storeRef = HDG.Store
local origQueueSave = storeRef.QueueSave
storeRef.QueueSave = function(self) saveCalls = saveCalls + 1 end

HDG.Middleware.Apply(HDG.Store, {
    HDG.Middleware.MakeCombatMiddleware(combatMeta),
    HDG.Middleware.MakePersistenceMiddleware(combatMeta),
})

-- Enter combat: inLockdown should be true now.
HDG.Store:Dispatch({ type = A.COMBAT_ENTER })
assertEq(state.session.combat.inLockdown, true, "combat: COMBAT_ENTER sets inLockdown")

-- Dispatch a combatUnsafe action while in lockdown.
-- CONFIG_SET is marked combatUnsafe in our meta, so it should be queued.
local configBefore = state.account.config.debug
HDG.Store:Dispatch({ type = A.CONFIG_SET, payload = { key = "debug", value = true } })

-- State must NOT have changed (the action was queued, not applied).
assertEq(state.account.config.debug, configBefore,
    "combat: combatUnsafe action during lockdown must NOT mutate state")

-- The queued table must have the action.
local queued = state.session.combat.queued
assertTrue(type(queued) == "table" and #queued == 1,
    "combat: combatUnsafe action must appear in session.combat.queued")
assertEq(queued[1].type, A.CONFIG_SET, "combat: queued action has correct type")

-- Exit combat via PLAYER_REGEN_ENABLED simulation. This is the production
-- trigger path: CombatMiddleware wires a PLAYER_REGEN_ENABLED listener
-- that (1) snapshots the queue, (2) dispatches COMBAT_EXIT via the public
-- chain (reducer clears queue + sets inLockdown=false), (3) replays the
-- snapshot through the chain. We use BlizzardEvents:_simulate so the
-- full middleware-driven replay loop is exercised, not just a bare
-- COMBAT_EXIT dispatch.
HDG.BlizzardEvents:_simulate("PLAYER_REGEN_ENABLED")
-- Drain any deferred C_Timer.After callbacks (mock is synchronous by default).
MockTimerDrain()
assertEq(state.session.combat.inLockdown, false, "combat: COMBAT_EXIT clears inLockdown")
-- After replay, CONFIG_SET should have applied.
assertEq(state.account.config.debug, true,
    "combat: queued action must replay and mutate state after COMBAT_EXIT")
-- Queue must be empty after drain.
assertEq(#state.session.combat.queued, 0, "combat: queue empty after COMBAT_EXIT drain")

-- ===== d. PersistenceMiddleware: QueueSave gating ===========================
-- PersistenceMiddleware calls QueueSave iff persists=true in meta.
-- We already applied the chain above. COUNT saves from the point we reset saveCalls.

saveCalls = 0

-- CONFIG_SET -> persists=true in combatMeta -> should trigger QueueSave.
HDG.Store:Dispatch({ type = A.CONFIG_SET, payload = { key = "debug", value = false } })
assertTrue(saveCalls > 0, "persistence: persists=true action must call QueueSave")

saveCalls = 0

-- COMBAT_ENTER -> persists=false -> must NOT call QueueSave.
HDG.Store:Dispatch({ type = A.COMBAT_ENTER })
assertEq(saveCalls, 0, "persistence: persists=false action must NOT call QueueSave")

-- Restore QueueSave.
HDG.Store.QueueSave = origQueueSave

print("test_middleware_chain.lua passed")
