-- test_invalidation_correctness.lua
-- Priority 1: Invalidation correctness (R15 -- the highest-leverage test).
--
-- R15 risk: if a selector's `_hdgrReadsClosure` is incomplete, the
-- BindingEngine:Apply will skip re-pushing that widget when the action
-- fires. The result is stale data shown to the user with NO error.
--
-- What this test asserts:
--   For every action in ACTIONS that has a non-"*" `invalidates` list:
--     - Dispatch the action.
--     - Assert that EVERY synthetic widget whose readsClosure intersects
--       the invalidation WAS pushed (widget._pushCount incremented).
--     - Assert that ZERO widgets whose readsClosure does NOT intersect
--       were pushed on that dispatch (no spurious refreshes).
--
--   For actions with `invalidates = {}` (empty list, if any):
--     - Assert that ZERO widgets are pushed.
--
-- Implementation approach:
--   We build a synthetic rootFrame.widgets table with a controlled set of
--   "probe" widgets. Each probe has a hardcoded _hdgrReadsClosure. We call
--   BindingEngine:Apply directly with a concrete invalidation list derived
--   from the action meta, and count pushes per widget.
--
-- The BindingEngine.Apply path:
--   for each widget: if Paths.MatchesAny(readsClosure, invalidation) -> push
-- This test verifies THAT logic is correct for the declared invalidation
-- shapes, and that the Paths.MatchesAny contract matches expectations.
--
-- NOTE: this test does NOT load selectors or a real LayoutConfig. It uses
-- stub widgets with pre-set readsClosure values so it stays O(constant)
-- and does not depend on selector correctness (that is Priority 2).

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("../Core/HDGR_BlizzardEvents.lua")
dofile("../Core/HDGR_Environment.lua")
dofile("../Core/HDGR_Modules.lua")
dofile("../UI/HDGR_Theme.lua")
-- Locale: OnInitialize calls Locale:Initialize unconditionally (no-guards).
dofile("../Core/HDGR_Locale.lua")
dofile("../Core/HDGR_Init.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

-- Boot just enough to get actionMeta populated on HDG.Store.
HDG_DB = {}
HDG.Store:LoadFromSavedVariables()
if HDG.Theme and HDG.Theme.Initialize then HDG.Theme:Initialize() end  -- boundary
pcall(function() HDG:OnInitialize() end)

local actionMeta = HDG.Store._actionMeta
assertTrue(type(actionMeta) == "table", "setup: actionMeta must be populated")

-- ===== Synthetic push harness ================================================

-- Build a probe widget with a given readsClosure.
local function makeProbe(id, readsClosure)
    local w = {
        id              = id,
        _hdgrBound       = true,
        _hdgrBinding     = { text = "static:ignored" },
        _hdgrReadsClosure= readsClosure,
        _pushCount      = 0,
        _hdgrDispatcher  = function(self, _resolved, _ctx)
            self._pushCount = self._pushCount + 1
        end,
    }
    return w
end

-- Probe matrix: a set of widgets covering the main state regions.
-- Each widget's readsClosure is chosen to be:
--   "hit"  -- should be pushed for the given action's invalidation
--   "miss" -- should NOT be pushed
--   "star" -- reads = "*" (always refreshed; sanity control)
local function buildProbeMatrix()
    return {
        -- Regions used by most actions.
        session_ui_decor   = makeProbe("session_ui_decor",   { "session.ui.decor" }),
        session_ui_acq     = makeProbe("session_ui_acq",     { "session.ui.acquisition" }),
        session_ui_recipes = makeProbe("session_ui_recipes", { "session.ui.recipes" }),
        session_ui_mogul   = makeProbe("session_ui_mogul",   { "session.ui.mogul" }),
        session_ui_styles  = makeProbe("session_ui_styles",  { "session.ui.styles" }),
        account_collection = makeProbe("account_collection", { "account.collection" }),
        account_config     = makeProbe("account_config",     { "account.config" }),
        account_ui         = makeProbe("account_ui",         { "account.ui" }),
        account_craft      = makeProbe("account_craft",      { "account.craft" }),
        account_prices     = makeProbe("account_prices",     { "account.prices" }),
        session_combat     = makeProbe("session_combat",     { "session.combat" }),
        session_log        = makeProbe("session_log",        { "session.log" }),
        session_bag        = makeProbe("session_bag",        { "session.resolvers.bag" }),
        session_prices     = makeProbe("session_prices",     { "session.prices" }),
        account_favorites  = makeProbe("account_favorites",  { "account.favorites" }),
        account_characters = makeProbe("account_characters", { "account.characters" }),
        account_recipes    = makeProbe("account_recipes",    { "account.recipes" }),
        session_styles     = makeProbe("session_styles",     { "session.styles" }),
        star_widget        = makeProbe("star_widget",        "*"),
    }
end

-- Minimal synthetic rootFrame.
local function makeRootFrame(probes)
    return { widgets = probes }
end

-- Simulate BindingEngine:Apply's matching loop. Returns { pushed = [...], skipped = [...] }.
-- This mirrors the exact logic in HDGR_BindingEngine.lua:Apply so we test the
-- same predicate (Paths.MatchesAny) that the engine uses.
local function simulateApply(probes, invalidation)
    local pushed, skipped = {}, {}
    for id, widget in pairs(probes) do
        if widget._hdgrBound and widget._hdgrBinding and widget._hdgrDispatcher then
            local readsClosure = widget._hdgrReadsClosure or "*"
            if HDG.Paths.MatchesAny(readsClosure, invalidation) then
                widget:_hdgrDispatcher({}, {})
                pushed[#pushed + 1] = id
            else
                skipped[#skipped + 1] = id
            end
        end
    end
    return pushed, skipped
end

-- Helper: resolve the invalidation list for an action (mirrors _RawDispatch).
local function resolveInvalidation(meta, action)
    if meta == nil then return "*" end
    local inv = meta.invalidates
    if type(inv) == "function" then
        local ok, result = pcall(inv, action)
        return (ok and result) or "*"
    elseif inv ~= nil then
        return inv
    end
    return "*"
end

-- ===== Core invariant tests ==================================================
local A = HDG.Constants.ACTIONS

-- 1. Empty invalidation list: only the "*"-reads widget should be pushed.
--    MatchesAny semantics: readsList == "*" -> always true regardless of
--    invalidationList. So star_widget (readsClosure = "*") is pushed even
--    for an empty invalidation list. That's correct and by design: "*" means
--    "I read all of state; any dispatch might matter." The key invariant is
--    that path-scoped widgets (non-star) are NOT pushed for empty invalidation.
local emptyInvalidation = {}
local probes = buildProbeMatrix()
local pushed, skipped = simulateApply(probes, emptyInvalidation)
local nonStarPushed = 0
for _, id in ipairs(pushed) do
    if id ~= "star_widget" then nonStarPushed = nonStarPushed + 1 end
end
assertEq(nonStarPushed, 0,
    "empty invalidation: only star_widget should be pushed (no path-scoped widgets)")

-- 2. Wildcard "*" invalidation pushes ALL widgets.
probes = buildProbeMatrix()
pushed, skipped = simulateApply(probes, "*")
local totalWidgets = 0
for _ in pairs(probes) do totalWidgets = totalWidgets + 1 end
assertEq(#pushed, totalWidgets, "wildcard invalidation must push every widget")
assertEq(#skipped, 0, "wildcard invalidation: zero widgets skipped")

-- 3. For each action with a concrete (non-"*") invalidation, verify that:
--    - Widgets whose readsClosure INTERSECTS the invalidation ARE pushed.
--    - Widgets whose readsClosure does NOT intersect are NOT pushed.
--
-- We test a representative sample covering each major invalidation region.
-- "representative" keeps runtime < 1s and avoids needing full boot.

local sampleActions = {
    -- Format: { actionType, samplePayload, expectedHitProbes, expectedMissProbes }
    -- Hit/miss probe IDs from buildProbeMatrix.
    {
        action = { type = A.DECOR_SET_SEARCH, payload = { query = "rug" } },
        hits   = { "session_ui_decor", "star_widget" },
        misses = { "account_collection", "session_ui_acq", "account_config", "session_combat" },
    },
    {
        action = { type = A.COMBAT_ENTER, payload = {} },
        hits   = { "session_combat", "star_widget" },
        misses = { "session_ui_decor", "account_collection", "account_config" },
    },
    {
        action = { type = A.COLLECTION_ITEM_LEARNED, payload = { decorID = 1 } },
        -- invalidates = { "account.collection.ownedDecorIDs" }
        -- Matches "account.collection" (parent prefix match) -> hit.
        hits   = { "account_collection", "star_widget" },
        misses = { "session_ui_decor", "session_combat", "account_config" },
    },
    {
        action = { type = A.CONFIG_SET, payload = { key = "debug", value = true } },
        -- invalidates = { "account.config.debug" } (dynamic fn result for key="debug")
        hits   = { "account_config", "star_widget" },
        misses = { "session_ui_decor", "account_collection", "session_combat" },
    },
    {
        action = { type = A.RECIPES_SET_SEARCH, payload = { query = "phial" } },
        hits   = { "session_ui_recipes", "star_widget" },
        misses = { "session_ui_decor", "account_collection", "session_combat" },
    },
    {
        action = { type = A.BAG_INVENTORY_UPDATED, payload = { tick = 1 } },
        hits   = { "session_bag", "star_widget" },
        misses = { "session_ui_decor", "account_collection", "session_combat" },
    },
    {
        action = { type = A.LOG_PUSH, payload = { tag = "dispatch", level = "debug",
                                                   text = "test", timestamp = 0 } },
        hits   = { "session_log", "star_widget" },
        misses = { "session_ui_decor", "account_collection" },
    },
    {
        action = { type = A.FAVORITE_TOGGLE, payload = { itemID = 999 } },
        hits   = { "account_favorites", "star_widget" },
        misses = { "session_ui_decor", "session_combat" },
    },
}

for _, tc in ipairs(sampleActions) do
    local meta = actionMeta[tc.action.type]
    local inv = resolveInvalidation(meta, tc.action)

    -- Reset probes.
    local p = buildProbeMatrix()

    -- Run the apply simulation.
    simulateApply(p, inv)

    -- Build lookup sets.
    local pushedSet = {}
    for id, w in pairs(p) do
        if w._pushCount > 0 then pushedSet[id] = true end
    end

    -- Assert hits.
    for _, hitID in ipairs(tc.hits) do
        if not pushedSet[hitID] then
            error(("invalidation correctness: action=%s should push widget %q "
                .. "(its reads intersect invalidation %s) but did NOT")
                :format(tc.action.type, hitID, require and "" or tostring(inv)), 2)
        end
    end

    -- Assert misses.
    for _, missID in ipairs(tc.misses) do
        if pushedSet[missID] then
            local widgetReads = p[missID]._hdgrReadsClosure
            error(("invalidation correctness: action=%s must NOT push widget %q "
                .. "(reads=%s does not intersect invalidation=%s) but DID push it")
                :format(tc.action.type, missID,
                    type(widgetReads) == "table" and table.concat(widgetReads, ",") or tostring(widgetReads),
                    type(inv) == "table" and table.concat(inv, ",") or tostring(inv)), 2)
        end
    end
end

-- 4. ZERO re-push when action's invalidation touches a path that no probe reads.
--    Use a very narrow invalidation that none of our non-star probes covers.
probes = buildProbeMatrix()
simulateApply(probes, { "account.someNonExistentPath.deep" })
nonStarPushed = 0
for id, w in pairs(probes) do
    if id ~= "star_widget" and w._pushCount > 0 then
        nonStarPushed = nonStarPushed + 1
    end
end
assertEq(nonStarPushed, 0,
    "narrow path with no matching widget must push ZERO path-scoped widgets")

-- 5. v7 meta-path regression: the Projects reducers write account.projects.LAYOUTS
--    (+ rooms / session.furn) -- a meta still declaring the retired
--    account.projects.versions path silently skips every canvas/selector re-push
--    (shipped bug: Architect stayed empty after capture). Pin the paths here.
do
    local A    = HDG.Constants.ACTIONS
    local meta = HDG.Store._actionMeta or HDG.Store.actionMeta
    local function declares(actionType, path)
        local m = meta and meta[actionType]
        local inv = m and m.invalidates
        if inv == "*" then return true end
        for _, pth in ipairs(inv or {}) do
            if pth == path then return true end
        end
        return false
    end
    local function declaresExplicitly(actionType, path)
        local m = meta and meta[actionType]
        if m and m.invalidates == "*" then return false end   -- wildcard never names a retired path
        return declares(actionType, path)
    end
    local LAYOUT_WRITERS = {
        A.PROJECTS_CAPTURE_COMMIT, A.PROJECTS_CLEAR_HOUSE,
        A.PROJECTS_SET_VERSION_FLOORS, A.PROJECTS_RENAME_VERSION,
        A.PROJECTS_CREATE_VERSION, A.PROJECTS_DELETE_VERSION,
        A.PROJECTS_IMPORT_LAYOUT,
        A.LAYOUT_PLACE, A.LAYOUT_MOVE, A.LAYOUT_REMOVE_PLACEMENT, A.LAYOUT_ASSIGN,
        A.LAYOUT_SWAP_ROOM, A.LAYOUT_UNASSIGN, A.FURN_ROOM_DUPLICATE,
    }
    for _, t in ipairs(LAYOUT_WRITERS) do
        assertTrue(declares(t, "account.projects.layouts"),
            ("v7 meta drift: %s writes layouts but does not invalidate account.projects.layouts"):format(tostring(t)))
        assertTrue(not declaresExplicitly(t, "account.projects.versions"),
            ("v7 meta drift: %s still invalidates the retired account.projects.versions path"):format(tostring(t)))
    end
    assertTrue(declares(A.PROJECTS_CAPTURE_COMMIT, "account.rooms"),
        "v7 meta drift: CAPTURE_COMMIT merges room names but does not invalidate account.rooms")
end

print("test_invalidation_correctness.lua passed")
