-- test_hdg_migration.lua
-- Round-trip, idempotency, backup, already-HDG, and adversarial tests for
-- HDGR_Migration.lua -- the in-place HDG_DB -> HDG account.* schema transform.
--
-- NOTE: print() is a no-op in wow_mock.lua; use io.write() for any output.

dofile("wow_mock.lua")
dofile("../Core/HDGR_SchemeConstants.lua")
dofile("../Core/HDGR_Constants.lua")
dofile("../Core/HDGR_Migration.lua")

-- ============================================================================
-- Assertion helpers
-- ============================================================================

local failures = 0

local function fail(msg)
    failures = failures + 1
    io.write("FAIL: " .. msg .. "\n")
end

local function assertEq(actual, expected, label)
    if actual ~= expected then
        fail(label .. ": expected " .. tostring(expected) .. " got " .. tostring(actual))
    end
end

local function assertTrue(v, label)
    if not v then fail(label) end
end

local function assertNil(v, label)
    if v ~= nil then fail(label .. " (expected nil, got " .. tostring(v) .. ")") end
end

local function assertType(v, t, label)
    if type(v) ~= t then
        fail(label .. ": expected type " .. t .. " got " .. type(v))
    end
end

-- ============================================================================
-- Fixture builder: a synthetic old-HDG_DB (schemaVersion==2 flat schema)
-- ============================================================================

local function buildOldHDGDB()
    return {
        schemaVersion = 2,

        -- === MIGRATE targets ===
        collections = {
            -- curated style: items in a `curatedItems` SET (old HDG shape)
            ["style:custom-1"] = { id = "style:custom-1", type = "style", displayName = "Foo Style",
                curatedItems = { [123] = true, [456] = true }, tier = "bronze" },
            -- snapshot: type="style" + `snapshot` sub-table (old HDG shape)
            ["style:snapshot-1"] = { id = "style:snapshot-1", type = "style", displayName = "All Placed Decor",
                snapshot = { capturedAt = 7000, items = { [10] = {}, [20] = {}, [30] = {} }, totalPlacementCost = 99 } },
            -- smart set: type="style" + `query` only (old HDG shape)
            ["style:smart-1"] = { id = "style:smart-1", type = "style", displayName = "Wood Things",
                query = { material = { wood = true } } },
            -- crate: feature never shipped -> must be DROPPED
            ["crate:house:alliance:f1:rABCD:c01"] = { id = "crate:...", type = "crate", parent = "house:alliance:f1:rABCD", name = "Lounge", members = {} },
        },
        favorites = { [555] = true, [666] = true },
        userNotes = {
            [100] = { text = "my note", ts = 1000 },
            [200] = { text = "another", ts = 2000 },
        },
        vendorNotes = {
            [9999] = { text = "vendor note", ts = 3000 },
        },
        vendorShoppingLists = {
            ["L1"] = { name = "Main list", items = { { itemID = 10, npcID = 5, qty = 2, addedAt = 500 } }, meta = {}, createdAt = 100 },
            ["L2"] = { name = "Alt list",  items = { { itemID = 20, qty = 1, addedAt = 600 } },             meta = {}, createdAt = 200 },
        },
        activeShoppingListId = "L1",
        craftingHistory = {
            { itemID = 111, itemName = "Soft Rug", quantity = 3, profession = "Tailoring", character = "Vamoose", realm = "Kel'Thuzad", source = "Crafted",  timestamp = 10000 },
            { itemID = 222, itemName = "Candle",   quantity = 1, profession = "Tailoring", character = "Vamoose", realm = "Kel'Thuzad", source = "Learned",  timestamp = 20000 },
            { id = 333,     itemName = "Legacy",   quantity = 2, source = "Crafted", timestamp = 5000 }, -- legacy `id` alias, no `itemID`
        },
        farmingHistory = {
            { sessionId = "s1", collected = 150, duration = 3600, perHour = 150, timestamp = 50000, zone = "Hallowfall", lumberId = 251762, lumberName = "Coldwind", character = "Vamoose", realm = "KelThuzad" },
            { sessionId = "s2", collected = 80,  timestamp = 60000, zone = "Azj-Kahet" },
            { timestamp = 70000, collected = 200, zone = "Karazhan" }, -- no sessionId
        },
        recentActivity = {
            houses = {
                ["house:alliance"] = {
                    sessionOrder = { 1000 },
                    sessions = {
                        [1000] = { sessionID = 1000, startedAt = 1000, endedAt = 2000, eventCount = 5, events = {}, actions = {} }
                    }
                }
            },
            lastHouseKey = "house:alliance",
        },
        characters = {
            ["Vamoose-KelThuzad"] = {
                professions = {
                    Tailoring = {
                        -- old-HDG per-prof shape: knownCount/totalCount + skillLevels
                        -- (migration must rename skillLevels -> skillLines).
                        knownCount   = 2,
                        totalCount   = 5,
                        knownRecipes = { [100] = true, [200] = true },
                        skillLevels  = { ["The War Within"] = { current = 100, max = 200 } },
                    },
                },
                lastUpdated = 99000,
                knowsFindLumber = true,
                lumberInventory = { [1] = 50 },
            },
            ["Alt-Realm"] = {
                professions = {},
                lastUpdated = 50000,
            },
        },

        -- === DROP targets (must NOT appear in account.*) ===
        projects = { houses = { ["h1"] = {} }, rooms = {}, crates = {} },
        config   = { debug = true, scale = 0.8 },
        ui       = { framePosition = { x = 100, y = 200 } },
        minimap  = { hide = true, minimapPos = 90 },
        mogul    = { lumberBudget = 999 },
        altsViewMode = "skills",
        recentActivityCap = 30,

        -- === SKIP targets (caches) ===
        vendorNameCache    = { [9999] = "Ziri the Vendor" },
        vendorCurrencyCache = { [9999] = { currencyID = 1 } },
        directPriceCache   = { [111] = 5000 },
        directPriceCacheTime = 12345,
        ownedAuctions      = { [42] = { qty = 1, buyout = 10000 } },
        collection         = { version = 3, ownedDecorIDs = { [555] = true } },

        -- === DROP: obsolete ===
        craftingQueue   = { { itemID = 9, spellID = 99, quantity = 1 } },
        customStyles    = { ["style1"] = { name = "Old Style", members = {} } },
        customStyleOrder = { "style1" },
        migrations      = { ["v1-collections"] = true, ["v3-curated-library"] = true },
    }
end

-- ============================================================================
-- TEST 1: Round-trip — migrate a full old-HDG_DB fixture
-- ============================================================================

do
    io.write("--- Test 1: round-trip migration\n")

    -- Reset any backup from previous test runs in this session.
    _G.HDG_DB_preMigrationBackup = nil

    local rawDB = buildOldHDGDB()
    HDG.Migration:Run(rawDB)

    local acct = rawDB.account
    assertType(acct, "table", "account sub-table exists")

    -- schemaVersion stamped
    local sv = HDG.Constants.SCHEMA_VERSION
    assertEq(acct.schemaVersion, sv, "account.schemaVersion stamped")

    -- collections TRANSFORMED to the HDGR style model (not verbatim)
    assertType(acct.collections, "table", "collections migrated")
    -- curated: curatedItems SET -> items ARRAY, stays type=style + "style:" key
    local cs = acct.collections["style:custom-1"]
    assertType(cs, "table", "curated style present (key unchanged)")
    assertEq(cs.type, "style", "curated stays type=style")
    assertEq(cs.displayName, "Foo Style", "displayName preserved")
    assertEq(#cs.items, 2, "curatedItems SET -> items ARRAY (2)")
    assertNil(cs.curatedItems, "curatedItems cleared after transform")
    -- snapshot: type=style+snapshot -> type=snapshot + items + takenAt, RE-KEYED to "snapshot:"
    assertNil(acct.collections["style:snapshot-1"], "old snapshot key removed (re-keyed)")
    local sn = acct.collections["snapshot:1"]
    assertType(sn, "table", "snapshot re-keyed style:snapshot-1 -> snapshot:1")
    assertEq(sn.id, "snapshot:1", "snapshot def.id re-keyed")
    assertEq(sn.type, "snapshot", "snapshot retyped style->snapshot")
    assertEq(#sn.items, 3, "snapshot.items -> items ARRAY (3)")
    assertEq(sn.takenAt, 7000, "takenAt from snapshot.capturedAt")
    assertNil(sn.snapshot, "snapshot sub-table cleared after transform")
    -- smart set: query-only -> type=smartset, query preserved, RE-KEYED to "smartset:"
    assertNil(acct.collections["style:smart-1"], "old smartset key removed (re-keyed)")
    local sm = acct.collections["smartset:smart-1"]
    assertType(sm, "table", "smartset re-keyed style:smart-1 -> smartset:smart-1")
    assertEq(sm.type, "smartset", "query-only retyped style->smartset")
    assertType(sm.query, "table", "smartset query preserved")
    -- crate: DROPPED (feature never shipped)
    assertNil(acct.collections["crate:house:alliance:f1:rABCD:c01"], "crate dropped on migration")

    -- favorites
    assertType(acct.favorites, "table", "favorites migrated")
    assertTrue(acct.favorites[555] == true, "favorites[555]")
    assertTrue(acct.favorites[666] == true, "favorites[666]")

    -- userNotes
    assertType(acct.userNotes, "table", "userNotes migrated")
    assertEq(acct.userNotes[100].text, "my note", "userNotes[100].text")
    assertEq(acct.userNotes[200].ts, 2000, "userNotes[200].ts")

    -- vendorNotes
    assertType(acct.vendorNotes, "table", "vendorNotes migrated")
    assertEq(acct.vendorNotes[9999].text, "vendor note", "vendorNotes[9999].text")

    -- vendorShoppingLists
    assertType(acct.vendorShoppingLists, "table", "vendorShoppingLists migrated")
    assertType(acct.vendorShoppingLists["L1"], "table", "L1 present")
    assertEq(acct.vendorShoppingLists["L1"].name, "Main list", "L1 name")
    assertEq(#acct.vendorShoppingLists["L1"].items, 1, "L1 items count")
    assertEq(acct.activeShoppingListId, "L1", "activeShoppingListId carried")
    -- shoppingListSeq reseeded to max(2) from L1,L2
    assertTrue(acct.shoppingListSeq >= 2, "shoppingListSeq reseeded past L2")

    -- craftingHistory transformed
    assertType(acct.craft, "table", "craft table exists")
    assertType(acct.craft.history, "table", "craft.history exists")
    assertType(acct.craft.history.entries, "table", "craft.history.entries exists")
    -- 3 entries from fixture
    assertEq(#acct.craft.history.entries, 3, "craft history entry count")
    assertEq(acct.craft.history.nextID, 3, "craft history nextID")
    -- Entries are ordered OLDEST-first (the Your Data selector reverse-renders, so
    -- newest shows at top). Sorted by timestamp: 333(5000) < 111(10000) < 222(20000),
    -- and ids increase with time (oldest = id 1).
    local e1 = acct.craft.history.entries[1]   -- oldest: ts=5000, legacy `id` alias (no itemID)
    assertEq(e1.id, 1, "entry[1].id (oldest gets lowest id)")
    assertEq(e1.itemID, 333, "entry[1].itemID via legacy id alias")
    assertEq(e1.qty, 2, "entry[1].qty")
    assertEq(e1.eventType, "crafted", "entry[1].eventType")
    assertEq(e1.timestamp, 5000, "entry[1].timestamp")
    assertNil(e1.recipeID, "entry[1].recipeID nil (HDG did not persist)")
    local e2 = acct.craft.history.entries[2]   -- ts=10000
    assertEq(e2.itemID, 111, "entry[2].itemID")
    assertEq(e2.qty, 3, "entry[2].qty")
    assertEq(e2.eventType, "crafted", "entry[2].eventType")
    local e3 = acct.craft.history.entries[3]   -- ts=20000, source "Learned"
    assertEq(e3.itemID, 222, "entry[3].itemID")
    assertEq(e3.eventType, "learned", "entry[3].eventType learned")
    assertTrue(e1.timestamp < e2.timestamp and e2.timestamp < e3.timestamp, "craft entries ordered oldest-first")

    -- farmingHistory transformed
    assertType(acct.lumber, "table", "lumber table exists")
    assertType(acct.lumber.history, "table", "lumber.history exists")
    assertType(acct.lumber.history.entries, "table", "lumber.history.entries exists")
    assertEq(#acct.lumber.history.entries, 3, "farming history entry count")
    assertEq(acct.lumber.history.nextID, 3, "farming history nextID")
    local f1 = acct.lumber.history.entries[1]
    assertEq(f1.id, 1, "farming entry[1].id")
    assertEq(f1.startedAt, 50000, "farming entry[1].startedAt from timestamp")
    assertEq(f1.sessionTotal, 150, "farming entry[1].sessionTotal from collected")
    assertEq(f1.zone, "Hallowfall", "farming entry[1].zone")
    -- lumberId -> lumberID carried (dropping it was the v1 bug); identity + duration too.
    assertEq(f1.lumberID, 251762, "farming entry[1].lumberID carried from lumberId")
    assertEq(f1.charKey, "Vamoose-KelThuzad", "farming entry[1].charKey from character-realm")
    assertEq(f1.character, "Vamoose", "farming entry[1].character carried")
    assertEq(f1.finalizedAt, 53600, "farming entry[1].finalizedAt = timestamp + duration")
    -- entry[2] has no lumber/char fields -> nil (not all old sessions recorded them)
    assertNil(acct.lumber.history.entries[2].lumberID, "farming entry[2].lumberID nil when absent in source")

    -- recentActivity carried (HDG-compatible shape)
    assertType(acct.recentActivity, "table", "recentActivity migrated")
    assertType(acct.recentActivity.houses, "table", "recentActivity.houses")
    assertEq(acct.recentActivity.lastHouseKey, "house:alliance", "recentActivity.lastHouseKey")

    -- characters migrated
    assertType(acct.characters, "table", "characters migrated")
    local vChar = acct.characters["Vamoose-KelThuzad"]
    assertType(vChar, "table", "Vamoose-KelThuzad present")
    assertType(vChar.professions, "table", "professions carried")
    assertType(vChar.professions.Tailoring, "table", "Tailoring profession carried")
    assertTrue(vChar.professions.Tailoring.knownRecipes[100] == true, "knownRecipes[100]")
    -- professions transform: skillLevels -> skillLines (inner {current,max} verbatim)
    assertType(vChar.professions.Tailoring.skillLines, "table", "skillLines produced from skillLevels")
    assertEq(vChar.professions.Tailoring.skillLines["The War Within"].current, 100, "skillLines inner current carried")
    assertNil(vChar.professions.Tailoring.skillLevels, "old skillLevels key dropped")
    assertEq(vChar.lastSeen, 99000, "lastSeen from lastUpdated")
    assertTrue(vChar.knowsFindLumber == true, "knowsFindLumber carried")
    assertEq(vChar.hidden, false, "hidden seeded false")
    -- identity: name/realm parsed from "Name-Realm" charKey; class backfilled by scanner
    assertEq(vChar.name,  "Vamoose",   "name parsed from charKey")
    assertEq(vChar.realm, "KelThuzad", "realm parsed from charKey")
    assertNil(vChar.class, "class nil (backfilled by scanner)")

    -- === DROP assertions: these keys must NOT be present in account ===
    assertNil(acct.projects,    "projects dropped")
    assertNil(acct.config,      "config dropped (fresh HDG defaults)")
    assertNil(acct.minimap,     "minimap dropped")
    assertNil(acct.mogul,       "mogul dropped")
    assertNil(acct.altsViewMode, "altsViewMode dropped")
    assertNil(acct.craftingQueue, "craftingQueue dropped (obsolete)")
    assertNil(acct.customStyles,  "customStyles dropped")
    assertNil(acct.customStyleOrder, "customStyleOrder dropped")
    assertNil(acct.migrations,   "migrations infra dropped")

    -- === SKIP assertions: caches must NOT be in account ===
    assertNil(acct.vendorNameCache,    "vendorNameCache skipped")
    assertNil(acct.vendorCurrencyCache, "vendorCurrencyCache skipped")
    assertNil(acct.directPriceCache,   "directPriceCache skipped")
    assertNil(acct.directPriceCacheTime, "directPriceCacheTime skipped")
    assertNil(acct.ownedAuctions,      "ownedAuctions skipped")

    io.write("    round-trip assertions done\n")
end

-- ============================================================================
-- TEST 2: Backup written exactly once, equals pre-migration snapshot
-- ============================================================================

do
    io.write("--- Test 2: backup written once and matches snapshot\n")

    _G.HDG_DB_preMigrationBackup = nil

    local rawDB = buildOldHDGDB()
    -- Capture expected snapshot BEFORE Run() mutates rawDB.
    local expectedFavoritesLen = 0
    for _ in pairs(rawDB.favorites) do expectedFavoritesLen = expectedFavoritesLen + 1 end

    HDG.Migration:Run(rawDB)

    -- Backup must exist now.
    assertType(_G.HDG_DB_preMigrationBackup, "table", "backup written")

    -- Backup should have the flat old-HDG keys (not the migrated account.*).
    assertEq(_G.HDG_DB_preMigrationBackup.schemaVersion, 2, "backup.schemaVersion = 2 (HDG)")
    assertType(_G.HDG_DB_preMigrationBackup.craftingHistory, "table", "backup has flat craftingHistory")
    assertEq(#_G.HDG_DB_preMigrationBackup.craftingHistory, 3, "backup craftingHistory count matches fixture")
    assertType(_G.HDG_DB_preMigrationBackup.favorites, "table", "backup has favorites")
    local bkFavLen = 0
    for _ in pairs(_G.HDG_DB_preMigrationBackup.favorites) do bkFavLen = bkFavLen + 1 end
    assertEq(bkFavLen, expectedFavoritesLen, "backup favorites count matches pre-migration")

    -- Run again: backup must NOT be overwritten (idempotency of backup).
    local backupRef = _G.HDG_DB_preMigrationBackup
    HDG.Migration:Run(rawDB)
    assertTrue(_G.HDG_DB_preMigrationBackup == backupRef, "backup ref unchanged on second run (not overwritten)")

    io.write("    backup assertions done\n")
end

-- ============================================================================
-- TEST 3: Idempotency — run twice, no double-append
-- ============================================================================

do
    io.write("--- Test 3: idempotency\n")

    _G.HDG_DB_preMigrationBackup = nil

    local rawDB = buildOldHDGDB()
    HDG.Migration:Run(rawDB)

    local afterFirst = {
        craftEntryCount  = #rawDB.account.craft.history.entries,
        craftNextID      = rawDB.account.craft.history.nextID,
        farmEntryCount   = #rawDB.account.lumber.history.entries,
        farmNextID       = rawDB.account.lumber.history.nextID,
        favCount         = (function() local n = 0; for _ in pairs(rawDB.account.favorites) do n=n+1 end; return n end)(),
        collectionsCount = (function() local n = 0; for _ in pairs(rawDB.account.collections) do n=n+1 end; return n end)(),
        schemaVersion    = rawDB.account.schemaVersion,
    }

    -- Second run.
    HDG.Migration:Run(rawDB)

    assertEq(rawDB.account.craft.history.nextID, afterFirst.craftNextID, "craft nextID unchanged on 2nd run")
    assertEq(#rawDB.account.craft.history.entries, afterFirst.craftEntryCount, "craft entries not doubled on 2nd run")
    assertEq(rawDB.account.lumber.history.nextID, afterFirst.farmNextID, "farming nextID unchanged on 2nd run")
    assertEq(#rawDB.account.lumber.history.entries, afterFirst.farmEntryCount, "farming entries not doubled on 2nd run")
    local favCount2 = 0; for _ in pairs(rawDB.account.favorites) do favCount2 = favCount2 + 1 end
    assertEq(favCount2, afterFirst.favCount, "favorites not duplicated on 2nd run")
    local collCount2 = 0; for _ in pairs(rawDB.account.collections) do collCount2 = collCount2 + 1 end
    assertEq(collCount2, afterFirst.collectionsCount, "collections not duplicated on 2nd run")
    assertEq(rawDB.account.schemaVersion, afterFirst.schemaVersion, "schemaVersion unchanged on 2nd run")

    io.write("    idempotency assertions done\n")
end

-- ============================================================================
-- TEST 4: Already-HDG DB — no-op (current HDG dev users untouched)
-- ============================================================================

do
    io.write("--- Test 4: already-HDG DB is a no-op\n")

    _G.HDG_DB_preMigrationBackup = nil

    local sv = HDG.Constants.SCHEMA_VERSION
    local alreadyHDG = {
        account = {
            schemaVersion = sv,
            favorites     = { [42] = true },
            craft         = { history = { entries = { { id = 1, eventType = "crafted", itemID = 999, qty = 1, timestamp = 5 } }, nextID = 1 } },
            userNotes     = { [7] = { text = "existing", ts = 1 } },
        },
    }

    HDG.Migration:Run(alreadyHDG)

    -- No-op: account untouched.
    assertEq(alreadyHDG.account.schemaVersion, sv, "already-HDG: schemaVersion preserved")
    assertEq(#alreadyHDG.account.craft.history.entries, 1, "already-HDG: history entries not touched")
    assertTrue(alreadyHDG.account.favorites[42] == true, "already-HDG: favorites preserved")
    assertEq(alreadyHDG.account.userNotes[7].text, "existing", "already-HDG: userNotes preserved")
    -- Backup must NOT be written (migration never ran).
    assertNil(_G.HDG_DB_preMigrationBackup, "already-HDG: no backup written")

    io.write("    already-HDG no-op assertions done\n")
end

-- ============================================================================
-- TEST 5: Adversarial — malformed / partial / empty HDG_DB
-- ============================================================================

do
    io.write("--- Test 5: adversarial inputs\n")

    -- 5a: nil input — must not crash.
    local ok = pcall(function() HDG.Migration:Run(nil) end)
    assertTrue(ok, "5a: nil input does not crash")

    -- 5b: empty table with no schema signals — not an old-HDG DB, no-op.
    _G.HDG_DB_preMigrationBackup = nil
    local emptyDB = {}
    HDG.Migration:Run(emptyDB)
    assertNil(emptyDB.account, "5b: empty table with no signals -> no account seeded")
    assertNil(_G.HDG_DB_preMigrationBackup, "5b: empty table -> no backup written")

    -- 5c: old-HDG signal (schemaVersion=2) but all data keys absent.
    _G.HDG_DB_preMigrationBackup = nil
    local partialDB = { schemaVersion = 2 }
    HDG.Migration:Run(partialDB)
    assertType(partialDB.account, "table", "5c: partial DB with schemaVersion=2 creates account")
    assertType(_G.HDG_DB_preMigrationBackup, "table", "5c: backup written for partial DB")
    -- Empty migrate targets produce empty tables / no crash.
    assertType(partialDB.account.collections, "table", "5c: collections seeded empty")
    assertNil(partialDB.account.craft, "5c: craft absent when no craftingHistory key")
    assertNil(partialDB.account.lumber, "5c: lumber absent when no farmingHistory key")

    -- 5d: craftingHistory with malformed entries mixed with valid entries.
    -- Note: nil entries are not placed at ipairs-stop positions (nil at [1]
    -- stops ipairs; this tests malformed non-nil entries interspersed with valid ones).
    _G.HDG_DB_preMigrationBackup = nil
    local mixedDB = {
        schemaVersion   = 2,
        craftingHistory = {
            { itemID = 77, quantity = 1, source = "Crafted", timestamp = 100 },  -- valid
            "not a table",                                 -- string entry: skipped
            { itemID = 88, quantity = 2, source = "Learned", timestamp = 200 },  -- valid
            {},                                            -- empty table (no itemID, no id): skipped
        },
    }
    HDG.Migration:Run(mixedDB)
    local entries = mixedDB.account.craft.history.entries
    assertEq(#entries, 2, "5d: only valid entries migrated from mixed craftingHistory")
    assertEq(entries[1].itemID, 77, "5d: first valid entry itemID")
    assertEq(entries[2].itemID, 88, "5d: second valid entry itemID")
    assertEq(entries[2].eventType, "learned", "5d: second entry eventType learned")

    -- 5e: farmingHistory with malformed entries.
    _G.HDG_DB_preMigrationBackup = nil
    local farmDB = {
        farmingHistory = {   -- `farmingHistory` present = old-HDG signal
            { timestamp = 1000, collected = 50, zone = "Twilight Highlands" },  -- valid
            "bad entry",                                                          -- invalid
            { sessionId = "x" },                                                  -- no timestamp -> still valid (has sessionId)
        },
    }
    HDG.Migration:Run(farmDB)
    local fentries = farmDB.account.lumber.history.entries
    assertEq(#fentries, 2, "5e: 2 valid farming entries (sessionId OR timestamp required)")

    -- 5f: characters with one malformed entry.
    _G.HDG_DB_preMigrationBackup = nil
    local charDB = {
        vendorNameCache = {},   -- old-HDG signal
        characters = {
            ["ValidChar-Realm"] = { professions = { Tailoring = { knownRecipes = { [1] = true } } }, lastUpdated = 5000 },
            [42] = { professions = {} },   -- numeric key: not a string charKey -> skip
            ["BadChar-Realm"] = "not a table",   -- string value: skip
        },
    }
    HDG.Migration:Run(charDB)
    local chars = charDB.account.characters
    assertType(chars["ValidChar-Realm"], "table", "5f: valid char migrated")
    assertNil(chars[42], "5f: numeric key char skipped")
    assertNil(chars["BadChar-Realm"], "5f: non-table char skipped")

    -- 5g: very large craftingHistory does not crash (cap is handled by EnsureStateShape;
    -- migration carries all without cap -- same as the HDG Store did).
    _G.HDG_DB_preMigrationBackup = nil
    local bigDB = { schemaVersion = 2, craftingHistory = {} }
    for i = 1, 600 do
        bigDB.craftingHistory[i] = { itemID = i, quantity = 1, source = "Crafted", timestamp = i * 100 }
    end
    local bigOk = pcall(function() HDG.Migration:Run(bigDB) end)
    assertTrue(bigOk, "5g: 600-entry craftingHistory does not crash")
    assertEq(#bigDB.account.craft.history.entries, 600, "5g: all 600 entries migrated (cap handled by EnsureStateShape)")

    io.write("    adversarial assertions done\n")
end

-- ============================================================================
-- TEST 6: crate collections are DROPPED (the crate feature never shipped in old
-- HDG); non-crate collections survive the transform.
-- ============================================================================

do
    io.write("--- Test 6: crate collections dropped; styles survive\n")

    _G.HDG_DB_preMigrationBackup = nil

    local crateDB = {
        schemaVersion = 2,
        collections = {
            ["crate:house:alliance:f1:rXX:c01"] = {
                id = "crate:house:alliance:f1:rXX:c01",
                type = "crate",
                parent = "house:alliance:f1:rXX",
                name  = "Test crate",
                members = { [10] = true },
            },
            ["lib_curated:foo"] = { id = "lib_curated:foo", type = "library", name = "Foo Lib", members = {} },
            ["lib_player1"]     = { id = "lib_player1", type = "library", name = "New crate", members = { [7] = true } },
            ["style:keep-1"] = { id = "style:keep-1", type = "style",
                displayName = "Keep", curatedItems = { [1] = true } },
        },
    }

    HDG.Migration:Run(crateDB)

    assertNil(crateDB.account.collections["crate:house:alliance:f1:rXX:c01"], "crate dropped")
    assertNil(crateDB.account.collections["lib_curated:foo"], "curated library dropped")
    assertNil(crateDB.account.collections["lib_player1"], "player-save library dropped")
    local kept = crateDB.account.collections["style:keep-1"]
    assertType(kept, "table", "non-crate style kept")
    assertEq(#kept.items, 1, "kept style transformed (curatedItems -> items)")

    io.write("    crate-drop assertions done\n")
end

-- ============================================================================
-- TEST 7: recentActivity — malformed or absent (various shapes)
-- ============================================================================

do
    io.write("--- Test 7: recentActivity migration variants\n")

    -- 7a: absent recentActivity key.
    _G.HDG_DB_preMigrationBackup = nil
    local noRA = { schemaVersion = 2 }
    HDG.Migration:Run(noRA)
    assertNil(noRA.account.recentActivity, "7a: absent recentActivity -> nil (EnsureStateShape seeds)")

    -- 7b: recentActivity is a non-standard table without .houses.
    _G.HDG_DB_preMigrationBackup = nil
    local badRA = { schemaVersion = 2, recentActivity = { sessionCap = 20 } }
    HDG.Migration:Run(badRA)
    assertNil(badRA.account.recentActivity, "7b: non-standard recentActivity (no .houses) -> nil")

    -- 7c: well-formed recentActivity.
    _G.HDG_DB_preMigrationBackup = nil
    local goodRA = {
        schemaVersion = 2,
        recentActivity = {
            houses = { ["house:horde"] = { sessionOrder = {}, sessions = {} } },
            lastHouseKey = "house:horde",
        },
    }
    HDG.Migration:Run(goodRA)
    assertType(goodRA.account.recentActivity, "table", "7c: well-formed recentActivity migrated")
    assertType(goodRA.account.recentActivity.houses, "table", "7c: houses present")
    assertEq(goodRA.account.recentActivity.lastHouseKey, "house:horde", "7c: lastHouseKey preserved")

    io.write("    recentActivity variant assertions done\n")
end

-- ============================================================================
-- TEST 8: v1 -> v2 re-run repairs an already-migrated (buggy) account in-place.
-- Reproduces the live bug: the buggy v1 transform DeepCopied professions verbatim
-- (left skillLevels + nil identity). A v2 re-run (account.schemaVersion < current)
-- must (a) rename skillLevels -> skillLines, (b) parse name/realm, and
-- (c) PRESERVE professions the live scanner already healed to skillLines.
-- ============================================================================

do
    io.write("--- Test 8: v1->v2 in-place repair (no clobber of scanner-fresh data)\n")

    _G.HDG_DB_preMigrationBackup = nil

    -- A DB that already has a v1-migrated account (schemaVersion = 1, the OLD value)
    -- plus the still-present flat source. account.characters carries the buggy shape
    -- for two chars, and one scanner-healed profession that must survive.
    local reDB = {
        schemaVersion = 2,  -- detection signal: still flagged old-HDG
        characters = {  -- flat source (immutable; migration reads it)
            ["Gray-Khaz'goroth"]    = { lastUpdated = 10, professions = {
                Jewelcrafting = { knownRecipes = { [1] = true }, skillLevels = { ["Midnight"] = { current = 5, max = 10 } } },
            } },
            ["Vamoose-Khaz'goroth"] = { lastUpdated = 20, professions = {
                Enchanting  = { knownRecipes = { [2] = true }, skillLevels = { ["Midnight"] = { current = 1, max = 2 } } },
                Engineering = { knownRecipes = { [3] = true }, skillLevels = { ["Midnight"] = { current = 3, max = 4 } } },
            } },
        },
        -- flat farming source for the in-place repair (still present post-v1).
        farmingHistory = {
            { sessionId = "s1", timestamp = 50000, duration = 600, collected = 9, zone = "Z", lumberId = 251762, character = "Vamoose", realm = "Khaz'goroth" },
        },
        account = {
            schemaVersion = 1,  -- the buggy v1 stamp -> 1 < 2 so detection must NOT no-op
            collections = {},   -- already migrated (must be left alone)
            -- buggy v1 farming entry: lumberID dropped, startedAt == flat timestamp.
            lumber = { history = { nextID = 1, entries = {
                { id = 1, lumberID = nil, startedAt = 50000, sessionTotal = 9, zone = "Z" },
            } } },
            characters = {
                -- buggy v1: name nil, professions still skillLevels
                ["Gray-Khaz'goroth"] = {
                    name = nil, realm = nil, class = nil, hidden = false, lastSeen = 10,
                    professions = { Jewelcrafting = { knownRecipes = { [1] = true }, skillLevels = { ["Midnight"] = { current = 5, max = 10 } } } },
                },
                -- partially scanner-healed: name set, Enchanting already skillLines (fresh,
                -- richer knownRecipes), Engineering still buggy skillLevels.
                ["Vamoose-Khaz'goroth"] = {
                    name = "Vamoose", realm = "Khaz'goroth", class = "Shaman", hidden = false, lastSeen = 20,
                    professions = {
                        Enchanting  = { knownRecipes = { [2] = true, [99] = true }, skillLines  = { ["Midnight"] = { current = 1, max = 2 } } },
                        Engineering = { knownRecipes = { [3] = true },             skillLevels = { ["Midnight"] = { current = 3, max = 4 } } },
                    },
                },
            },
        },
    }

    HDG.Migration:Run(reDB)

    assertEq(reDB.account.schemaVersion, HDG.Constants.SCHEMA_VERSION, "8: re-run stamps current schemaVersion")

    -- Gray: identity parsed, skillLevels -> skillLines, knownRecipes intact.
    local g = reDB.account.characters["Gray-Khaz'goroth"]
    assertEq(g.name, "Gray", "8: Gray name parsed on repair")
    assertEq(g.realm, "Khaz'goroth", "8: Gray realm parsed on repair")
    assertType(g.professions.Jewelcrafting.skillLines, "table", "8: Gray skillLevels renamed to skillLines")
    assertNil(g.professions.Jewelcrafting.skillLevels, "8: Gray old skillLevels dropped")
    assertTrue(g.professions.Jewelcrafting.knownRecipes[1] == true, "8: Gray knownRecipes preserved")

    -- Vamoose: Enchanting (scanner-fresh skillLines) NOT clobbered; Engineering repaired.
    local v = reDB.account.characters["Vamoose-Khaz'goroth"]
    assertEq(v.name, "Vamoose", "8: Vamoose name left as-is (not re-parsed)")
    assertTrue(v.professions.Enchanting.knownRecipes[99] == true, "8: Vamoose scanner-fresh Enchanting recipes preserved")
    assertType(v.professions.Engineering.skillLines, "table", "8: Vamoose Engineering repaired to skillLines")
    assertNil(v.professions.Engineering.skillLevels, "8: Vamoose Engineering skillLevels dropped")

    -- Farming: v1 entry backfilled in-place from flat (matched by timestamp).
    local fe = reDB.account.lumber.history.entries[1]
    assertEq(fe.lumberID, 251762, "8: farming lumberID backfilled on repair")
    assertEq(fe.character, "Vamoose", "8: farming character backfilled")
    assertEq(fe.finalizedAt, 50600, "8: farming finalizedAt rebuilt from duration")
    assertEq(#reDB.account.lumber.history.entries, 1, "8: farming entries not doubled on repair")

    io.write("    v1->v2 repair assertions done\n")
end

-- ============================================================================
-- TEST 9: customStyles rescue -- styles living ONLY in the legacy customStyles
-- store are migrated; ones already in collections are NOT duplicated. (P4 BUG-1)
-- ============================================================================
do
    io.write("--- Test 9: customStyles orphan rescue (no dup)\n")
    _G.HDG_DB_preMigrationBackup = nil
    local db = {
        schemaVersion = 2,
        collections = {
            ["style:custom-dup"] = { id="style:custom-dup", type="style", displayName="In Both", curatedItems={[1]=true} },
        },
        customStyles = {
            ["custom-dup"]    = { displayName="In Both (stale)", curatedItems={[1]=true,[2]=true} },  -- already in collections -> skip
            ["custom-orphan"] = { displayName="Orphan", curatedItems={[9]=true,[8]=true} },           -- only here -> rescue
            ["custom-qonly"]  = { displayName="Query Orphan", query={ subject={[1]="letter"} } },     -- query-only orphan -> smartset
        },
    }
    HDG.Migration:Run(db)
    assertType(db.account.collections["style:custom-dup"], "table", "9: dup style kept")
    assertEq(#db.account.collections["style:custom-dup"].items, 1, "9: dup uses collections copy, not stale customStyles")
    assertNil(db.account.collections["custom-dup"], "9: no bare-key dup created")
    local orph = db.account.collections["style:custom-orphan"]
    assertType(orph, "table", "9: curated orphan rescued")
    assertEq(orph.type, "style", "9: orphan type=style")
    assertEq(#orph.items, 2, "9: orphan items from curatedItems")
    local qo = db.account.collections["smartset:custom-qonly"]
    assertType(qo, "table", "9: query orphan rescued as smartset")
    assertEq(qo.type, "smartset", "9: query orphan type=smartset")
    io.write("    customStyles rescue assertions done\n")
end

-- ============================================================================
-- TEST 10: a curated style that ALSO has a query drops the vestigial query so it
-- doesn't mis-export as a smartset. (P4 BUG-3)
-- ============================================================================
do
    io.write("--- Test 10: stale query cleared on curated+query style\n")
    _G.HDG_DB_preMigrationBackup = nil
    local db = {
        schemaVersion = 2,
        collections = {
            ["style:custom-both"] = { id="style:custom-both", type="style", displayName="Both",
                curatedItems={[1]=true,[2]=true}, query={ subject={[1]="letter"} }, _querySets={ {} } },
        },
    }
    HDG.Migration:Run(db)
    local c = db.account.collections["style:custom-both"]
    assertEq(c.type, "style", "10: stays style (curatedItems present)")
    assertEq(#c.items, 2, "10: items from curatedItems")
    assertNil(c.query, "10: vestigial query cleared")
    assertNil(c._querySets, "10: vestigial _querySets cleared")
    io.write("    stale-query assertions done\n")
end

-- ============================================================================
-- TEST 11: re-run does NOT clobber shopping-list edits made post-migration.
-- ============================================================================
do
    io.write("--- Test 11: shopping list re-run preserves post-migration edits\n")
    _G.HDG_DB_preMigrationBackup = nil
    local db = {
        schemaVersion = 2,
        vendorShoppingLists = { ["abc"] = { name="L", items={{itemID=10,qty=1}}, meta={}, createdAt=1 } },
        activeShoppingListId = "abc",
    }
    HDG.Migration:Run(db)
    table.insert(db.account.vendorShoppingLists["abc"].items, { itemID=20, qty=5 })
    db.account.vendorShoppingLists["abc"].name = "L edited"
    db.account.schemaVersion = 5   -- simulate a SCHEMA_VERSION bump re-run
    HDG.Migration:Run(db)
    local list = db.account.vendorShoppingLists["abc"]
    assertEq(#list.items, 2, "11: post-migration item NOT clobbered on re-run")
    assertEq(list.name, "L edited", "11: post-migration rename NOT clobbered")
    io.write("    shopping re-run assertions done\n")
end

-- ============================================================================
-- TEST 12: M:Run return value drives the one-time upgrade notice -- a result
-- table (with hadProjects) on migrate, false on a no-op.
-- ============================================================================
do
    io.write("--- Test 12: M:Run return value (migrate vs no-op)\n")
    _G.HDG_DB_preMigrationBackup = nil
    local db = { schemaVersion = 2 }
    assertEq(HDG.Migration:Run(db), true, "12: migrate returns true")
    local db2 = { account = { schemaVersion = HDG.Constants.SCHEMA_VERSION } }
    assertEq(HDG.Migration:Run(db2), false, "12: no-op returns false")
    assertEq(HDG.Migration:Run(nil), false, "12: nil input returns false")
    io.write("    return-value assertions done\n")
end

-- ============================================================================
-- TEST 13: edge cases -- empty snapshot, no-dash charKey, random shopping IDs.
-- ============================================================================
do
    io.write("--- Test 13: edge cases\n")
    _G.HDG_DB_preMigrationBackup = nil
    local db = {
        schemaVersion = 2,
        collections = {
            ["style:snapshot-empty"] = { id="style:snapshot-empty", type="style", displayName="Empty",
                snapshot = { capturedAt=12345, items={}, totalPlacementCost=0 } },
        },
        characters = { ["NoDash"] = { professions={}, lastUpdated=100 } },
        vendorShoppingLists = { ["rnd9x"] = { name="Random", items={}, meta={}, createdAt=1 } },
    }
    HDG.Migration:Run(db)
    local sn = db.account.collections["snapshot:empty"]
    assertType(sn, "table", "13: empty snapshot preserved")
    assertEq(#sn.items, 0, "13: empty snapshot has 0 items")
    assertEq(sn.type, "snapshot", "13: empty snapshot type=snapshot")
    local ch = db.account.characters["NoDash"]
    assertType(ch, "table", "13: no-dash char stored without crash")
    assertNil(ch.name, "13: no-dash char name nil (parse returns nil)")
    assertType(db.account.vendorShoppingLists["rnd9x"], "table", "13: random-id shopping list preserved")
    assertEq(db.account.shoppingListSeq, 0, "13: seq=0 for non-L-format ids")
    io.write("    edge-case assertions done\n")
end

-- ============================================================================
-- Final verdict
-- ============================================================================

if failures > 0 then
    io.write("\nFAILED: " .. failures .. " assertion(s) above\n")
    os.exit(1)
end

io.write("\ntest_hdg_migration.lua passed\n")
