-- VDS.Middleware
-- ============================================================================
-- Action-stream middleware (Rodux two-curry shape). Cross-cutting concerns:
-- combat queueing, logging, persistence, error boundaries.
-- Spec: UI_WIDGET_TAXONOMY.md s4. Ref: LUA_SAMPLES.md s1a.
--
-- Shape: function(nextDispatch, store) return function(action) ... end end
-- Registration order = execution order. Right-fold builds the closure chain;
-- result replaces store.Dispatch.

VDS = VDS or {}
VDS.Middleware = VDS.Middleware or {}

local M = VDS.Middleware

-- ===== Chain construction =================================================

-- Right-fold middlewares onto store; middlewares[1] becomes outermost. Replaces store.Dispatch.
function M.Apply(store, middlewares)
    if type(middlewares) ~= "table" or #middlewares == 0 then return end

    local baseDispatch = function(action)
        return store:_RawDispatch(action)
    end

    -- Right-fold: iterate backwards so middlewares[1] wraps everything inside.
    local dispatch = baseDispatch
    for i = #middlewares, 1, -1 do
        local mw = middlewares[i]
        if type(mw) ~= "function" then
            error(("VDS.Middleware.Apply: middleware at index %d is not a function"):format(i), 2)
        end
        dispatch = mw(dispatch, store)
        if type(dispatch) ~= "function" then
            error(("VDS.Middleware.Apply: middleware at index %d did not return a dispatch function"):format(i), 2)
        end
    end

    -- Replace store.Dispatch; _RawDispatch preserved as the chain's base.
    store.Dispatch = function(self, action)
        return dispatch(action)
    end
end

-- ===== Standard middlewares ===============================================

-- LoggerMiddleware: debug-gated action log. Sits first; sees raw action before any transform.
-- VDS_ prefix stripped; action name padded to LOGGER_NAME_WIDTH for column alignment.
local LOGGER_NAME_WIDTH = 22  -- pad action names to this width (most are <= 20)
local LOGGER_MAX_KEYS   = 6   -- payload keys shown before truncating (bumped from 3 in readable-output pass)
local LOGGER_MAX_STR    = 80  -- string-value chars before "..." (bumped from 24)
local function formatPayload(payload)
    if type(payload) ~= "table" then return "" end
    local parts, count = {}, 0
    for k, v in pairs(payload) do
        count = count + 1
        if count > LOGGER_MAX_KEYS then
            parts[#parts + 1] = "..."
            break
        end
        local repr
        local t = type(v)
        if t == "string" then
            repr = v
            if #repr > LOGGER_MAX_STR then repr = repr:sub(1, LOGGER_MAX_STR - 3) .. "..." end
        elseif t == "boolean" or t == "number" then
            repr = tostring(v)
        elseif t == "table" then
            repr = "{...}"
        else
            repr = "<" .. t .. ">"
        end
        parts[#parts + 1] = tostring(k) .. "=" .. repr
    end
    return table.concat(parts, " ")
end

function M.LoggerMiddleware(nextDispatch, store)
    return function(action)
        -- Skip LOG_PUSH to avoid infinite recursion; other log actions flow through normally.
        local actionType = action.type
        if actionType == VDS.Constants.ACTIONS.LOG_PUSH then
            return nextDispatch(action)
        end

        -- noisy=true in action meta: dispatch normally, skip the log (prevents chat spam on hover).
        local actionMeta = store._actionMeta and store._actionMeta[actionType]  -- exception(nullable): meta not yet registered for early-boot dispatches
        if actionMeta and actionMeta.noisy then  -- exception(nullable): meta can be absent early-boot
            return nextDispatch(action)
        end

        local state = store:GetState()
        if state.account.config.debug then  -- factory state exists from file load; shape guaranteed
            local short = tostring(actionType or "?"):gsub("^VDS_", "")
            -- Pad action name so the payload column lands at the same X each line.
            local padded = short
            if #padded < LOGGER_NAME_WIDTH then
                padded = padded .. string.rep(" ", LOGGER_NAME_WIDTH - #padded)
            end
            local payload = action.payload
            local payloadStr = formatPayload(payload)
            -- Single string arg: Log:Debug(tag, msg) has no third parameter --
            -- the old `{ payloadStr = ... }` table was silently dropped, so
            -- the log window showed bare action names with no payload column.
            VDS.Log:Debug("dispatch", padded .. payloadStr)
        end
        return nextDispatch(action)
    end
end

-- CombatMiddleware: queues combatUnsafe actions during PLAYER_REGEN_DISABLED;
-- drains on PLAYER_REGEN_ENABLED. OWNS those two Blizzard events (modules cannot
-- subscribe to them; BlizzardEvents validator rejects). Wires via _internalSubscribe.
function M.MakeCombatMiddleware(actionMeta)
    actionMeta = actionMeta or {}
    return function(nextDispatch, store)
        -- Wire listeners once (idempotent). State mutations go through public Dispatch
        -- so the reducer stays the single source of truth for session.combat.inLockdown.
        if not store._combatListenersWired then
            store._combatListenersWired = true
            local A = VDS.Constants.ACTIONS
            VDS.BlizzardEvents:_internalSubscribe("PLAYER_REGEN_DISABLED", function()
                store:Dispatch({ type = A.COMBAT_ENTER })
            end)
            VDS.BlizzardEvents:_internalSubscribe("PLAYER_REGEN_ENABLED", function()
                -- 1. Snapshot queued actions. 2. COMBAT_EXIT (reducer empties queue).
                -- 3. Replay snapshot through public chain (full middleware pass).
                local s = store:GetState()
                local queued = s.session.combat.queued  -- factory-guaranteed; PLAYER_REGEN can't fire pre-init
                local snapshot = {}
                for i, a in ipairs(queued) do snapshot[i] = a end
                store:Dispatch({ type = A.COMBAT_EXIT })
                for _, queuedAction in ipairs(snapshot) do
                    store:Dispatch(queuedAction)
                end
            end)
        end

        return function(action)
            local meta = actionMeta[action.type]
            local inLockdown = store:GetState().session.combat.inLockdown == true

            if meta and meta.combatUnsafe and inLockdown then  -- exception(nullable): meta can be absent early-boot
                -- Queue for drain on PLAYER_REGEN_ENABLED via reducer (ADR-004).
                local A = VDS.Constants.ACTIONS or {}
                store:Dispatch({
                    type    = A.COMBAT_QUEUE_ACTION,
                    payload = { action = action },
                })
                return  -- deferred; no result for caller
            end
            return nextDispatch(action)
        end
    end
end

-- EnvMiddleware: injects env as 2nd arg to thunk actions (Rodux makeThunkMiddleware). Spec s9.
function M.MakeEnvMiddleware(env)
    return function(nextDispatch, store)
        return function(action)
            if type(action) == "function" then
                return action(store, env)
            end
            return nextDispatch(action)
        end
    end
end

-- PersistenceMiddleware: queues a SavedVars save when action meta has persists=true.
function M.MakePersistenceMiddleware(actionMeta)
    actionMeta = actionMeta or {}
    return function(nextDispatch, store)
        return function(action)
            local result = nextDispatch(action)
            local meta = actionMeta[action.type]
            if meta and meta.persists and store.QueueSave then  -- exception(nullable): meta/QueueSave absent during early boot
                store:QueueSave()
            end
            return result
        end
    end
end

-- ErrorBoundaryMiddleware: pcall the chain; must be OUTERMOST (registered first).
-- On error: log via VDS.Log AND route to geterrorhandler() (BugSack/BugGrabber hook).
-- pcall stays load-bearing so subscriber fan-out and combat-queue replay don't cascade-fail. ADR-021.
function M.ErrorBoundaryMiddleware(nextDispatch, store)
    return function(action)
        local ok, result = pcall(nextDispatch, action)
        if not ok then
            local actionType = action and action.type or "?"  -- exception(false-positive): outermost boundary; must not crash while reporting a crash
            local text = ("dispatch error (action=%s): %s"):format(
                tostring(actionType), tostring(result))
            VDS.Log:Error("error", text)
            -- pcall the handler itself so a misbehaving error handler can't re-crash us (ADR-021).
            if _G.geterrorhandler then
                pcall(_G.geterrorhandler(), text)  -- exception(fire-forget): Log:Error above is the record; handler re-crash absorbed per ADR-021
            end
            return nil
        end
        return result
    end
end

-- ===== Standard chain assembly =============================================

-- Standard chain in recommended order (outermost first):
-- ErrorBoundary -> Logger -> Combat -> Env -> Persistence
function M.StandardChain(opts)
    opts = opts or {}
    return {
        M.ErrorBoundaryMiddleware,
        M.LoggerMiddleware,
        M.MakeCombatMiddleware(opts.actionMeta),
        M.MakeEnvMiddleware(opts.env),
        M.MakePersistenceMiddleware(opts.actionMeta),
    }
end
