-- M6 battle extensibility: effect-record coverage and parity, statuses/
-- balls/rulesets/ai_classes consumption, move-field promotion, the battle
-- hooks and events, and the side/field substrate.  Self-contained like the
-- other mod suites: own bootstrap, assert-based checks, error() on failure.
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
local Font = require("src.render.Font")
Font.load(Data)

local BattleState = require("src.battle.BattleState")
local PaletteFX = require("src.render.PaletteFX")
local Catching = require("src.battle.Catching")
local Damage = require("src.battle.Damage")
local Events = require("src.mods.Events")
local Evolution = require("src.pokemon.Evolution")
local Experience = require("src.battle.Experience")
local Growth = require("src.pokemon.Growth")
local Hooks = require("src.mods.Hooks")
local MoveEffects = require("src.battle.MoveEffects")
local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local Schemas = require("src.mods.Schemas")
local Status = require("src.battle.Status")
local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")

TypeChart.load(Data)
local ruleset = require("src.battle.rulesets.gen1_faithful")

local S = require("tests.harness").suite("mod battle")
local check = S.check

local function mkseq(vals) -- scripted rng: pops vals, then max rolls
  local i = 0
  return function(a, b)
    i = i + 1
    return vals[i] ~= nil and vals[i] or b
  end
end

-- a stub stack keeps the queue pump self-contained (no UI rows in these
-- probes, so top() never has to return the battle)
local function makeGame(party)
  local save = SaveData.newGame()
  save.party = party
  local stack = { states = {} }
  function stack:push(state) self.states[#self.states + 1] = state end
  function stack:pop() return table.remove(self.states) end
  function stack:top() return self.states[#self.states] end
  return { data = Data, save = save, stack = stack,
           input = { wasPressed = function() return true end,
                     isDown = function() return true end } }
end

local function pump(battle, limit)
  local steps = 0
  while steps < (limit or 6000) do
    steps = steps + 1
    if not battle:updateQueue() then break end
  end
end

local function hasText(battle, fragment)
  for _, item in ipairs(battle.queue) do
    if item.text and item.text:find(fragment, 1, true) then return true end
  end
  return false
end

-- fresh buses for the hook/event sections; restored at the bottom
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
local events, hooks = Events.new(), Hooks.new()
Runtime.install(events, hooks)

-- ------- every vanilla move's effect resolves to a registered record

local effectCount, fullCount = 0, 0
for _, move in pairs(Data.moves) do
  if move.effect then
    local record = MoveEffects.RECORDS[move.effect]
    check(record ~= nil, "effect record exists for " .. move.effect)
    check(record.kind == "primary" or record.kind == "secondary"
          or record.kind == "full", "effect record kind valid for " .. move.effect)
  end
end
for _, record in pairs(MoveEffects.RECORDS) do
  effectCount = effectCount + 1
  if record.kind == "full" then fullCount = fullCount + 1 end
end
check(fullCount >= 25, "the 25 inline effects registered as full records")
check(effectCount >= 60, "primary/secondary/full records all registered")
check(MoveEffects.RECORDS.SWIFT_EFFECT.neverMiss == true, "Swift record never misses")
check(MoveEffects.RECORDS.OHKO_EFFECT.gate ~= nil, "OHKO record carries its gate")
check(MoveEffects.RECORDS.TWINEEDLE_EFFECT.kind == "full"
      and MoveEffects.RECORDS.TWINEEDLE_EFFECT.run ~= nil,
      "Twineedle is a full record with its secondary run")
check(MoveEffects.RECORDS.SLEEP_EFFECT.accuracyChecked == true
      and MoveEffects.RECORDS.ATTACK_UP1_EFFECT.accuracyChecked == nil,
      "accuracyChecked marks the MoveHitTest primaries")

-- ------- category-vs-isSpecial equivalence

local typeCount = 0
for id, record in pairs(TypeChart.TYPES) do
  typeCount = typeCount + 1
  check(Damage.isSpecial(id) == (record.category == "special"),
        "category matches isSpecial for " .. id)
end
check(typeCount == 15, "all 15 vanilla types checked")
check(TypeChart.displayName("PSYCHIC_TYPE") == "PSYCHIC", "type display name")

-- ------- record-driven parity probes

do
  -- fixed damage through the SPECIAL_DAMAGE record
  local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
  local battle = BattleState.newWild(game, "SNORLAX", 30)
  battle.rng = mkseq({ 0 }) -- accuracy only: SONICBOOM rolls nothing else
  local before = battle.enemy.mon.hp
  battle:performMove(battle.player, battle.enemy, { id = "SONICBOOM", pp = 10 })
  check(before - battle.enemy.mon.hp == 20, "SONICBOOM deals a fixed 20")

  -- OHKO gate fails against a faster target
  local game2 = makeGame({ Pokemon.new(Data, "BULBASAUR", 5) })
  local slow = BattleState.newWild(game2, "RATTATA", 30)
  slow.rng = mkseq({})
  slow:performMove(slow.player, slow.enemy, { id = "FISSURE", pp = 5 })
  check(hasText(slow, "But, it failed!"), "OHKO fails against a faster target")
  check(slow.enemy.mon.hp == slow.enemy.mon.stats.hp, "no damage through a failed gate")
end

-- ------- a mod-registered move effect drives a battle

do
  local landed = false
  local effects = {}
  for id, record in pairs(MoveEffects.RECORDS) do effects[id] = record end
  effects.TEST_SIDE_EFFECT = { kind = "secondary", run = function(ctx)
    landed = true
    return { "It tingles!" }
  end }
  Data.move_effects = effects
  Data.moves.TEST_STRIKE = { id = "TEST_STRIKE", name = "TEST STRIKE",
    type = "NORMAL", power = 40, accuracy = 100, pp = 10,
    effect = "TEST_SIDE_EFFECT" }
  local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
  local battle = BattleState.newWild(game, "SNORLAX", 30)
  battle.rng = mkseq({ 0, 255, 255 })
  battle:performMove(battle.player, battle.enemy, { id = "TEST_STRIKE", pp = 10 })
  check(landed, "a registered move effect runs post-damage")
  check(hasText(battle, "It tingles!"), "the effect's message is queued")
  check(battle.enemy.mon.hp < battle.enemy.mon.stats.hp,
        "an unknown-kind move still deals its damage")
  Data.move_effects = nil
  Data.moves.TEST_STRIKE = nil
end

-- ------- statuses registry: gauntlet, residual, HUD, catch bonus

do
  local statuses = {}
  for id, record in pairs(Status.RECORDS) do statuses[id] = record end
  statuses.FBT = {
    id = "FBT", label = "FBT", hudLabel = "FBT",
    catchBonus = 20, shakeBonus = 30,
    beforeMovePriority = 40,
    beforeMove = function(battler)
      return false, { battler.name .. "\nis frostbitten!" }
    end,
    residual = function(battler)
      battler.mon.hp = math.max(0, battler.mon.hp - 3)
      return { "The frostbite\nhurts!" }
    end,
  }
  Data.statuses = statuses
  local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
  local battle = BattleState.newWild(game, "SNORLAX", 30)
  battle.enemy.mon.status = "FBT"
  local canMove, msgs = Status.beforeMove(battle.enemy, battle.rng, battle)
  check(canMove == false and msgs[1]:find("frostbitten", 1, true),
        "a registered status joins the beforeMove gauntlet")
  local hp = battle.enemy.mon.hp
  local residualMsgs = Status.residual(battle.enemy, battle.player, battle)
  check(battle.enemy.mon.hp == hp - 3 and #residualMsgs == 1,
        "a registered status joins the residual sweep")
  check(battle:statusLabel(battle.enemy.mon) == "FBT",
        "the HUD reads the registered hudLabel")

  -- the catch roll subtracts the registered catchBonus
  local mon = { status = "FBT", stats = { hp = 100 }, hp = 100 }
  local caught = Catching.attempt("POKE_BALL", mon, { catchRate = 0 },
    mkseq({ 19 }), nil, { statuses = statuses })
  check(caught == true, "registered catchBonus underflows the catch roll")
  local uncaught = Catching.attempt("POKE_BALL", mon, { catchRate = 0 },
    mkseq({ 19 }), nil, nil)
  check(uncaught == false, "an unknown status grants no catch bonus")

  -- the failure wobble adds the registered shakeBonus (fallback +5)
  local _, wobbles = Catching.attempt("POKE_BALL", mon, { catchRate = 50 },
    mkseq({ 100 }), nil, { statuses = statuses })
  check(wobbles == 2, "registered shakeBonus feeds the wobble math")
  local _, plain = Catching.attempt("POKE_BALL", mon, { catchRate = 50 },
    mkseq({ 100 }), nil, nil)
  check(plain == 1, "an unknown status falls back to the stock wobble bonus")
  Data.statuses = nil

  -- vanilla gauntlet parity without a battle on hand
  local sleeper = { mon = { status = "SLP" }, sleepTurns = 2, name = "SLEEPY" }
  local slpMove, slpMsgs = Status.beforeMove(sleeper, mkseq({}))
  check(slpMove == false and slpMsgs[1]:find("fast asleep", 1, true),
        "vanilla sleep runs through its record")
  local par = { mon = { status = "PAR" }, name = "ZAPPED" }
  local parMove = Status.beforeMove(par, mkseq({ 62 }))
  check(parMove == false, "full paralysis on a low roll")
  local parFree = Status.beforeMove(par, mkseq({ 63 }))
  check(parFree == true, "paralysis clears on a high roll")
end

-- ------- balls registry: record fields and the attempt override

do
  local calls = {}
  local rng = function(a, b)
    calls[#calls + 1] = { a, b }
    return b
  end
  local mon = { status = nil, stats = { hp = 100 }, hp = 100 }
  Catching.attempt("MOD_BALL", mon, { catchRate = 100 }, rng, nil,
    { ballDef = { randMax = 100, hpFactor = 12, wobbleFactor = 150 } })
  check(calls[1][2] == 100, "a registered ball's randMax bounds the roll")

  local auto = Catching.attempt("MOD_BALL", mon, { catchRate = 0 },
    function() error("autoCatch must not roll") end, nil,
    { ballDef = { randMax = 0, autoCatch = true } })
  check(auto == true, "autoCatch skips every roll")

  -- an attempt override doubles the rate then falls through to the math
  local caught = Catching.attempt("MOD_BALL", mon, { catchRate = 100 },
    mkseq({ 100, 85 }), nil,
    { ballDef = { randMax = 255, hpFactor = 12, wobbleFactor = 150,
      attempt = function(ctx)
        ctx.rateOverride = math.min(255, ctx.targetDef.catchRate * 2)
        return ctx.vanillaAttempt()
      end } })
  check(caught == true, "an attempt override rewrites the rate and delegates")

  -- toss/flicker resolve from the records
  local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
  local battle = BattleState.newWild(game, "RATTATA", 5)
  check(battle:tossAnimFor("GREAT_BALL") == "GREATTOSS_ANIM", "toss arc from record")
  check(battle:ballFlicker("ULTRA_BALL") == true, "Ultra flickers")
  check(battle:ballFlicker("POKE_BALL") == false, "Poke ball does not flicker")
end

-- ------- rulesets from the merged registry

do
  Data.rulesets = {
    gen1_faithful = ruleset,
    test_rules = { name = "test_rules", oneIn256Miss = false,
      critUsesBaseSpeed = true, critIgnoresStages = true,
      randMin = 255, randMax = 255, focusEnergyBug = true },
  }
  local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
  game.save.options = { ruleset = "test_rules" }
  local battle = BattleState.newWild(game, "RATTATA", 5)
  check(battle.ruleset.name == "test_rules", "battle picks the registered ruleset")
  game.save.options = { ruleset = "no_such_rules" }
  local fallback = BattleState.newWild(game, "RATTATA", 5)
  check(fallback.ruleset == ruleset, "unknown ruleset falls back to the default")
  Data.rulesets = nil
end

-- ------- options menu cycles the merged ruleset registry

do
  local OptionsMenu = require("src.ui.OptionsMenu")
  Data.rulesets = {
    gen1_faithful = ruleset,
    modern_clean = require("src.battle.rulesets.modern_clean"),
    aaa_rules = { name = "aaa rules" },
    zz_hidden = { name = "hidden rules", hidden = true },
  }
  local pressed = {}
  local game = { data = Data, save = SaveData.newGame(),
                 input = { wasPressed = function(_, key)
                   return pressed[key] or false
                 end },
                 stack = { pop = function() end } }
  local menu = OptionsMenu.new(game)
  -- RULESET is in no group, so this focuses it on the top level.
  check(menu:focusRow("ruleset") == menu, "RULESET focuses in place")
  local function press(key)
    pressed = { [key] = true }
    menu:update(1 / 60)
    pressed = {}
  end
  check(game.save.options.ruleset == "gen1_faithful",
        "new saves start on the default ruleset")
  press("right")
  check(game.save.options.ruleset == "modern_clean",
        "right steps to the next sorted id")
  press("right")
  check(game.save.options.ruleset == "aaa_rules",
        "a mod-registered ruleset is selectable")
  press("right")
  check(game.save.options.ruleset == "gen1_faithful",
        "the cycle wraps and never offers the hidden record")
  press("left")
  check(game.save.options.ruleset == "aaa_rules", "left steps backwards")

  local drawn = {}
  local savedDraw = Font.draw
  Font.draw = function(text) drawn[#drawn + 1] = text end
  menu:draw()
  Font.draw = savedDraw
  local shown = false
  for _, text in ipairs(drawn) do
    if text == "aaa rules" then shown = true end
  end
  check(shown, "the row displays the record's name, not the id")
  Data.rulesets = nil
end

-- ------- ai_classes: brains and layer records

-- ------- custom trainer portraits: palette sources and base portraits

do
  local oldMode = PaletteFX.mode
  PaletteFX.mode = "redpp"
  local custom = { id = "TEST_TRAINER",
                   paletteSource = "ROM:SpriteSheetPointerTable[21]" }
  local pal = BattleState.trainerPalette(Data, custom)
  local expected = PaletteFX.spriteObp({ paletteSource = custom.paletteSource }, custom.id)
  PaletteFX.mode = oldMode
  check(pal and expected and pal.colors[3][1] == expected[3][1]
        and pal.colors[3][2] == expected[3][2]
        and pal.colors[3][3] == expected[3][3],
        "a custom trainer portrait resolves its Advanced OBJ palette source")
  check(BattleState.trainerPicPath(Data, { basePic = "OPP_ENGINEER" })
        == Data.trainers.OPP_ENGINEER.pic,
        "a custom trainer can reuse a base trainer portrait by id")
  check(BattleState.trainerTrueColor(Data, { trueColor = true }) == true,
        "a trainer record's trueColor flag is readable")
  check(BattleState.trainerTrueColor(Data, { trueColor = false }) == false,
        "explicit false stays false")
  check(BattleState.trainerTrueColor(Data, { basePic = "OPP_ENGINEER" })
        == false,
        "a vanilla base portrait is not trueColor")
  check(Schemas.check(Schemas.REGISTRIES.trainers, "trainers", "OPP_BROCK",
                      { trueColor = true }, "patch"),
        "a trueColor trainers patch validates against the catalog schema")
  check(Schemas.check(Schemas.REGISTRIES.trainers, "trainers", "BEAUTY",
                      { pic = "mods/x/beauty.png", trueColor = true },
                      "patch", 2),
        "a Gold trainers patch can carry pic and trueColor")
end

do
  Data.ai_classes = { OPP_YOUNGSTER = { brain = function(battle)
    return { id = "SPLASH", pp = 1, brained = true }
  end } }
  local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
  local battle = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1)
  local action = battle:enemyAction()
  check(action.brained == true, "a registered brain chooses the enemy action")

  Data.ai_classes = { LAYER_1 = { score = function(view, moveDef, score)
    if moveDef and moveDef.id == "TACKLE" then return score + 50 end
    return score
  end } }
  local aiMon = { curMoves = { { id = "TACKLE", pp = 10 },
                               { id = "GROWL", pp = 10 } } }
  local aiBattle = { enemyAIMods = { 1 }, data = Data,
                     player = { mon = {}, curTypes = { "NORMAL" } } }
  local pick = TrainerAI.chooseMove(aiMon, mkseq({}), aiBattle)
  check(pick.id == "GROWL", "a registered layer record rescores the choice")
  Data.ai_classes = nil

  local brock = TrainerAI.classFor({ trainer = { id = "OPP_BROCK" }, data = Data })
  check(brock and brock.item == "FULL_HEAL", "class lookup falls back to the data file")
end

-- ------- move-field promotion

do
  -- priority beats speed
  local slow = { curStats = { speed = 5 }, stages = {}, mon = {} }
  local fast = { curStats = { speed = 99 }, stages = {}, mon = {} }
  check(TurnOrder.firstMover(slow, { id = "X", priority = 1 }, fast,
          { id = "TACKLE" }, mkseq({})) == true,
        "move.priority wins the turn order")
  check(TurnOrder.firstMover(slow, { id = "QUICK_ATTACK" }, fast,
          { id = "TACKLE" }, mkseq({})) == true,
        "legacy priority ids keep resolving")

  -- highCrit matches the legacy table's boosted rate
  local battler = { def = { baseStats = { speed = 128 } } }
  local function critCount(moveId, highCrit)
    local n = 0
    for i = 0, 255 do
      if Damage.critRoll(ruleset, battler, moveId, function() return i end,
                         highCrit) then
        n = n + 1
      end
    end
    return n
  end
  check(critCount("TACKLE", true) == critCount("SLASH", nil),
        "highCrit = true matches the legacy high-crit list")
  check(critCount("TACKLE", nil) == 64, "an unmarked move keeps the normal rate")

  -- fixedDamage field through the SPECIAL_DAMAGE record
  Data.moves.TEST_FIX = { id = "TEST_FIX", name = "TEST FIX", type = "NORMAL",
    power = 1, accuracy = 100, pp = 10, effect = "SPECIAL_DAMAGE_EFFECT",
    fixedDamage = 15 }
  local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
  local battle = BattleState.newWild(game, "SNORLAX", 30)
  battle.rng = mkseq({ 0 })
  local before = battle.enemy.mon.hp
  battle:performMove(battle.player, battle.enemy, { id = "TEST_FIX", pp = 10 })
  check(before - battle.enemy.mon.hp == 15, "fixedDamage field sets the damage")
  Data.moves.TEST_FIX = nil

  -- chargeText and semiInvulnerable fields
  Data.moves.TEST_CHARGE = { id = "TEST_CHARGE", name = "TEST CHARGE",
    type = "NORMAL", power = 40, accuracy = 100, pp = 10,
    effect = "CHARGE_EFFECT", chargeText = "%s\nis winding up!",
    semiInvulnerable = true }
  local cb = BattleState.newWild(makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }),
                                 "RATTATA", 5)
  cb.rng = mkseq({})
  local inst = { id = "TEST_CHARGE", pp = 10 }
  cb:performMove(cb.player, cb.enemy, inst)
  check(cb.player.charging == inst, "charge record starts the charge turn")
  check(cb.player.invulnerable == true, "semiInvulnerable field goes invulnerable")
  check(hasText(cb, "is winding up!"), "chargeText field picks the text")
  Data.moves.TEST_CHARGE = nil

  -- counterable field replaces the Normal/Fighting whitelist
  local counterGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
  local counter = BattleState.newWild(counterGame, "SNORLAX", 30)
  counter.rng = mkseq({ 0 })
  counter.lastDamage = 30
  counter.enemy.lastMove = "WATER_GUN"
  counter:performMove(counter.player, counter.enemy, { id = "COUNTER", pp = 10 })
  check(hasText(counter, "attack missed!"), "a Water move is not counterable")
  Data.moves.WATER_GUN.counterable = true
  local counter2 = BattleState.newWild(counterGame, "SNORLAX", 30)
  counter2.rng = mkseq({ 0 })
  counter2.lastDamage = 30
  counter2.enemy.lastMove = "WATER_GUN"
  local hp = counter2.enemy.mon.hp
  counter2:performMove(counter2.player, counter2.enemy, { id = "COUNTER", pp = 10 })
  check(hp - counter2.enemy.mon.hp == 60, "counterable = true doubles the last damage")
  Data.moves.WATER_GUN.counterable = nil

  -- multiHit field: a plain count consumes no distribution roll
  Data.moves.TEST_MULTI = { id = "TEST_MULTI", name = "TEST MULTI",
    type = "NORMAL", power = 15, accuracy = 100, pp = 10,
    effect = "TWO_TO_FIVE_ATTACKS_EFFECT", multiHit = 2 }
  local mh = BattleState.newWild(makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }),
                                 "SNORLAX", 30)
  mh.rng = mkseq({ 0, 255, 255 })
  mh:performMove(mh.player, mh.enemy, { id = "TEST_MULTI", pp = 10 })
  check(hasText(mh, "Hit the enemy\n2 times!"), "multiHit = 2 lands two hits")
  Data.moves.TEST_MULTI = nil
end

-- ------- constants: badge boosts and exp tuning

do
  local function plain(badges, boosts)
    return { curStats = { attack = 10, defense = 10, speed = 10, special = 10 },
             stages = {}, curTypes = {}, badges = badges, badgeBoosts = boosts,
             name = "TEST", mon = { level = 10 },
             def = { baseStats = { speed = 10 } } }
  end
  local physTest = { id = "PHYS", power = 100, type = "NORMAL", accuracy = 100 }
  local maxRoll = { rng = function() return 255 end, forceCrit = false }
  local rows = { { badge = "ZAPBADGE", stat = "attack", num = 2, den = 1 } }
  local boosted = Damage.compute(ruleset, plain({ ZAPBADGE = true }, rows),
                                 plain(nil), physTest, maxRoll)
  check(boosted == 26, "a registered badge boost row rescales the stat")
  local speedRows = { { badge = "ZAPBADGE", stat = "speed", num = 2, den = 1 } }
  check(TurnOrder.effectiveSpeed(plain({ ZAPBADGE = true }, speedRows)) == 20,
        "a registered speed boost row reaches TurnOrder")

  local rat = Data.pokemon.RATTATA
  check(Experience.gainFor(rat, 10, false, 1, false, { exp = { divisor = 14 } })
        == math.floor(rat.baseExp * 10 / 14),
        "constants.exp.divisor retunes the exp formula")
  check(Experience.gainFor(rat, 10, false, 1, false) ==
        math.floor(rat.baseExp * 10 / 7), "no constants keeps the /7 formula")
  check(Growth.levelForExp("MEDIUM_FAST", 100000000, 50) == 50,
        "levelForExp honors the level cap")
end

-- ------- growth rates registry

do
  local rates = { TESTCURVE = { expForLevel = function(n) return n * 100 end } }
  check(Growth.expForLevel("TESTCURVE", 3, rates) == 300,
        "a registered curve resolves through the rates table")
  check(Growth.levelForExp("TESTCURVE", 500, 100, rates) == 5,
        "levelForExp walks a registered curve")
  check(Growth.expForLevel("MEDIUM_FAST", 10) == 1000,
        "vanilla curves unchanged without a rates table")
end

-- ------- evolution methods and the evolution.check hook

do
  local egame = { data = {
    pokemon = { TESTMON = { evolutions = {
      { method = "LEVEL", level = 5, species = "RAICHU" } } } },
  } }
  local mon = { species = "TESTMON", level = 10 }
  local species = Evolution.pendingFor(egame, mon, { kind = "levelup" })
  check(species == "RAICHU", "LEVEL method fires through pendingFor")
  check(Evolution.pendingFor(egame, mon, { kind = "trade" }) == nil,
        "a trade trigger does not fire LEVEL")

  local unsub = hooks:wrap("evolution.check", function() return false end)
  check(Evolution.pendingFor(egame, mon, { kind = "levelup" }) == nil,
        "evolution.check can cancel an evolution")
  unsub()
  check(Evolution.pendingFor(egame, mon, { kind = "levelup" }) == "RAICHU",
        "unhooked dispatch is vanilla again")

  local fgame = { data = {
    pokemon = { TESTMON = { evolutions = {
      { method = "FRIENDSHIP", species = "RAICHU" } } } },
    evolution_methods = { FRIENDSHIP = { check = function(g, m, evo, trigger)
      return trigger.kind == "levelup" and (m.friendship or 0) >= 200
    end } },
  } }
  local buddy = { species = "TESTMON", level = 5, friendship = 250 }
  check(Evolution.pendingFor(fgame, buddy, { kind = "levelup" }) == "RAICHU",
        "a registered evolution method fires")
  buddy.friendship = 0
  check(Evolution.pendingFor(fgame, buddy, { kind = "levelup" }) == nil,
        "the registered method's own gate holds")
end

-- ------- checkParty only offers evolutions for mons that leveled (#213)

do
  local caterpie = Pokemon.new(Data, "CATERPIE", 7) -- at LEVEL evo threshold
  local bench = Pokemon.new(Data, "PIDGEY", 5)
  local save = SaveData.newGame()
  save.party = { caterpie, bench }
  local stack = { states = {} }
  function stack:push(state) self.states[#self.states + 1] = state end
  function stack:pop() return table.remove(self.states) end
  function stack:top() return self.states[#self.states] end
  local game = { data = Data, save = save, stack = stack }

  local offered = {}
  local origEvolve = Evolution.evolve
  Evolution.evolve = function(_, mon, to, onDone)
    offered[#offered + 1] = { mon = mon, to = to }
    if onDone then onDone() end
  end

  check(Evolution.checkParty(game) == 0,
        "checkParty with no leveledUp set offers nothing")
  check(#offered == 0, "no evolve calls without leveledUp")

  check(Evolution.checkParty(game, nil, {}) == 0,
        "checkParty with empty leveledUp offers nothing")
  check(#offered == 0, "cancel-at-threshold does not re-offer next battle")

  check(Evolution.checkParty(game, nil, { [bench] = true }) == 0,
        "a leveled mon with no pending evo is skipped")
  check(#offered == 0, "bench level-up alone does not evolve CATERPIE")

  check(Evolution.checkParty(game, nil, { [caterpie] = true }) == 1,
        "checkParty offers evo for the mon that leveled")
  check(#offered == 1 and offered[1].mon == caterpie and offered[1].to == "METAPOD",
        "the leveled CATERPIE is queued for METAPOD")

  Evolution.evolve = origEvolve
end

-- ------- the rare-candy flow runs the hook-wrapped dispatch

do
  local Bag = require("src.inventory.Bag")
  local BagMenu = require("src.ui.BagMenu")

  local pressed = {}
  local function uiGame(party)
    local save = SaveData.newGame()
    save.party = party
    local stack = { states = {} }
    function stack:push(state) self.states[#self.states + 1] = state end
    function stack:pop() return table.remove(self.states) end
    function stack:top() return self.states[#self.states] end
    return { data = Data, save = save, stack = stack,
             input = { wasPressed = function(_, key)
                         return pressed[key] or false
                       end,
                       isDown = function() return false end } }
  end

  -- feed one candy through the real bag UI: press A through the item
  -- list, USE, the party pick, the level text, the stat box and any
  -- evolution text until every state has popped
  local function candyFlow()
    local mon = Pokemon.new(Data, "CHARMANDER", 15)
    local game = uiGame({ mon })
    Bag.add(game.save, "RARE_CANDY", 1)
    game.stack:push(BagMenu.new(game))
    for _ = 1, 800 do
      local top = game.stack:top()
      if not top then break end
      pressed = { a = true }
      top:update(1)
      pressed = {}
    end
    check(game.stack:top() == nil, "the candy flow runs to completion")
    return mon
  end

  local fed = candyFlow()
  check(fed.level == 16, "the candy levels the mon")
  check(fed.species == "CHARMELEON", "the level evolution fires afterwards")

  local unsub = hooks:wrap("evolution.check", function() return false end)
  local blocked = candyFlow()
  check(blocked.level == 16, "the cancel hook leaves the level gain alone")
  check(blocked.species == "CHARMANDER",
        "evolution.check gates the rare-candy evolution")
  unsub()
end

-- ------- battle hooks: pass-through, transform, isolation

do
  local function tackleProbe()
    local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) })
    local battle = BattleState.newWild(game, "SNORLAX", 30)
    battle.rng = mkseq({ 0, 255, 255 })
    local before = battle.enemy.mon.hp
    battle:performMove(battle.player, battle.enemy, { id = "TACKLE", pp = 10 })
    return before - battle.enemy.mon.hp
  end

  local baseline = tackleProbe()
  check(baseline > 0, "baseline tackle deals damage")
  check(tackleProbe() == baseline, "unhooked damage is deterministic")

  local unsub = hooks:wrap("battle.damage", function(nextFn, ctx)
    local dmg, info = nextFn(ctx)
    return dmg * 2, info
  end)
  check(tackleProbe() == baseline * 2, "battle.damage hook doubles the damage")
  unsub()
  check(tackleProbe() == baseline, "unwrapped damage is vanilla again")

  unsub = hooks:wrap("battle.damage", function() error("boom") end)
  check(tackleProbe() == baseline, "a throwing damage wrapper is skipped")
  unsub()

  unsub = hooks:wrap("battle.crit", function() return true end)
  local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) })
  local critBattle = BattleState.newWild(game, "SNORLAX", 30)
  critBattle.rng = mkseq({ 0, 255 }) -- accuracy, damage; the hook owns the crit
  critBattle:performMove(critBattle.player, critBattle.enemy, { id = "TACKLE", pp = 10 })
  check(hasText(critBattle, "Critical hit!"), "battle.crit hook forces a crit")
  unsub()

  unsub = hooks:wrap("battle.accuracy", function() return false end)
  local missBattle = BattleState.newWild(game, "SNORLAX", 30)
  missBattle.rng = mkseq({})
  missBattle:performMove(missBattle.player, missBattle.enemy, { id = "TACKLE", pp = 10 })
  check(hasText(missBattle, "attack missed!"), "battle.accuracy hook forces a miss")
  unsub()

  unsub = hooks:wrap("catch.rate", function() return true, 3 end)
  local catchBattle = BattleState.newWild(game, "SNORLAX", 30)
  catchBattle.rng = mkseq({})
  local caught, shakes = catchBattle:catchAttempt("POKE_BALL")
  check(caught == true and shakes == 3, "catch.rate hook decides the catch")
  unsub()

  unsub = hooks:wrap("exp.gain", function(nextFn, ctx)
    return nextFn(ctx) * 2
  end)
  local mon = Pokemon.new(Data, "BULBASAUR", 10)
  local expBefore = mon.exp
  local _, gained = Experience.apply(Data, mon, Data.pokemon.RATTATA, 10,
                                     false, 1, false)
  check(gained == Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, false) * 2,
        "exp.gain hook doubles the award")
  check(mon.exp == expBefore + gained, "the doubled award is what lands")
  unsub()

  local sawOrder = false
  unsub = hooks:wrap("battle.turn_order", function(nextFn, a, aMove, b, bMove, ctx)
    sawOrder = true
    return nextFn(a, aMove, b, bMove, ctx)
  end)
  local orderGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) })
  local orderBattle = BattleState.newWild(orderGame, "RATTATA", 5)
  orderBattle.rng = mkseq({})
  orderBattle:resolveTurn(orderBattle.player.curMoves[1])
  check(sawOrder, "battle.turn_order hook wraps the order roll")
  unsub()

  unsub = hooks:wrap("battle.run", function() return true end)
  local runGame = makeGame({ Pokemon.new(Data, "CATERPIE", 3) })
  local runBattle = BattleState.newWild(runGame, "RATTATA", 30)
  runBattle.rng = mkseq({ 0 })
  runBattle:tryRun()
  check(runBattle.result == "run", "battle.run hook forces the escape")
  unsub()

  unsub = hooks:wrap("battle.enemy_action", function()
    return { id = "TACKLE", pp = 1, hooked = true }
  end)
  local actGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) })
  local actBattle = BattleState.newWild(actGame, "RATTATA", 5)
  check(actBattle:enemyAction().hooked == true,
        "battle.enemy_action hook rewrites the choice")
  unsub()

  -- battle.catch_exp: vanilla catches never grant exp; a mod can flip that
  unsub = hooks:wrap("battle.catch_exp", function() return true end)
  local catchExpParty = { Pokemon.new(Data, "BULBASAUR", 10) }
  local catchExpGame = makeGame(catchExpParty)
  local catchExpBattle = BattleState.newWild(catchExpGame, "RATTATA", 3)
  catchExpBattle.enemy.mon = Pokemon.new(Data, "RATTATA", 3)
  local expBeforeCatch = catchExpParty[1].exp
  catchExpBattle:storeCaughtMon()
  check(catchExpParty[1].exp > expBeforeCatch,
        "battle.catch_exp hook pays out exp on a catch")
  unsub()

  -- battle.exp_award: a mod can replace the participant/EXP.ALL split
  -- wholesale via ctx.applyShare
  unsub = hooks:wrap("battle.exp_award", function(nextFn, ctx)
    ctx.applyShare(ctx.alive[1], 999, "flatShare")
  end)
  local awardParty = { Pokemon.new(Data, "BULBASAUR", 10) }
  local awardGame = makeGame(awardParty)
  local awardBattle = BattleState.newWild(awardGame, "RATTATA", 3)
  local expBeforeAward = awardParty[1].exp
  awardBattle:awardExp()
  check(awardParty[1].exp > expBeforeAward,
        "battle.exp_award hook replaces the award split")
  unsub()
end

-- ------- battle.low_health_alarm hook: mirrors the siren toggle

do
  local Sound = require("src.core.Sound")
  local calls = {}
  local origStart, origStop = Sound.startLoop, Sound.stopLoop
  Sound.startLoop = function(data, name) calls[#calls + 1] = { "start", name } end
  Sound.stopLoop = function(name) calls[#calls + 1] = { "stop", name } end

  local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
  local battle = BattleState.newWild(game, "RATTATA", 5)
  battle.player.mon.hp = 1
  battle.player.shownHP = 1

  local seenOn = nil
  local unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx)
    seenOn = ctx.on
    return nextFn(ctx)
  end)
  battle:updateFx()
  check(seenOn == true, "battle.low_health_alarm hook sees the alarm toggle on")
  check(calls[#calls][1] == "start" and calls[#calls][2] == "Low_Health_Alarm",
        "an unmodified hook still starts the siren loop")
  unsub()

  unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx)
    ctx.on = false
    return nextFn(ctx)
  end)
  battle:updateFx()
  check(calls[#calls][1] == "stop", "a mod can force the alarm off before vanilla acts")
  unsub()

  Sound.startLoop, Sound.stopLoop = origStart, origStop
end

-- ------- battle events: the scripted sequence

do
  local log = {}
  local function listen(name)
    events:on(name, function(payload)
      log[#log + 1] = { name = name, payload = payload }
    end)
  end
  for _, name in ipairs({ "battle.started", "battle.turn_started",
      "battle.turn_ended", "battle.move_used", "battle.damage_dealt",
      "battle.fainted", "battle.exp_gained", "battle.ended",
      "battle.status_inflicted", "battle.ball_thrown", "pokemon.caught",
      "battle.battler_switched" }) do
    listen(name)
  end
  local function indexOf(name)
    for i, entry in ipairs(log) do
      if entry.name == name then return i end
    end
    return nil
  end

  local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) })
  local battle = BattleState.newWild(game, "RATTATA", 2)
  battle.onFinish = function() end
  battle:enter()
  check(indexOf("battle.started") ~= nil, "battle.started fires on enter")
  check(log[indexOf("battle.started")].payload.kind == "wild",
        "battle.started carries the kind")
  battle.rng = function(a) return a end -- min rolls: the move always hits
  battle:resolveTurn(battle.player.curMoves[1])
  pump(battle)
  battle:finish()
  check(indexOf("battle.turn_started") ~= nil, "battle.turn_started fires")
  check(indexOf("battle.move_used") ~= nil, "battle.move_used fires")
  check(indexOf("battle.damage_dealt") ~= nil, "battle.damage_dealt fires")
  check(indexOf("battle.fainted") ~= nil, "battle.fainted fires")
  check(indexOf("battle.turn_ended") ~= nil, "battle.turn_ended fires")
  check(indexOf("battle.exp_gained") ~= nil, "battle.exp_gained fires")
  check(indexOf("battle.ended") ~= nil, "battle.ended fires")
  check(indexOf("battle.started") < indexOf("battle.turn_started")
        and indexOf("battle.turn_started") < indexOf("battle.move_used")
        and indexOf("battle.move_used") < indexOf("battle.damage_dealt")
        and indexOf("battle.damage_dealt") < indexOf("battle.fainted")
        and indexOf("battle.fainted") < indexOf("battle.ended"),
        "the battle events fire in order")

  -- status_inflicted on a landing Thunder Wave
  local waveGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) })
  local wave = BattleState.newWild(waveGame, "RATTATA", 5)
  wave.rng = mkseq({ 254 })
  wave:performMove(wave.player, wave.enemy, { id = "THUNDER_WAVE", pp = 10 })
  local inflicted = indexOf("battle.status_inflicted")
  check(inflicted ~= nil and log[inflicted].payload.status == "PAR",
        "battle.status_inflicted carries the status")

  -- ball_thrown + pokemon.caught (box destination, no UI rows)
  local party = {}
  for _ = 1, 6 do party[#party + 1] = Pokemon.new(Data, "PIDGEY", 5) end
  local catchGame = makeGame(party)
  catchGame.save.pokedex.owned.RATTATA = true
  local catchBattle = BattleState.newWild(catchGame, "RATTATA", 3)
  catchBattle.onFinish = function() end
  catchBattle.rng = function(a) return a end
  catchBattle.queue = {}
  catchBattle:throwBall("POKE_BALL")
  pump(catchBattle)
  local thrown = indexOf("battle.ball_thrown")
  check(thrown ~= nil and log[thrown].payload.caught == true,
        "battle.ball_thrown reports the outcome")
  local caughtIdx = indexOf("pokemon.caught")
  check(caughtIdx ~= nil, "pokemon.caught fires")
  check(log[caughtIdx].payload.ball == "POKE_BALL"
        and log[caughtIdx].payload.destination == "box",
        "pokemon.caught carries ball and destination")

  -- battler_switched on a mid-battle switch
  local swGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 20),
                            Pokemon.new(Data, "PIDGEY", 20) })
  local swBattle = BattleState.newWild(swGame, "RATTATA", 5)
  swBattle.rng = mkseq({})
  swBattle:resolveSwitch(swGame.save.party[2])
  pump(swBattle)
  local switched = indexOf("battle.battler_switched")
  check(switched ~= nil and log[switched].payload.side.index == 1,
        "battle.battler_switched names the side")
end

-- ------- side/field substrate

do
  local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
  local battle = BattleState.newWild(game, "RATTATA", 5)
  battle:syncSides()
  check(battle.sides[1].battlers[1] == battle.player
        and battle.sides[2].battlers[1] == battle.enemy,
        "sides mirror the singles battlers")
  check(battle:sideOf(battle.enemy).index == 2, "sideOf maps by side")
  check(battle.field.sides == battle.sides and battle.field.weather == nil,
        "the field substrate starts empty")

  local residuals, expired = 0, false
  table.insert(battle.sides[2].tokens, { id = "test", turns = 2,
    onResidual = function() residuals = residuals + 1 end,
    onExpire = function() expired = true end })
  table.insert(battle.field.tokens, { id = "haze", turns = 1,
    onExpire = function() end })
  battle.rng = mkseq({})
  battle:endOfTurn()
  check(residuals == 1 and not expired, "side tokens tick each end of turn")
  check(#battle.field.tokens == 0, "an expired field token is removed")
  battle:endOfTurn()
  check(expired and #battle.sides[2].tokens == 0,
        "a side token expires after its turns run out")
end

Runtime.install(savedEvents, savedHooks)

S.finish()
