-- M12 link compatibility: fingerprint determinism, the v2 handshake and its
-- verdicts, the negotiated trade subset, the extra bag, ppUps on the wire,
-- and desync attribution over a loopback lockstep battle.  Self-contained
-- like the other mod suites: own bootstrap, assert-based checks, error() on
-- failure.  Chained from tests/run_link_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")

local Data = require("src.core.Data")
if not Data.maps then Data:load() end
require("src.render.Font").load(Data)

local Events = require("src.mods.Events")
local Fingerprint = require("src.link.Fingerprint")
local Handshake = require("src.link.Handshake")
local Hooks = require("src.mods.Hooks")
local Input = require("src.core.Input")
local Json = require("src.link.Json")
local LinkBattle = require("src.link.LinkBattle")
local Net = require("src.link.Net")
local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Session = require("src.link.Session")

local S = require("tests.harness").suite("mod link")
local check, eq = S.check, S.eq

-- a message as the peer sees it: through the same encoder the wire uses
local function wire(msg)
  return Json.decode(Json.encode(msg))
end

local function copy(record)
  local out = {}
  for k, v in pairs(record) do out[k] = v end
  return out
end

-- a merged-data view with its own id maps, so a fixture can retune a record
-- without touching the shared catalog
local function cloneData(base)
  local out = { pokemon = {}, moves = {}, type_chart = base.type_chart,
                constants = base.constants }
  for id, record in pairs(base.pokemon) do out.pokemon[id] = record end
  for id, record in pairs(base.moves) do out.moves[id] = record end
  return out
end

local function fakeGame(data, name)
  return { data = data, save = { player = { name = name } } }
end

-- ------- fingerprint determinism

local vanilla = cloneData(Data)
local first = Fingerprint.compute(vanilla, {})
eq(Fingerprint.compute(vanilla, {}), first, "fingerprint is stable across calls")
eq(#first, 16, "fingerprint is a 64-bit hex digest")

-- the same records reached through a differently built map: the digest walks
-- a sorted id list, so table layout can never leak into it
local reordered = { pokemon = {}, moves = {}, type_chart = Data.type_chart,
                    constants = Data.constants }
local ids = {}
for id in pairs(Data.pokemon) do ids[#ids + 1] = id end
table.sort(ids)
for i = #ids, 1, -1 do reordered.pokemon[ids[i]] = Data.pokemon[ids[i]] end
local moveIds = {}
for id in pairs(Data.moves) do moveIds[#moveIds + 1] = id end
table.sort(moveIds)
for i = #moveIds, 1, -1 do reordered.moves[moveIds[i]] = Data.moves[moveIds[i]] end
eq(Fingerprint.surface(reordered, {}), Fingerprint.surface(vanilla, {}),
   "insertion order does not reach the canonical stream")
eq(Fingerprint.compute(reordered, {}), first, "reordered data fingerprints the same")

-- a record rebuilt with its subtable keys in another order still hashes the
-- same, and a stat edit does not
local restated = cloneData(Data)
local pidgey = copy(Data.pokemon.PIDGEY)
pidgey.baseStats = { special = Data.pokemon.PIDGEY.baseStats.special,
                     speed = Data.pokemon.PIDGEY.baseStats.speed,
                     defense = Data.pokemon.PIDGEY.baseStats.defense,
                     attack = Data.pokemon.PIDGEY.baseStats.attack,
                     hp = Data.pokemon.PIDGEY.baseStats.hp }
restated.pokemon.PIDGEY = pidgey
eq(Fingerprint.compute(restated, {}), first, "subtable key order is irrelevant")

local buffed = cloneData(Data)
local strongPidgey = copy(Data.pokemon.PIDGEY)
strongPidgey.baseStats = copy(Data.pokemon.PIDGEY.baseStats)
strongPidgey.baseStats.attack = strongPidgey.baseStats.attack + 1
buffed.pokemon.PIDGEY = strongPidgey
check(Fingerprint.compute(buffed, {}) ~= first, "a baseStats edit moves the digest")

-- ------- path independence (excluded fields)

local repathed = cloneData(Data)
local movedSprites = copy(Data.pokemon.PIDGEY)
movedSprites.spriteFront = "assets/generated/other/machine/pidgey.png"
movedSprites.spriteBack = "assets/generated/other/machine/pidgeyb.png"
movedSprites.source = "ROM:BaseStats[999]"
movedSprites.dexEntry = { kind = "TINY BIRD", heightFt = 1, heightIn = 0,
                          weight = 4.0, text = "different flavour" }
movedSprites.learnset = {}
repathed.pokemon.PIDGEY = movedSprites
eq(Fingerprint.compute(repathed, {}), first,
   "sprite paths, source, dex entry and learnset stay out of the digest")

local retuned = cloneData(Data)
local strongTackle = copy(Data.moves.TACKLE)
strongTackle.power = 60
retuned.moves.TACKLE = strongTackle
check(Fingerprint.compute(retuned, {}) ~= first, "a move power edit moves the digest")

-- the affects-link mod set is folded in, so a logic-only change that ships
-- as a new version moves the digest even with identical records
local withMod = { { id = "rijon", version = "1.2.0", affectsLink = true } }
check(Fingerprint.compute(vanilla, withMod) ~= first, "affects-link mods fold in")
Fingerprint.forget(vanilla)
eq(Fingerprint.compute(vanilla, { { id = "rijon", version = "1.2.0",
                                    affectsLink = false } }), first,
   "a mod that declares it stays link-compatible does not")

-- the hook lets a total conversion widen or narrow the surface
local hooks = Hooks.new()
local events = Events.new()
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
Runtime.install(events, hooks, {})
hooks:wrap("link.fingerprint", function(nxt, data, mods)
  return "ff" .. nxt(data, mods):sub(3)
end, 0, "test")
Fingerprint.forget(vanilla)
eq(Fingerprint.compute(vanilla, {}):sub(1, 2), "ff", "link.fingerprint hook applies")
hooks:removeOwner("test")
Fingerprint.forget(vanilla)
eq(Fingerprint.compute(vanilla, {}), first, "unwrapping restores the vanilla digest")

-- ------- linkModified: the cheap answer to "can I link with an old peer?"

-- the loader surface the discovery walk needs, backed by a path->text table
local function memfs(files)
  return {
    read = function(path) return files[path] end,
    getInfo = function(path)
      if files[path] then return { type = "file" } end
      local prefix = path .. "/"
      for key in pairs(files) do
        if key:sub(1, #prefix) == prefix then return { type = "directory" } end
      end
      return nil
    end,
    load = function(path)
      if not files[path] then return nil, "no file: " .. path end
      return load(files[path], path)
    end,
    getDirectoryItems = function(path)
      local seen, items = {}, {}
      local prefix = path .. "/"
      for key in pairs(files) do
        if key:sub(1, #prefix) == prefix then
          local child = key:sub(#prefix + 1):match("^[^/]+")
          if child and not seen[child] then
            seen[child] = true
            items[#items + 1] = child
          end
        end
      end
      table.sort(items)
      return items
    end,
  }
end

local pika = { name = "PIKA", baseStats = { hp = 1, attack = 1, defense = 1,
               speed = 1, special = 1 }, types = { "NORMAL" }, catchRate = 1,
               baseExp = 1, growthRate = "MEDIUM_FAST" }

local function loadMods(files)
  local data = { pokemon = { PIKA = pika }, moves = {}, audio = {} }
  local loader = require("src.mods.Loader").new({ fs = memfs(files) })
  loader:load(data)
  -- the loader claims the process-wide buses on load; this suite wants its own
  Runtime.install(events, hooks, {})
  return { mods = loader, data = data, save = { player = { name = "RED" } } }
end

local bareGame = loadMods({})
eq(Handshake.linkModified(bareGame), false, "no mods means an unmodified link")
eq(#Handshake.mods(bareGame), 0, "and an empty mod array on the wire")

local tweakGame = loadMods({
  ["mods/tweak/manifest.json"] =
    '{"id":"tweak","name":"tweak","version":"1.0.0","entry":"main.lua"}',
  ["mods/tweak/main.lua"] = [[
return function(mod)
  mod.content.pokemon:patch("PIKA", { catchRate = 40 })
end
]],
})
eq(Handshake.linkModified(tweakGame), true,
   "a content mod writing a link-surface record modifies the link")
eq(Handshake.mods(tweakGame)[1].affectsLink, false,
   "but a content pack does not claim the fingerprint by itself")

local overhaulGame = loadMods({
  ["mods/rijon/manifest.json"] =
    '{"id":"rijon","name":"rijon","version":"1.2.0","entry":"main.lua","profile":"overhaul"}',
  ["mods/rijon/main.lua"] = "return function(mod) end",
})
eq(Handshake.linkModified(overhaulGame), true, "an overhaul modifies the link")
eq(Handshake.mods(overhaulGame)[1].affectsLink, true, "and rides the hello")
eq(Handshake.hello(tweakGame, "trade").linkModified, true,
   "the hello carries the flag a v1 peer is judged against")

-- #501: a declared translation is invisible to the wire, so online play
-- lets an English install meet a Spanish one
local languageGame = loadMods({
  ["mods/espanol/manifest.json"] =
    '{"id":"espanol","name":"espanol","version":"1.0.0","entry":"main.lua",' ..
    '"language":true,"category":"LANGUAGE"}',
  ["mods/espanol/main.lua"] = [[
return function(mod)
  mod.content.strings:override("But, it failed!", "Pero fallo!")
end
]],
})
eq(Handshake.mods(languageGame)[1].language, true,
   "the translation flag rides the hello")
eq(Handshake.mods(languageGame)[1].affectsLink, false,
   "and a translation never claims the fingerprint")
eq(Handshake.linkModified(languageGame), false,
   "it leaves the link surface alone")
eq(Handshake.onlineAllowed(languageGame), true, "so online play allows it")
eq(Fingerprint.compute(languageGame.data, Handshake.mods(languageGame)),
   Fingerprint.compute(languageGame.data, {}),
   "and two peers reading different languages hash the same")
eq(Handshake.onlineAllowed(tweakGame), false,
   "a content mod that touches a species still forces vanilla")
eq(Handshake.onlineAllowed(bareGame), true, "vanilla still goes online")

-- the flag is a claim, not a pass: a mod that writes gameplay is not a
-- translation however its manifest describes itself
local fakeLanguageGame = loadMods({
  ["mods/faux/manifest.json"] =
    '{"id":"faux","name":"faux","version":"1.0.0","entry":"main.lua","language":true}',
  ["mods/faux/main.lua"] = [[
return function(mod)
  mod.content.strings:override("But, it failed!", "It worked!")
  mod.content.pokemon:patch("PIKA", { baseStats = { attack = 200 } })
end
]],
})
eq(Handshake.onlineAllowed(fakeLanguageGame), false,
   "a self-declared translation that patches a species is still blocked")
eq(#Handshake.onlineBlockers(fakeLanguageGame), 1,
   "and the mod manager restart prompt names it")

-- ------- builtin records are private per dataset

-- two independent loads must not share record tables: an edit through one
-- dataset (hot reload, a suite loading twice) must never reach the other,
-- nor the module statics the engine falls back on when it has no loader
local isoA, isoB = loadMods({}), loadMods({})
check(not rawequal(isoA.data.type_chart.types.NORMAL,
                   isoB.data.type_chart.types.NORMAL),
      "builtin type records are private per dataset")
check(not rawequal(isoA.data.type_chart.types.NORMAL,
                   require("src.battle.TypeChart").TYPES.NORMAL),
      "and are not the module's own table")
eq(isoA.data.type_chart.types.NORMAL.category, "physical",
   "the copy still carries the vanilla value")
isoA.data.type_chart.types.NORMAL.category = "special"
eq(isoB.data.type_chart.types.NORMAL.category, "physical",
   "an edit through one dataset stays in it")
check(not rawequal(isoA.data.statuses.BRN, isoB.data.statuses.BRN),
      "status records are private per dataset")
check(rawequal(isoA.data.statuses.BRN.residual, isoB.data.statuses.BRN.residual),
      "handler functions ride the copy by reference")

-- ------- link_fields: a declared extra field that forces agreement

local plainGame = loadMods({})
eq(plainGame.data.link_fields, nil,
   "an unregistered link_fields namespace never reaches Data")
local plainPrint = Fingerprint.compute(plainGame.data, {})

-- the held-item mod from the design, registered verbatim
local heldGame = loadMods({
  ["mods/held_items/manifest.json"] =
    '{"id":"held_items","name":"held items","version":"1.0.0","entry":"main.lua",'
    .. '"api":2,"affects_link":true}',
  ["mods/held_items/main.lua"] = [[
return function(mod)
  mod.content.link_fields:register("held_item", {
    rev = 1,
    pack = function(mon) return mon.heldItem end,
    unpack = function(mon, v) mon.heldItem = v end,
  })
end
]],
})
eq(#heldGame.mods.errors, 0, "a link_fields registration loads clean")
check(heldGame.mods.content.link_fields ~= nil,
      "the loader builds content.link_fields from the catalog")
eq(heldGame.data.link_fields.held_item.rev, 1, "and the merge lands the record")
eq(type(heldGame.data.link_fields.held_item.pack), "function",
   "the codec rides along for the wire")
eq(Handshake.linkModified(heldGame), true, "a declared field modifies the link")
local heldPrint = Fingerprint.compute(heldGame.data, {})
check(heldPrint ~= plainPrint, "and moves the fingerprint off vanilla")

-- rev is what an author bumps when the codec's meaning changes, so a peer
-- on the old revision lands in subset instead of desyncing mid-battle
heldGame.data.link_fields.held_item.rev = 2
Fingerprint.forget(heldGame.data)
check(Fingerprint.compute(heldGame.data, {}) ~= heldPrint, "bumping rev moves it again")

-- two mods can ship different bodies under one rev, so the digest must not
-- pretend it can see them
heldGame.data.link_fields.held_item.rev = 1
heldGame.data.link_fields.held_item.pack = function() return nil end
Fingerprint.forget(heldGame.data)
eq(Fingerprint.compute(heldGame.data, {}), heldPrint,
   "swapping the codec body alone does not")

local revlessGame = loadMods({
  ["mods/revless/manifest.json"] =
    '{"id":"revless","name":"revless","version":"1.0.0","entry":"main.lua","api":2}',
  ["mods/revless/main.lua"] = [[
return function(mod)
  mod.content.link_fields:register("held_item", { pack = function() end })
end
]],
})
check(table.concat(revlessGame.mods.errors, "\n")
        :find("link_fields.held_item", 1, true) ~= nil,
      "a field with no rev is rejected by name")
eq(revlessGame.data.link_fields, nil, "and leaves no residue")

-- ------- handshake verdicts

local helloA = Handshake.hello(fakeGame(vanilla, "RED"), "trade")
local helloB = Handshake.hello(fakeGame(cloneData(Data), "BLUE"), nil)
eq(helloA.protocol, 2, "hello announces the protocol revision")
eq(helloA.apiVersion, require("src.core.Version").modApi, "hello carries the api version")
eq(helloA.linkModified, false, "no mods means an unmodified link surface")
eq(helloA.fingerprint, helloB.fingerprint, "identical data fingerprints alike")
eq(Handshake.checkCompat(helloA, wire(helloB)), "full", "matching peers get full")
check(Handshake.battleAllowed("full"), "full allows lockstep")
check(Handshake.strict("full"), "full negotiates strictly")

-- side B adds a species and retunes a move: same engine, different surface
local modded = cloneData(Data)
local zorua = copy(Data.pokemon.RATTATA)
zorua.id, zorua.name, zorua.dex = "ZORUA", "ZORUA", 152
modded.pokemon.ZORUA = zorua
local moddedTackle = copy(Data.moves.TACKLE)
moddedTackle.power = 60
modded.moves.TACKLE = moddedTackle
local helloMod = Handshake.hello(fakeGame(modded, "BLUE"), nil)
helloMod.mods = { { id = "rijon", version = "1.2.0", affectsLink = true } }
eq(Handshake.checkCompat(helloA, wire(helloMod)), "subset",
   "differing fingerprints negotiate a subset")
check(not Handshake.battleAllowed("subset"), "subset refuses lockstep")
check(Handshake.tradeAllowed("subset"), "subset still trades")

-- v1 interop: no protocol field at all
eq(Handshake.checkCompat(helloA, { type = "hello", name = "OLD", mode = "trade" }),
   "vanilla_peer", "a v1 peer is compatible with an unmodified game")
local modifiedLocal = { linkModified = true, engineVersion = helloA.engineVersion,
                        fingerprint = "deadbeefdeadbeef", mods = {} }
eq(Handshake.checkCompat(modifiedLocal, { type = "hello", name = "OLD" }),
   "refused", "a v1 peer is refused by a link-modified game")
check(not Handshake.strict(nil), "no verdict keeps the v1 unpack path")

-- a different engine major is refused outright
local nextEngine = Handshake.hello(fakeGame(vanilla, "BLUE"), nil)
nextEngine.engineVersion = "2.0.0"
eq(Handshake.checkCompat(helloA, nextEngine), "refused", "engine major mismatch refuses")

-- same major, different release: the fingerprint can't see engine code,
-- and battle logic changes between releases, so lockstep would desync a
-- few turns in (#758) -- battle is refused up front, trade still works
local skewed = Handshake.hello(fakeGame(vanilla, "BLUE"), nil)
skewed.engineVersion = (tostring(helloA.engineVersion):match("^(%d+)") or "0") .. ".999.0"
local skewVerdict, skewReason = Handshake.checkCompat(helloA, wire(skewed))
eq(skewVerdict, "engine_skew", "same-major release skew is its own verdict")
eq(skewReason, "engine_release_mismatch", "and says why")
check(not Handshake.battleAllowed("engine_skew"), "release skew refuses lockstep")
check(Handshake.tradeAllowed("engine_skew"), "release skew still trades")
check(Handshake.strict("engine_skew"), "release skew negotiates strictly")
local skewLines = Handshake.describe(helloA, wire(skewed), "engine_skew", "battle")
local skewJoined = table.concat(skewLines, " ")
check(skewJoined:find("version", 1, true) ~= nil, "skew notice mentions versions")
check(skewJoined:find("999", 1, true) ~= nil, "skew notice names the peer release")
for _, line in ipairs(skewLines) do
  check(#line <= 20, "skew line fits the screen: " .. line)
end

local lines = Handshake.describe(helloA, wire(helloMod), "subset", "battle")
check(#lines > 0, "the incompatibility screen has something to say")
local joined = table.concat(lines, " ")
check(joined:find("RIJON", 1, true) ~= nil, "the report names the missing mod")
check(joined:find("battle", 1, true) ~= nil, "the report says battle is unavailable")
for _, line in ipairs(lines) do
  check(#line <= 20, "report line fits the screen: " .. line)
end
local refusedLines = Handshake.describe(modifiedLocal,
  { type = "hello", name = "OLD" }, "refused", "trade")
check(#refusedLines > 0, "a refusal explains itself too")
for _, line in ipairs(refusedLines) do
  check(#line <= 20, "refusal line fits the screen: " .. line)
end

-- ------- ppUps and the extra bag on the wire

local ppMon = Pokemon.new(Data, "PIDGEY", 20)
local base = Data.moves[ppMon.moves[1].id].pp
ppMon.moves[1].ppUps = 3
ppMon.moves[1].pp = base + 3 * math.floor(base / 5)
ppMon.extra = { held_items = { held_item = "LEFTOVERS", count = 2, on = true },
                bogus = print }
local packedPP = wire(Protocol.packMon(ppMon))
eq(packedPP.moves[1].ppUps, 3, "ppUps reaches the wire")
local restored = Protocol.unpackMon(Data, packedPP, { strict = true })
eq(restored.moves[1].ppUps, 3, "ppUps survives the round trip")
eq(restored.moves[1].pp, base + 3 * math.floor(base / 5),
   "PP clamps to the PP-Up-adjusted maximum, not base PP")
eq(restored.extra.held_items.held_item, "LEFTOVERS", "the extra bag round-trips")
eq(restored.extra.held_items.count, 2, "extra numbers round-trip")
eq(restored.extra.held_items.on, true, "extra booleans round-trip")
eq(restored.extra.bogus, nil, "a function in the extra bag is stripped")

local plainMon = Pokemon.new(Data, "PIDGEY", 20)
local packedPlain = Protocol.packMon(plainMon)
eq(packedPlain.moves[1].ppUps, nil, "a mon without PP Ups sends no ppUps key")
eq(packedPlain.extra, nil, "a mon without extra data sends no bag")
eq(Protocol.unpackMon(Data, wire(packedPlain)).moves[1].ppUps, nil,
   "an absent ppUps stays absent")

-- ------- negotiated rejection replaces the silent fallbacks

local orphan = { species = "PIDGEY", level = 20,
                 moves = { { id = "NOT_A_MOVE", pp = 10 } } }
local rebuilt = Protocol.unpackMon(Data, orphan)
eq(rebuilt.moves[1].id, "TACKLE", "the v1 path keeps the TACKLE substitute")
local rejected, why = Protocol.unpackMon(Data, orphan, { strict = true })
eq(rejected, nil, "strict mode rejects a mon with no shared moves")
eq(why, "no shared moves", "and says why")
local unknown, unknownWhy = Protocol.unpackMon(Data,
  { species = "ZORUA", level = 20, moves = {} }, { strict = true })
eq(unknown, nil, "strict mode rejects an unknown species")
check(unknownWhy ~= nil, "an unknown species is reported")

-- ------- subset trade: only mons both games rebuild identically

local partyVanilla = { Pokemon.new(Data, "PIDGEY", 12),
                       Pokemon.new(Data, "RATTATA", 12) }
partyVanilla[1].moves = { { id = "GUST", pp = Data.moves.GUST.pp } }
partyVanilla[2].moves = { { id = "TACKLE", pp = Data.moves.TACKLE.pp } }
local partyModded = { Pokemon.new(modded, "ZORUA", 12),
                      Pokemon.new(modded, "RATTATA", 12),
                      Pokemon.new(modded, "PIDGEY", 12) }
partyModded[1].moves = { { id = "GUST", pp = modded.moves.GUST.pp } }
partyModded[2].moves = { { id = "TACKLE", pp = modded.moves.TACKLE.pp } }
partyModded[3].moves = { { id = "GUST", pp = modded.moves.GUST.pp } }

local sessionA = Protocol.TradeSession.new(Data, partyVanilla,
  { subset = true, strict = true, peerName = "BLUE" })
local sessionB = Protocol.TradeSession.new(modded, partyModded,
  { subset = true, strict = true, peerName = "RED" })
local recordsA, recordsB = wire(sessionA:opening()), wire(sessionB:opening())
eq(recordsA.type, "records", "a subset trade opens with the record hashes")
local partyMsgA = wire(sessionA:handle(recordsB))
local partyMsgB = wire(sessionB:handle(recordsA))
eq(#partyMsgA.mons, 1, "only the agreed mons leave the vanilla game")
eq(#partyMsgB.mons, 1, "only the agreed mons leave the modded game")
eq(partyMsgA.mons[1].species, "PIDGEY", "the clean PIDGEY is eligible")
eq(partyMsgB.mons[1].species, "PIDGEY", "the modded game sends its clean PIDGEY")
check(sessionA:canPick(1), "a mon with shared data can be picked")
check(not sessionA:canPick(2), "a mon knowing a retuned move cannot")
check(sessionA.reasons[2] ~= nil, "the ineligible mon carries a reason")
check(not sessionB:canPick(1), "a species the other game lacks cannot be picked")
eq(sessionB.reasons[1], "not on the other game", "and says so")

sessionA:handle(partyMsgB)
sessionB:handle(partyMsgA)
eq(sessionA.stage, "picking", "both parties arrived")
local pickA = wire(sessionA:pick(1))    -- real slot 1
local pickB = wire(sessionB:pick(3))    -- real slot 3, wire slot 1
eq(pickB.index, 1, "the wire index is a position in the filtered list")
sessionA:handle(pickB)
sessionB:handle(pickA)
eq(sessionA.stage, "confirming", "both picks landed")
sessionA:handle(wire(sessionB:confirm(true)))
sessionB:handle(wire(sessionA:confirm(true)))
eq(sessionA.stage, "done", "the subset trade completes")
local received = sessionA:apply(nil)
eq(received.species, "PIDGEY", "the vanilla game received the agreed mon")
eq(partyVanilla[1], received, "and it landed in the slot that was given")

-- a full-verdict session sends the whole party and indexes it directly
local fullSession = Protocol.TradeSession.new(Data, partyVanilla, { strict = true })
eq(fullSession.stage, "waitParty", "a full session skips the record exchange")
eq(#fullSession:opening().mons, 2, "a full session sends the whole party")
eq(fullSession:wireIndex(2), 2, "and its wire indices are party slots")

-- ------- the state machine: hello promoted to pairing, verdict branch

local LinkState = require("src.link.LinkState")

local function mkInput()
  local stub = { pressed = {} }
  function stub:wasPressed(key) return self.pressed[key] == true end
  return stub
end

local function linkGame(name, species, data)
  local save = require("src.core.SaveData").newGame()
  save.player.name = name
  table.insert(save.party, Pokemon.new(Data, species, 20))
  local stack = { list = {} }
  function stack:push(state, ...)
    table.insert(self.list, state)
    if state.enter then state:enter(...) end
  end
  function stack:pop() return table.remove(self.list) end
  function stack:top() return self.list[#self.list] end
  return { data = data or Data, save = save, stack = stack, input = mkInput() }
end

-- LinkState talks to a Session, not to a raw transport (src/link/LinkState.lua
-- :75 wraps every backend the same way), so a loopback end has to be wrapped
-- here too or :update reaches for a getStatus the transport does not have.
local function linkSession(transport, role)
  return Session.new(transport, { role = role, kind = "link" })
end

-- two paired states, host already listening and guest already dialling
local function pairStates(gameA, gameB)
  local netA, netB = Net.loopbackPair()
  local host, guest = LinkState.new(gameA), LinkState.new(gameB)
  host.net = linkSession(netA, "host")
  guest.net = linkSession(netB, "guest")
  host.stage, guest.stage = "hosting", "joining"
  gameA.stack:push(host)
  gameB.stack:push(guest)
  return host, guest
end

local function pump(a, b, gameA, gameB, times)
  for _ = 1, (times or 1) do
    a:update(1 / 60)
    b:update(1 / 60)
    gameA.input.pressed = {}
    gameB.input.pressed = {}
  end
end

local gameHost, gameGuest = linkGame("RED", "PIDGEY"), linkGame("BLUE", "RATTATA")
local host, guest = pairStates(gameHost, gameGuest)
pump(host, guest, gameHost, gameGuest, 2)
eq(host.stage, "modeSelect", "the host reaches mode select")
eq(guest.stage, "waitMode", "the guest waits for the mode")
check(guest.myHello ~= nil and guest.myHello.protocol == 2,
      "the guest announces itself the moment it pairs")
check(host.peerHello ~= nil, "the host has the peer hello before it picks")

gameHost.input.pressed = { a = true }
host:update(1 / 60)
gameHost.input.pressed = {}
eq(host.verdict, "full", "two identical games agree")
eq(host.stage, "trade", "and go straight into the mode")
pump(host, guest, gameHost, gameGuest, 3)
eq(guest.verdict, "full", "the guest reaches the same verdict")
eq(host.trade.stage, "picking", "the host's trade session is ready")
eq(guest.trade.stage, "picking", "the guest's trade session is ready")
check(host.trade.strict, "a v2 verdict unpacks strictly")

-- a v1 peer sends the raw {name, mode} hello and nothing else
local gameOld = linkGame("RED", "PIDGEY")
local oldNet, peerNet = Net.loopbackPair()
local v1guest = LinkState.new(gameOld)
v1guest.net = linkSession(oldNet, "guest")
v1guest.stage = "joining"
gameOld.stack:push(v1guest)
v1guest:update(1 / 60)
peerNet:send({ type = "hello", name = "OLD", mode = "trade" })
peerNet:send({ type = "party",
               mons = Protocol.packParty({ Pokemon.new(Data, "MACHOKE", 20) }) })
v1guest:update(1 / 60)
eq(v1guest.verdict, "vanilla_peer", "an old peer is accepted by an unmodified game")
v1guest:update(1 / 60)
eq(v1guest.trade.stage, "picking", "and the v1 trade runs as it always did")
check(not v1guest.trade.strict, "the v1 path keeps the old unpack rules")

-- the host talking to a peer that never says hello falls back after the
-- grace period, and its own hello still carries the v1 fields
local gameLone = linkGame("RED", "PIDGEY")
local loneNet, silentNet = Net.loopbackPair()
local v1host = LinkState.new(gameLone)
v1host.net = linkSession(loneNet, "host")
v1host.stage = "hosting"
gameLone.stack:push(v1host)
v1host:update(1 / 60)
gameLone.input.pressed = { a = true }
v1host:update(1 / 60)
gameLone.input.pressed = {}
eq(v1host.stage, "waitHello", "the host waits for the peer's hello")
for _ = 1, 200 do v1host:update(1 / 60) end
eq(v1host.verdict, "vanilla_peer", "silence means a pre-mod peer")
eq(v1host.stage, "trade", "and the v1 path runs")
local sawHello = false
for _, msg in ipairs(silentNet:poll()) do
  if msg.type == "hello" then
    sawHello = true
    eq(msg.mode, "trade", "the hello still carries the mode a v1 guest reads")
    eq(msg.name, "RED", "and the name")
  end
end
check(sawHello, "the host's hello went out")

-- mismatched surfaces: the screen explains, trade continues in subset mode
local gameVan, gameMod = linkGame("RED", "PIDGEY"), linkGame("BLUE", "RATTATA", modded)
local vanHost, modGuest = pairStates(gameVan, gameMod)
pump(vanHost, modGuest, gameVan, gameMod, 2)
gameVan.input.pressed = { a = true }
vanHost:update(1 / 60)
gameVan.input.pressed = {}
eq(vanHost.verdict, "subset", "differing data lands in subset")
eq(vanHost.stage, "notice", "and shows the incompatibility screen")
check(#vanHost.noticeLines > 0, "the screen has lines to draw")
vanHost:draw() -- smoke: the report renders under the headless stub
check(not vanHost.noticeExits, "a subset trade may continue")
pump(vanHost, modGuest, gameVan, gameMod, 2)
eq(modGuest.stage, "notice", "the guest sees the same screen")
gameVan.input.pressed = { a = true }
gameMod.input.pressed = { a = true }
vanHost:update(1 / 60)
modGuest:update(1 / 60)
gameVan.input.pressed = {}
gameMod.input.pressed = {}
eq(vanHost.trade.stage, "waitRecords", "continuing opens a subset session")
pump(vanHost, modGuest, gameVan, gameMod, 3)
eq(vanHost.trade.stage, "picking", "the record exchange completes")
check(vanHost.trade.subset, "the session negotiated a subset")
vanHost:draw() -- smoke: the ineligible rows render too

-- the same mismatch on the battle side refuses instead
local gameVan2, gameMod2 = linkGame("RED", "PIDGEY"), linkGame("BLUE", "RATTATA", modded)
local vanHost2, modGuest2 = pairStates(gameVan2, gameMod2)
pump(vanHost2, modGuest2, gameVan2, gameMod2, 2)
vanHost2.index = 2 -- BATTLE
gameVan2.input.pressed = { a = true }
vanHost2:update(1 / 60)
gameVan2.input.pressed = {}
eq(vanHost2.stage, "notice", "a mismatched link battle stops at the screen")
check(vanHost2.noticeExits, "and the screen is the end of it")
vanHost2:draw() -- smoke: the battle-refusal wording renders too
gameVan2.input.pressed = { a = true }
vanHost2:update(1 / 60)
gameVan2.input.pressed = {}
check(gameVan2.stack:top() ~= vanHost2, "acknowledging leaves link play")

-- ------- lockstep battle: refusal, the turn-order hook, desync attribution

-- pinned DVs keep the lockstep run reproducible: the shared seed only fixes
-- the RNG stream, and rolled DVs would move the whole battle underneath it
local function fixedMon(species, level)
  local mon = Pokemon.new(Data, species, level)
  mon.dvs = { hp = 8, attack = 8, defense = 8, speed = 8, special = 8 }
  mon.statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }
  mon.stats = require("src.pokemon.Stats").calc(Data.pokemon[species], level,
                                                mon.dvs, mon.statExp)
  mon.hp = mon.stats.hp
  return mon
end

local function makeFakeGame(leadSpecies)
  local save = require("src.core.SaveData").newGame()
  table.insert(save.party, fixedMon(leadSpecies, 50))
  local stack = { list = {} }
  function stack:push(state, ...)
    table.insert(self.list, state)
    if state.enter then state:enter(...) end
  end
  function stack:pop() table.remove(self.list) end
  function stack:top() return self.list[#self.list] end
  function stack:update(dt)
    local top = self:top()
    if top and top.update then top:update(dt) end
  end
  return { data = Data, input = Input, stack = stack, save = save }
end

Input:init()

local refused, refusedWhy = LinkBattle.newHost(makeFakeGame("CHARIZARD"),
  select(1, Net.loopbackPair()),
  { myParty = Protocol.packParty(partyVanilla),
    theirParty = Protocol.packParty(partyVanilla), verdict = "subset" })
eq(refused, nil, "a subset verdict refuses to build a link battle")
check(refusedWhy ~= nil, "and reports why")
local strictRefused = LinkBattle.newHost(makeFakeGame("CHARIZARD"),
  select(1, Net.loopbackPair()),
  { myParty = { { species = "ZORUA", level = 20, moves = {} } },
    theirParty = Protocol.packParty(partyVanilla),
    verdict = "full", strict = true })
eq(strictRefused, nil, "a strict battle refuses a mon the peer cannot rebuild")

-- ------- pokemon.received on the link-battle unpack

-- the held-item validator from the design's mod-author example: it must get
-- the same shot at a link battle's mons that it gets at a traded one
local function heldMon(species, level, item)
  local mon = fixedMon(species, level)
  mon.extra = { held_items = { held_item = item } }
  return mon
end

local mine = { heldMon("CHARIZARD", 50, "LEFTOVERS"), fixedMon("PIDGEY", 20) }
local theirs = { heldMon("BLASTOISE", 50, "BAD_ITEM") }
local packedMine, packedTheirs = Protocol.packParty(mine), Protocol.packParty(theirs)

local seen
events:on("pokemon.received", function(payload)
  seen[#seen + 1] = payload
  payload.mon.nickname = "CHECKED"
  local bag = payload.mon.extra and payload.mon.extra.held_items
  if bag and bag.held_item == "BAD_ITEM" then bag.held_item = nil end
end, 0, "test")

seen = {}
local hostSide = LinkBattle.newHost(makeFakeGame("CHARIZARD"),
  select(1, Net.loopbackPair()),
  { myParty = packedMine, theirParty = packedTheirs, theirName = "BLUE",
    seed = 11, verdict = "full", strict = true })
check(hostSide ~= nil, "the host side builds")
eq(#seen, 3, "pokemon.received fires once per mon on both parties")
eq(seen[1].from, "link", "the payload names the link as the source")
eq(seen[1].peerName, "BLUE", "and carries the peer name")
eq(seen[1].mon.species .. "," .. seen[2].mon.species .. "," .. seen[3].mon.species,
   "CHARIZARD,PIDGEY,BLASTOISE", "the host announces its own party first")
eq(hostSide.player.name, "CHECKED",
   "a listener's edit reaches the battler it is built from")
eq(hostSide.enemy.mon.extra.held_items.held_item, nil,
   "an unrecognised held item is stripped before the simulation sees it")

-- the guest holds the same two parties the other way round and has to walk
-- them in the same order, or a mutating validator desyncs the lockstep
seen = {}
local guestSide = LinkBattle.newGuest(makeFakeGame("BLASTOISE"),
  select(1, Net.loopbackPair()),
  { myParty = packedTheirs, theirParty = packedMine, theirName = "RED",
    seed = 11, verdict = "full", strict = true })
check(guestSide ~= nil, "the guest side builds")
eq(#seen, 3, "and sees the same three mons")
eq(seen[1].mon.species .. "," .. seen[2].mon.species .. "," .. seen[3].mon.species,
   "CHARIZARD,PIDGEY,BLASTOISE", "in the same host-first order as the host")

events:removeOwner("test")
seen = {}
local unhooked = LinkBattle.newHost(makeFakeGame("CHARIZARD"),
  select(1, Net.loopbackPair()),
  { myParty = packedMine, theirParty = packedTheirs, theirName = "BLUE",
    seed = 11, verdict = "full", strict = true })
eq(#seen, 0, "nothing is emitted once the listener is gone")
eq(unhooked.enemy.mon.extra.held_items.held_item, "BAD_ITEM",
   "and the unpacked mon is untouched with nobody subscribed")

-- both sides run the same modded ordering rule and the same asymmetric
-- stage bump; the first proves the link path honours the battle hooks M6
-- landed, the second proves a divergence is caught and named
local orderCalls = 0
hooks:wrap("battle.turn_order", function(nxt, a, aMove, b, bMove, ctx)
  orderCalls = orderCalls + 1
  return nxt(a, aMove, b, bMove, ctx)
end, 0, "test")

local turnStarts = 0
local desync = nil
events:on("battle.turn_started", function(payload)
  turnStarts = turnStarts + 1
  check(payload.turn ~= nil, "battle.turn_started carries the turn number")
end, 0, "test")
events:on("link.desync", function(payload) desync = desync or payload end, 0, "test")

local gameA, gameB = makeFakeGame("CHARIZARD"), makeFakeGame("BLASTOISE")
gameB.save.player.name = "BLUE"
local netA, netB = Net.loopbackPair()
local packedA = Protocol.packParty(gameA.save.party)
local packedB = Protocol.packParty(gameB.save.party)
local battleA = LinkBattle.newHost(gameA, netA, {
  myParty = packedA, theirParty = packedB, theirName = "BLUE", seed = 424242,
  verdict = "full", strict = true })
local battleB = LinkBattle.newGuest(gameB, netB, {
  myParty = packedB, theirParty = packedA, theirName = "RED", seed = 424242,
  verdict = "full", strict = true })
check(battleA ~= nil and battleB ~= nil, "a full verdict builds both sides")

-- only the host's simulation gets the extra boost, so the two states must
-- disagree on the actives component
events:on("battle.turn_started", function(payload)
  if payload.battle == battleA and payload.turn == 2 then
    local stages = payload.battle.player.stages
    stages.attack = (stages.attack or 0) + 2
  end
end, 0, "test")

local resA, resB
battleA.onFinish = function(result) resA = result end
battleB.onFinish = function(result) resB = result end
gameA.stack:push(battleA)
gameB.stack:push(battleB)
local guard = 0
while (resA == nil or resB == nil) and guard < 60000 do
  guard = guard + 1
  Input.pressed = { a = true }
  gameA.stack:update(1 / 60)
  gameB.stack:update(1 / 60)
end
check(orderCalls > 0, "the link path routes turn order through battle.turn_order")
check(turnStarts > 0, "the link path emits battle.turn_started")
eq(resA, "draw", "the host ends the desynced match as a draw")
eq(resB, "draw", "the guest ends the desynced match as a draw")
check(desync ~= nil, "link.desync fired")
eq(desync.component, "actives", "the report names the diverging component")
eq(desync.turn, 2, "and the turn it happened on")
check(desync.localHash ~= desync.remoteHash, "the two component hashes differ")
check(battleA.localHashes[2]:find("^%u+:%d+:") ~= nil,
      "the hash message keeps the v1 value shape a pre-mod peer compares")

-- a verified turn stays recorded, so a finished battle's whole hash trail
-- can be swept; consuming compared turns left 0-1 entries behind
for turn = 1, battleA.turnCount do
  check(battleA.localHashes[turn] ~= nil and battleA.localParts[turn] ~= nil,
        "the host retains the turn " .. turn .. " hash record")
end
eq(battleA.localHashes[1], battleB.localHashes[1],
   "the retained pre-desync hashes agree across peers")

-- a pre-mod peer sends no parts at all, so the combined value has to stay
-- the comparison of record
desync = nil
local gameOldPeer = makeFakeGame("CHARIZARD")
local oldNetA = select(1, Net.loopbackPair())
local battleOld = LinkBattle.newHost(gameOldPeer, oldNetA, {
  myParty = Protocol.packParty(gameOldPeer.save.party),
  theirParty = Protocol.packParty(makeFakeGame("BLASTOISE").save.party),
  theirName = "OLD", seed = 7 })
gameOldPeer.stack:push(battleOld)
battleOld.localHashes[1] = "CHARIZARD:100:nil|BLASTOISE:100:nil"
table.insert(oldNetA.inbox, { type = "hash", turn = 1,
                              value = "CHARIZARD:100:nil|BLASTOISE:100:nil" })
gameOldPeer.stack:update(1 / 60)
eq(battleOld.result, nil, "a matching v1 hash is not a desync")
eq(desync, nil, "and nothing is reported")
check(battleOld.localHashes[1] ~= nil, "a matching v1 hash stays recorded")
table.insert(oldNetA.inbox, { type = "hash", turn = 2, value = "elsewhere" })
battleOld.localHashes[2] = "CHARIZARD:90:nil|BLASTOISE:80:nil"
gameOldPeer.stack:update(1 / 60)
eq(battleOld.result, "draw", "a differing v1 hash still ends the match")
check(desync ~= nil and desync.component == "state",
      "and is attributed to the whole state, the only thing a v1 peer sends")

events:removeOwner("test")
hooks:removeOwner("test")
Runtime.install(savedEvents, savedHooks, nil)

S.finish()
