-- test_dispatch_equivalence.lua
-- ============================================================================
-- STANDING DISPATCH-CORPUS REGRESSION. Born as the conversion spine of the
-- action self-registration pass (design: Lattice/docs/SELFREG_RESOLVER_
-- DESIGN_2026-06-11.md Part 3), where the fixture pinned the pre-conversion
-- monolith's behavior; that equivalence role completed when the chain was
-- deleted (lattice-selfreg 4/N) and the harness was retained as a plain
-- golden-state regression: a deterministic dispatch corpus across the major
-- reducer domains, canonically serialized and compared against the fixture.
-- Any reduce whose behavior drifts -- field renames, missed writes, ordering
-- -- shows up as a fixture diff naming the exact path.
--
-- Regenerate (ONLY on the untouched engine / after an intended behavior
-- change reviewed by a human): GOLDEN=regen lua test_dispatch_equivalence.lua
--
-- Intended regens so far:
--   2026-06-11  resolver registry wave (lattice-selfreg 5/N): species-A/D
--               ticks moved to session.resolvers.<name>.tick; the bag /
--               questNames / questStatus / achievementStatus / staticData /
--               rep session domains dissolved into resolver slots;
--               session.catalog.sweepGeneration became the catalog resolver's
--               tick. Values preserved; only state SHAPE changed.
--   2026-06-11  tick cleanup (lattice-selfreg 6/N): species-B dissolved
--               (session.itemNames.tick deleted -- consumers read .names;
--               session.prices.tick split: A-side -> resolvers.prices.tick,
--               B-side scan/cache actions invalidate account.prices paths
--               only); species-C renamed to the changeSeq convention
--               (furn/styles/roomCatalog/categoryTree/snapshot). Values
--               preserved; only state SHAPE + field names changed.
--   2026-06-12  catalog vintage ("New in <patch>" chip): new persisted
--               account.catalogVintage slot (snapshot/newIds/build stamps)
--               + CATALOG_VINTAGE_UPDATE action. Purely additive shape.
--
-- NOTE: print() is a no-op in wow_mock.lua; use io.write() for output.

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_ConfigSchema.lua")
dofile("../Core/HDGR_Format.lua")
dofile("../Core/HDGR_Paths.lua")
dofile("../Core/HDGR_Log.lua")
dofile("../Core/HDGR_ProjectsIDs.lua")
dofile("../Core/HDGR_Memo.lua")
dofile("../Core/HDGR_TableUtils.lua")
dofile("../Core/HDGR_StoreFurnishings.lua")
dofile("../Core/HDGR_Store.lua")
dofile("../UI/HDGR_Selectors.lua")

local FIXTURE = "fixtures/golden_dispatch_state.lua"

-- ===== Canonical serializer ==================================================
-- Sorted keys, recursive, stable number formatting; functions/userdata are
-- illegal in state (fail loud if seen).
local function canon(v, indent, out)
    local t = type(v)
    if t == "nil" then out[#out + 1] = "nil"
    elseif t == "boolean" or t == "number" then out[#out + 1] = tostring(v)
    elseif t == "string" then out[#out + 1] = string.format("%q", v)
    elseif t == "table" then
        local keys = {}
        for k in pairs(v) do keys[#keys + 1] = k end
        table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
        out[#out + 1] = "{\n"
        for _, k in ipairs(keys) do
            out[#out + 1] = indent .. "  [" .. string.format(type(k) == "string" and "%q" or "%s", k) .. "] = "
            canon(v[k], indent .. "  ", out)
            out[#out + 1] = ",\n"
        end
        out[#out + 1] = indent .. "}"
    else
        error("illegal value type in state: " .. t)
    end
end
local function serialize(state)
    local out = {}
    canon(state, "", out)
    return table.concat(out)
end

-- ===== Deterministic corpus ==================================================
-- Every ts is fixed; no time()/random. Sequences adapted from the domain
-- tests so the corpus exercises real flows, not synthetic field pokes.

_G.HDG_DB = {}
HDG.Store:LoadFromSavedVariables()
local A = HDG.Constants.ACTIONS
local function d(t, payload) HDG.Store:_RawDispatch({ type = t, payload = payload or {} }) end

-- Projects topology + capture
d(A.PROJECTS_UPSERT_HOUSE, { houseID = "house:eq", fields = { name = "Equivalence Manor", lastCapturedAt = 10 } })
-- One room per commit: ApplyCapture iterates payload.rooms with pairs(),
-- so multi-room commits mint slot numbers in HASH ORDER (nondeterministic
-- run-to-run; fine live because lineage rides capturedID, fatal for a
-- golden corpus). Sequential commits pin slot:1..3.
d(A.PROJECTS_CAPTURE_COMMIT, { houseID = "house:eq", lastCapturedAt = 11, rooms = {
    ["house:eq:f1:r1"] = { shape = "entry",    name = "Door",  cell = { x = 5, y = 9, rotation = 0 } } } })
d(A.PROJECTS_CAPTURE_COMMIT, { houseID = "house:eq", lastCapturedAt = 12, rooms = {
    ["house:eq:f1:r2"] = { shape = "square_m", name = "Hall",  cell = { x = 2, y = 2, rotation = 0 } } } })
d(A.PROJECTS_CAPTURE_COMMIT, { houseID = "house:eq", lastCapturedAt = 13, rooms = {
    ["house:eq:f2:r1"] = { shape = "octagon_s", name = "Tower", cell = { x = 4, y = 4, rotation = 90 } } } })
d(A.PROJECTS_HOUSE_TICK, { numFloors = 2, budget = { decorSpent = 12, decorCount = 3 }, editorActive = false })

-- Furnishings: sets, designs, tags (multi-assign / reassign / unassign / fork engine)
d(A.FURN_SET_CREATE, { name = "Golden Set", items = { { id = 101, count = 2 }, { id = 202, count = 1 } } })
d(A.FURN_SET_ITEM_ADD, { setID = "set:1", itemID = 303 })
d(A.FURN_SET_ITEM_ADD, { setID = "set:1", itemID = 303, count = 4 })
d(A.FURN_SET_ITEM_REMOVE, { setID = "set:1", itemID = 101 })
d(A.FURN_ROOM_CREATE, { shape = "square_m", name = "Gold Room" })
d(A.FURN_ROOM_EQUIP, { roomID = "room:1", setID = "set:1" })
local lid = HDG.Store:GetState().account.projects.houses["house:eq"].currentVersionID
d(A.LAYOUT_PLACE, { layoutID = lid, roomID = "room:1", floor = 1, x = 12, y = 3 })
d(A.LAYOUT_PLACE, { layoutID = lid, roomID = "room:1", floor = 1, x = 20, y = 3 })   -- multi-assign
d(A.LAYOUT_PLACE, { layoutID = lid, shape = "closet_xs", floor = 1, x = 30, y = 8 }) -- doodle
d(A.FURN_ROOM_CREATE, { shape = "closet_xs", name = "Nook" })
do  -- assign the doodle to Nook, then unassign it
    local lay = HDG.Store:GetState().account.projects.layouts[lid]
    local key
    for k, pl in pairs(lay.placements) do
        if pl.shape == "closet_xs" and not pl.roomID then key = k end
    end
    d(A.LAYOUT_ASSIGN,  { layoutID = lid, slotKey = key, roomID = "room:2" })
    d(A.LAYOUT_UNASSIGN, { layoutID = lid, key = key })
    d(A.LAYOUT_ASSIGN,  { layoutID = lid, slotKey = key, roomID = "room:2" })
end
d(A.FURN_ROOM_DUPLICATE, { roomID = "room:1", ts = 20 })
d(A.LAYOUT_MOVE, { layoutID = lid, key = "slot:4", x = 14, rotation = 90 })
d(A.FURN_SET_CREATE, { name = "Local Bits", isLocal = true, ownerRoom = "room:2", items = { { id = 404, count = 3 } } })
d(A.FURN_ROOM_EQUIP, { roomID = "room:2", setID = "set:2" })
d(A.FURN_ROOM_DELETE, { roomID = "room:2" })
d(A.FURN_SET_PROMOTE, { setID = "set:2", name = "Promoted Bits" })

-- Versions: branch, switch, delete
d(A.PROJECTS_CREATE_VERSION, { houseID = "house:eq", name = "Equiv What-if", basedOn = lid, createdAt = 30 })
local whatif = HDG.Store:GetState().account.projects.houses["house:eq"].activeVersionID
d(A.PROJECTS_RENAME_VERSION, { versionID = whatif, name = "Renamed What-if" })
d(A.PROJECTS_SET_ACTIVE_VERSION, { houseID = "house:eq", versionID = lid })
d(A.PROJECTS_SET_ACTIVE_VERSION, { houseID = "house:eq", versionID = whatif })
d(A.PROJECTS_DELETE_VERSION, { houseID = "house:eq", versionID = whatif })

-- Shipping crates (DeepCopy path)
d(A.SHIPPING_CRATE_PACK, { shipID = "ship:eq1", record = {
    id = "ship:eq1", type = "shippingCrate", name = "Equiv Backup", houseID = "house:eq",
    packedAt = 40, captureLevel = "manifest",
    topology = { rooms = { r1 = { shape = "entry" } } },
    contents = { { id = 11, count = 2 } },
} })
d(A.SHIPPING_CRATE_DELETE, { shipID = "ship:eq1" })

-- UI transients + persistents
d(A.UI_SET_TRANSIENT, { view = "projects", key = "selectedFloor", value = 2 })
d(A.UI_SET_TRANSIENT, { view = "projects", key = "helpStage", value = 4 })
d(A.UI_SET_PERSISTENT, { key = "view", value = "projectsArchitect" })

-- Resolver ticks + observers (CONFIG_SET needs the Config/profiles module --
-- its domain joins the corpus when that conversion adds the dep)
d(A.ITEM_INFO_RESOLVED, { names = { [505] = "Equiv Lamp", [606] = "Equiv Rug" } })
d(A.QUEST_INFO_RESOLVED, {})
d(A.BAG_INVENTORY_UPDATED, {})
d(A.HOUSE_LIST_UPDATED, { houses = {
    { houseGUID = "g-eq", neighborhoodName = "Equiv Hills", houseName = "Casa Equiv",
      plotID = 3, neighborhoodGUID = "ng-eq", faction = "Alliance" },
    { houseGUID = "g-eq2", neighborhoodName = "Equiv Hills", houseName = 0,   -- the truthy-0 gotcha
      plotID = 4, neighborhoodGUID = "ng-eq", faction = "Alliance" },
} })

-- Craft queue + lumber session (charKey-dependent paths use the mock identity)
d(A.SESSION_IDENTITY_SET, { name = "Equiv", realm = "Realm", class = "Druid", classFile = "DRUID", factionGroup = "Alliance" })
d(A.CRAFT_QUEUE_ADD, { recipeID = 707, itemID = 707, qty = 2 })
d(A.CRAFT_QUEUE_ADD, { recipeID = 707, itemID = 707, qty = 1 })
d(A.LUMBER_SESSION_START, { lumberID = 808, zone = "Equiv Woods", ts = 50 })
d(A.LUMBER_TICK, { ts = 51 })
d(A.LUMBER_SESSION_END, { ts = 60 })

-- ===== Compare or regenerate =================================================
local dump = serialize(HDG.Store:GetState())

if os.getenv("GOLDEN") == "regen" then
    local f = assert(io.open(FIXTURE, "w"))
    f:write("-- GOLDEN dispatch-equivalence fixture. Generated by\n")
    f:write("-- GOLDEN=regen lua test_dispatch_equivalence.lua -- regenerate ONLY for\n")
    f:write("-- an intended, human-reviewed behavior change (see file header).\n")
    f:write("return " .. string.format("%q", dump):gsub("\\\n", "\\n") .. "\n")
    f:close()
    io.write("test_dispatch_equivalence.lua: GOLDEN fixture regenerated\n")
    return
end

local ok, golden = pcall(dofile, FIXTURE)
if not ok or type(golden) ~= "string" then
    io.write("test_dispatch_equivalence.lua FAILED: missing/unreadable fixture -- run GOLDEN=regen first\n")
    os.exit(1)
end

if dump == golden then
    io.write("test_dispatch_equivalence.lua passed (state identical to golden corpus)\n")
else
    -- Name the first diverging line so the failure points at a path.
    local li = 0
    local gi, di = golden:gmatch("[^\n]*"), dump:gmatch("[^\n]*")
    while true do
        li = li + 1
        local g, dd = gi(), di()
        if g ~= dd then
            io.write(("test_dispatch_equivalence.lua FAILED at line %d:\n  golden: %s\n  actual: %s\n")
                :format(li, tostring(g), tostring(dd)))
            break
        end
        if g == nil then break end
    end
    os.exit(1)
end
