-- Gen 2 battle core: where the weather multiplier sits in the damage chain,
-- which failures suppress the attack animation, and the two move lock-ins
-- (Rollout and the EFFECT_RAMPAGE pair, Thrash and Petal Dance).
--
--   luajit tests/gen2_battle_lockin_test.lua
--
-- ROM-free.  Every expectation names the pokegold routine it asserts:
-- engine/battle/misc.asm DoWeatherModifiers, engine/battle/effect_commands.asm
-- BattleCommand_Stab / _MoveAnimNoSub / _CheckRampage / _Rampage / _StatDown /
-- _Poison, engine/battle/move_effects/rollout.asm and leech_seed.asm, and
-- engine/battle/core.asm CheckPlayerLockedIn.

package.path = "./?.lua;./?/init.lua;" .. package.path

local S = require("tests.harness").suite("gen2 battle lock-in")
local check, eq = S.check, S.eq

local Battle = require("src.battle.gen2.Battle")
local Damage = require("src.battle.gen2.Damage")
local Effects = require("src.battle.gen2.Effects")
local Mon = require("src.battle.gen2.Mon")

-- ---------------------------------------------------------------- fixtures

local TYPES = {
  NORMAL = { id = "NORMAL", index = 0, category = "physical" },
  GHOST = { id = "GHOST", index = 8, category = "physical" },
  ROCK = { id = "ROCK", index = 5, category = "physical" },
  GRASS = { id = "GRASS", index = 22, category = "special" },
  WATER = { id = "WATER", index = 21, category = "special" },
  POISON = { id = "POISON", index = 3, category = "physical" },
}

-- data/type_matchups.asm's own row: NORMAL does nothing at all to GHOST.
local MATCHUPS = {
  { attacker = "NORMAL", defender = "GHOST", multiplier = 0 },
}

local MOVES = {
  TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
    accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
  ROLLOUT = { id = "ROLLOUT", name = "ROLLOUT", power = 30, type = "ROCK",
    accuracy = 90, pp = 20, effect = "EFFECT_ROLLOUT" },
  THRASH = { id = "THRASH", name = "THRASH", power = 90, type = "NORMAL",
    accuracy = 100, pp = 20, effect = "EFFECT_RAMPAGE" },
  LEECH_SEED = { id = "LEECH_SEED", name = "LEECH SEED", power = 0,
    type = "GRASS", accuracy = 90, pp = 10, effect = "EFFECT_LEECH_SEED" },
  TOXIC = { id = "TOXIC", name = "TOXIC", power = 0, type = "POISON",
    accuracy = 85, pp = 10, effect = "EFFECT_TOXIC" },
  POISONPOWDER = { id = "POISONPOWDER", name = "POISONPOWDER", power = 0,
    type = "GRASS", accuracy = 75, pp = 35, effect = "EFFECT_POISON" },
  GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL",
    accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" },
  -- An *_HIT twin: the drop rides a hit that already animated, so a refused
  -- drop must leave the move event unmarked (data/moves/effects.asm,
  -- AttackDownHit puts `attackdown` after `moveanim`).
  AURORA_BEAM = { id = "AURORA_BEAM", name = "AURORA BEAM", power = 65,
    type = "WATER", accuracy = 100, pp = 20,
    effect = "EFFECT_ATTACK_DOWN_HIT", effectChance = 100 },
}

local GROWTH = {
  GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
    linear = 0, constant = 0 },
}

local POKEMON = {
  growthRates = GROWTH,
  MACHOP = {
    id = "MACHOP", index = 66, name = "MACHOP",
    baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
      specialAttack = 35, specialDefense = 35 },
    types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
    growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
    levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
  },
  GASTLY = {
    id = "GASTLY", index = 92, name = "GASTLY",
    baseStats = { hp = 30, attack = 35, defense = 30, speed = 80,
      specialAttack = 100, specialDefense = 35 },
    types = { "GHOST", "GHOST" }, catchRate = 190, baseExp = 95,
    growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
    levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
  },
  TANGELA = {
    id = "TANGELA", index = 114, name = "TANGELA",
    baseStats = { hp = 65, attack = 55, defense = 115, speed = 60,
      specialAttack = 100, specialDefense = 40 },
    types = { "GRASS", "GRASS" }, catchRate = 45, baseExp = 166,
    growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
    levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
  },
}

local DATA = {
  pokemon = POKEMON,
  moves = MOVES,
  type_chart = { types = TYPES, matchups = MATCHUPS },
  items = {},
}

local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)

local function rolls(queue, fill)
  local at = 0
  return function(n)
    at = at + 1
    local value = queue[at]
    if value == nil then value = fill or 0 end
    return value % math.max(1, n or 1)
  end
end

local function newBattle(opts)
  opts = opts or {}
  local player = Mon.new(DATA, opts.playerSpecies or "MACHOP",
    opts.playerLevel or 15, { dvs = perfect })
  player.moves = opts.playerMoves or { { id = "TACKLE", pp = 35, maxPp = 35 } }
  local wild = Mon.new(DATA, opts.wildSpecies or "MACHOP",
    opts.wildLevel or 15, { dvs = perfect })
  wild.moves = opts.wildMoves or { { id = "TACKLE", pp = 35, maxPp = 35 } }
  local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
    random = opts.random })
  return battle, player, wild
end

-- The screen animates off the move event and skips a marked one
-- (src/ui/gen2/BattleState.lua reads `event.missed`), so "did the attack
-- animation play" is exactly "is this flag nil".
local function moveEvent(events)
  for _, event in ipairs(events or {}) do
    if event.kind == "move" then return event end
  end
  return nil
end

local function findText(events, text)
  for _, event in ipairs(events or {}) do
    if event.kind == "message" and event.text == text then return true end
  end
  return false
end

-- ---- DoWeatherModifiers sits FIRST in BattleCommand_Stab -------------------
--
-- effect_commands.asm:1254 farcalls DoWeatherModifiers before DoBadgeTypeBoosts
-- (:1261), before the STAB x1.5 and before .TypesLoop (:1299), and long before
-- BattleCommand_DamageVariation.  Every one of those steps floors, so the
-- multiply has to land where the cart puts it, not on the finished number.
do
  -- data/battle/weather_modifiers.asm pairs rain with MORE_EFFECTIVE, and
  -- MORE_EFFECTIVE is 15, not 20 (constants/battle_constants.asm:22).
  eq(Effects.weatherModifier("rain", "WATER"), 1.5,
    "rain is MORE_EFFECTIVE (15), a half again -- not the chart's x2")
  eq(Effects.weatherModifier("sun", "WATER"), 0.5,
    "sun is NOT_VERY_EFFECTIVE (05) against Water")

  -- Chosen so the two orders disagree.  Base damage floors to 1, the tail
  -- adds MIN_DAMAGE for 3, and the mon is Water so STAB applies:
  --   cart: 3 -> weather 4 -> STAB 6 -> variation 85% -> 5
  --   port's old order: 3 -> STAB 4 -> variation 85% -> 3 -> weather -> 4
  local opts = {
    level = 5, power = 20, moveType = "WATER",
    attacker = { attack = 10, specialAttack = 10, types = { "WATER" },
      stages = {} },
    defender = { defense = 10, specialDefense = 10, types = {}, stages = {} },
    types = TYPES, matchups = {}, variation = 85,
  }
  opts.weatherPercent = nil
  local clear = Damage.calc(opts)
  opts.weatherPercent = 15
  local rain = Damage.calc(opts)
  eq(clear, 3, "clear weather: the plain chain")
  eq(rain, 5, "rain multiplies ahead of STAB and DamageVariation")
  check(rain ~= 4,
    "and not on the finished number, which would have floored to 4")

  -- The .ApplyModifier zero-quotient arm forces the result back to 1, so a
  -- halved hit never falls to nothing (misc.asm:129-136).
  opts.weatherPercent = 5
  opts.variation = 100
  local weak = Damage.calc(opts)
  check(weak >= 1, "a weather-halved hit still deals at least 1")
end

-- ---- immunity suppresses the animation -----------------------------------
--
-- BattleCommand_Stab's `.GotMatchup` writes wAttackMissed when the matchup
-- byte is 0 (effect_commands.asm:1337), `stab` runs ahead of `moveanim` in
-- every damaging effect list (data/moves/effects.asm:5), and
-- BattleCommand_MoveAnimNoSub early-outs on wAttackMissed (:1958), so an
-- immune move plays MoveDelay and nothing else.
do
  local battle, player, wild = newBattle({
    wildSpecies = "GASTLY", random = rolls({}, 0) })
  battle:useMove(player, wild, "TACKLE")
  local events = battle:takeEvents()
  check(findText(events, "It doesn't affect GASTLY..."), "DoesntAffectText")
  eq(moveEvent(events).missed, true,
    "and wAttackMissed is set, so no attack animation plays")
  eq(wild.hp, wild.maxHp, "an immune hit deals nothing")
end

-- ---- the other AnimateFailedMove arms -------------------------------------
do
  -- leech_seed.asm `.grass`: AnimateFailedMove, then PrintDoesntAffect.
  local battle, player, wild = newBattle({
    playerMoves = { { id = "LEECH_SEED", pp = 10, maxPp = 10 } },
    wildSpecies = "TANGELA", random = rolls({}, 0) })
  battle:useMove(player, wild, "LEECH_SEED")
  local events = battle:takeEvents()
  check(findText(events, "It doesn't affect TANGELA..."), "PrintDoesntAffect")
  eq(moveEvent(events).missed, true, "a Grass target plays no animation")
  eq(wild.volatile and wild.volatile.leechSeed, nil, "and nothing is seeded")
end

do
  -- leech_seed.asm `.evaded`: a repeat seeds nothing and animates nothing.
  local battle, player, wild = newBattle({
    playerMoves = { { id = "LEECH_SEED", pp = 10, maxPp = 10 } },
    random = rolls({}, 0) })
  battle:useMove(player, wild, "LEECH_SEED")
  eq(moveEvent(battle:takeEvents()).missed, nil,
    "the seeding arm reaches AnimateCurrentMove")
  battle:useMove(player, wild, "LEECH_SEED")
  local events = battle:takeEvents()
  check(findText(events, "MACHOP evaded the attack!"), "EvadedText")
  eq(moveEvent(events).missed, true, "and the repeat plays nothing")
end

do
  -- BattleCommand_StatDown's `.CantLower` sets wAttackMissed
  -- (effect_commands.asm:4380-4390) and `statdownanim` reads it.
  local battle, player, wild = newBattle({
    playerMoves = { { id = "GROWL", pp = 40, maxPp = 40 } },
    random = rolls({}, 0) })
  battle.stages.enemy.attack = -Effects.MAX_STAGE
  battle:useMove(player, wild, "GROWL")
  local events = battle:takeEvents()
  check(findText(events, "MACHOP's ATTACK won't drop anymore!"),
    "the refusal line")
  eq(moveEvent(events).missed, true, "a capped drop plays no animation")
end

do
  -- The negative regression that matters: AttackDownHit's `attackdown` runs
  -- AFTER `moveanim`, so a refused SECONDARY drop must leave the event alone.
  -- Battle:markMissed marks the move event retroactively, and the screen only
  -- reads the flag when the queue drains, so marking here would delete an
  -- animation the cart played.
  local battle, player, wild = newBattle({
    playerMoves = { { id = "AURORA_BEAM", pp = 20, maxPp = 20 } },
    random = rolls({}, 0) })
  battle.stages.enemy.attack = -Effects.MAX_STAGE
  battle:useMove(player, wild, "AURORA_BEAM")
  local events = battle:takeEvents()
  eq(moveEvent(events).missed, nil,
    "a refused secondary drop keeps the animation the hit already played")
  check(wild.hp < wild.maxHp, "and the hit still landed")
end

do
  -- BattleCommand_Poison's already-statused arm loads its text and jumps to
  -- `.failed`, which is AnimateFailedMove (effect_commands.asm:3748-3750).
  -- AnimateCurrentMove only runs on `.apply_poison` (:3752).
  local battle, player, wild = newBattle({
    playerMoves = { { id = "POISONPOWDER", pp = 35, maxPp = 35 },
      { id = "TOXIC", pp = 10, maxPp = 10 } },
    random = rolls({}, 0) })
  battle:useMove(player, wild, "POISONPOWDER")
  eq(wild.status, "poison", "the first one lands")
  eq(moveEvent(battle:takeEvents()).missed, nil, "and animates")
  battle:useMove(player, wild, "TOXIC")
  local events = battle:takeEvents()
  eq(moveEvent(events).missed, true,
    "a status move aimed at an already-statused target plays nothing")
  eq(wild.status, "poison", "and changes nothing")
end

-- ---- Rollout locks the user in --------------------------------------------
--
-- BattleCommand_RolloutPower sets SUBSTATUS_ROLLOUT while the counter is short
-- of MAX_ROLLOUT_COUNT (move_effects/rollout.asm), and CheckPlayerLockedIn
-- quits ParsePlayerAction while it is set (core.asm:546), so no menu is
-- offered, no PP is spent and no obedience check is made.
do
  local battle, player, wild = newBattle({
    playerMoves = { { id = "ROLLOUT", pp = 20, maxPp = 20 },
      { id = "TACKLE", pp = 35, maxPp = 35 } },
    wildLevel = 60, random = rolls({}, 0) })
  eq(battle:forcedMove(player), nil, "nothing forces the first Rollout")
  for turn = 1, 4 do
    battle:useMove(player, wild, "ROLLOUT")
    battle:takeEvents()
    eq(battle:forcedMove(player), "ROLLOUT",
      "turn " .. turn .. " of five leaves SUBSTATUS_ROLLOUT set")
  end
  eq(player.moves[1].pp, 19, "only the first turn spent PP (checkrollout skips doturn)")
  battle:useMove(player, wild, "ROLLOUT")
  battle:takeEvents()
  eq(battle:forcedMove(player), nil,
    "MAX_ROLLOUT_COUNT reached: the fifth hit clears the bit")
  eq(player.moves[1].pp, 19, "and still no extra PP was spent")
end

do
  -- A SECOND sequence, started after the first ran its five hits out.
  -- BattleCommand_CheckRollout takes its `.reset` arm whenever
  -- SUBSTATUS_ROLLOUT is clear as the move starts, and zeroes
  -- wPlayerRolloutCount there (move_effects/rollout.asm), so a spent counter
  -- never feeds the next sequence: the new ROLLOUT opens at base power, locks
  -- the menu again, and pays its one PP through the doturn the `.reset` arm
  -- falls through to.
  --
  -- The port used to decide "continue the ramp?" by asking whether the LAST
  -- move was also ROLLOUT, which has no cart equivalent: it held the counter at
  -- the cap, so the second sequence opened at 16x power and left
  -- CheckPlayerLockedIn (core.asm:546) with no bit to read.
  local battle, player, wild = newBattle({
    playerMoves = { { id = "ROLLOUT", pp = 20, maxPp = 20 } },
    wildLevel = 60, random = rolls({}, 0) })

  -- Topped up each time so the target never faints out from under the ramp.
  local function rolloutDamage()
    wild.hp = wild.maxHp
    battle:useMove(player, wild, "ROLLOUT")
    battle:takeEvents()
    return wild.maxHp - wild.hp
  end

  local opening = rolloutDamage()
  eq(battle:forcedMove(player), "ROLLOUT", "the opening hit locks")
  local capped
  for _ = 2, 5 do capped = rolloutDamage() end
  eq(battle:forcedMove(player), nil, "the fifth hit clears SUBSTATUS_ROLLOUT")
  eq(player.moves[1].pp, 19, "the whole first sequence cost one PP")
  check(capped > opening, "the ramp really did double along the way")

  local restarted = rolloutDamage()
  eq(restarted, opening, "the restarted ROLLOUT is back at base power")
  eq(battle:forcedMove(player), "ROLLOUT", "and it locks the menu again")
  eq(player.moves[1].pp, 18, "and pays PP, because .reset falls through to doturn")
end

do
  -- `.skip_set_rampage` reads wAttackMissed before the counter and clears
  -- SUBSTATUS_ROLLOUT outright, so a miss releases the lock.
  local battle, player, wild = newBattle({
    playerMoves = { { id = "ROLLOUT", pp = 20, maxPp = 20 } },
    wildLevel = 60, random = rolls({}, 0) })
  battle:useMove(player, wild, "ROLLOUT")
  battle:takeEvents()
  eq(battle:forcedMove(player), "ROLLOUT", "the first hit locks")
  -- 99 against ROLLOUT's 90 accuracy is a miss on every roll of the turn.
  battle.random = rolls({}, 99)
  battle:useMove(player, wild, "ROLLOUT")
  check(findText(battle:takeEvents(), "MACHOP's attack missed!"), "a miss")
  eq(battle:forcedMove(player), nil, "and the miss releases the lock")
end

-- ---- Thrash / Petal Dance: EFFECT_RAMPAGE ---------------------------------
--
-- BattleCommand_Rampage rolls 1 or 2 MORE turns (effect_commands.asm:4886), so
-- the move runs for two or three turns in all; BattleCommand_CheckRampage
-- (:4851) runs the counter down and, at zero, clears the bit and writes
-- SUBSTATUS_CONFUSED with its own `and %00000001` plus two count.
do
  -- rolls: the rampage length (0 -> one MORE turn, two in all), then the
  -- confusion count (0 -> `and 1` plus two, two turns).
  local battle, player, wild = newBattle({
    playerMoves = { { id = "THRASH", pp = 20, maxPp = 20 },
      { id = "TACKLE", pp = 35, maxPp = 35 } },
    wildLevel = 80, random = rolls({ 0 }, 0) })
  battle:useMove(player, wild, "THRASH")
  battle:takeEvents()
  eq(battle:forcedMove(player), "THRASH", "the opening turn locks the user in")
  eq(player.moves[1].pp, 19, "the opening turn spends PP")
  eq(player.volatile.confuseCount, nil, "and nothing is confused yet")

  battle:useMove(player, wild, "THRASH")
  battle:takeEvents()
  eq(battle:forcedMove(player), nil, "the count runs out on the second turn")
  eq(player.moves[1].pp, 19, "a continuing rampage skips doturn, so no PP")
  eq(player.volatile.confuseCount, 2,
    "CheckRampage writes the confusion count itself, `and 1` plus two")
  check(wild.hp < wild.maxHp, "and the last turn still attacked")
end

do
  -- The long roll: 2 more turns, so three in all.
  local battle, player, wild = newBattle({
    playerMoves = { { id = "THRASH", pp = 20, maxPp = 20 } },
    wildLevel = 80, random = rolls({ 1 }, 0) })
  battle:useMove(player, wild, "THRASH")
  battle:takeEvents()
  eq(battle:forcedMove(player), "THRASH", "turn one")
  battle:useMove(player, wild, "THRASH")
  battle:takeEvents()
  eq(battle:forcedMove(player), "THRASH", "turn two")
  battle:useMove(player, wild, "THRASH")
  battle:takeEvents()
  eq(battle:forcedMove(player), nil, "turn three ends it")
  check(player.volatile.confuseCount ~= nil, "and leaves the user confused")
end

do
  -- Switching out is CleanUpBattleRAM: every substatus goes, lock included.
  local battle, player, wild = newBattle({
    playerMoves = { { id = "THRASH", pp = 20, maxPp = 20 } },
    wildLevel = 80, random = rolls({ 1 }, 0) })
  battle:useMove(player, wild, "THRASH")
  battle:takeEvents()
  eq(battle:forcedMove(player), "THRASH", "locked")
  battle:clearVolatile(player)
  eq(battle:forcedMove(player), nil, "a send-out zeroes the substatus")
end

S.finish()
