-- Parity test,  Workstream J.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity J")
local check, eq = S.check, S.eq

-- === assertions per your spec test plan ===

local Game = require("src.core.Game")
Game.data = Data
Game.save = require("src.core.SaveData").newGame()
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local MoveEffects = require("src.battle.MoveEffects")

local function freshBattle()
  Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
  local tb = BattleState.newWild(Game, "PIDGEY", 10)
  return tb
end

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

-- (1) TRANSFORM: user.sprite morphs into the target species pic AND the
-- target's stat stages are copied (not cleared).  transform.asm:31-53
-- (AnimationTransformMon) + :57-132 (copies wEnemyMonStatMods).
do
  local tb = freshBattle()
  local preSprite = tb.player.sprite
  tb.enemy.stages.attack = 2
  tb.enemy.stages.speed = -1
  MoveEffects.primary.TRANSFORM_EFFECT(tb, tb.player, tb.enemy)
  check(tb.player.sprite ~= preSprite, "transform swaps the user's sprite")
  eq(tb.player.sprite.path, Data.pokemon.PIDGEY.spriteBack,
     "player-side transform uses the target species BACK pic")
  eq(tb.player.stages.attack, 2, "transform copies target's +attack stage")
  eq(tb.player.stages.speed, -1, "transform copies target's -speed stage")
  -- deep copy: later changes to the target must not bleed into the user
  tb.enemy.stages.attack = 5
  eq(tb.player.stages.attack, 2, "transform stages are a deep copy")
  -- enemy-side transform uses the FRONT pic
  local tb2 = freshBattle()
  MoveEffects.primary.TRANSFORM_EFFECT(tb2, tb2.enemy, tb2.player)
  eq(tb2.enemy.sprite.path, Data.pokemon.BULBASAUR.spriteFront,
     "enemy-side transform uses the target species FRONT pic")
end

-- (2) TRAPPING: continueTrapping replays the trapping move's per-turn
-- animation (core.asm:3554-3566 -> GetPlayerAnimationType).
do
  local tb = freshBattle()
  -- performMove WRAP rng: accuracy, crit, damage-random, trapping counter
  tb.rng = mkseq({ 0, 255, 255, 0 })
  tb:performMove(tb.player, tb.enemy, { id = "WRAP", pp = 10 })
  eq(tb.player.trapMove, "WRAP", "the trapping move id is stored on the user")
  -- keep the victim alive so the continuation stays clean
  tb.enemy.mon.stats.hp = 999
  tb.enemy.mon.hp = 999
  tb.queue = {}
  tb.nextInsert = 0
  tb:continueTrapping(tb.player, tb.enemy)
  local animRow
  for _, row in ipairs(tb.queue) do
    if row.anim == "WRAP" then animRow = row end
  end
  check(animRow ~= nil, "continueTrapping queues an anim row for the trap move")
  check(animRow and animRow.attackerIsPlayer == true,
        "the queued trap anim row is attributed to the attacker")
end

-- (2b) Issue #140: being held by Wrap must NOT skip DisplayBattleMenu
-- (core.asm:312 then 323-329).  FIGHT forces CANNOT_MOVE; AI still
-- auto-selects bound.  A player switch clears the foe's trap bit
-- (SendOutMon core.asm:1761-1762).
do
  local tb = freshBattle()
  tb.enemy.trappingTurns = 2
  tb.enemy.trapMove = "WRAP"
  tb.enemy.trapDamage = 5
  check(tb:menuLockedAction(tb.player) == nil,
        "a Wrap victim still gets the battle menu")
  local fight = tb:fightLockedAction(tb.player)
  check(fight and fight.special == "bound",
        "FIGHT while wrapped selects CANNOT_MOVE (bound)")
  -- wrapper continues only after FIGHT, not by skipping the menu
  check(tb:menuLockedAction(tb.enemy) == nil,
        "a Wrap user still gets the battle menu")
  check(tb:fightLockedAction(tb.enemy) and
        tb:fightLockedAction(tb.enemy).special == "trapping",
        "FIGHT while wrapping continues the trap")
  check(tb:lockedAction(tb.player) and tb:lockedAction(tb.player).special == "bound",
        "lockedAction still reports bound for AI / callers")
end
do
  Game.save.party = {
    Pokemon.new(Data, "BULBASAUR", 20),
    Pokemon.new(Data, "SQUIRTLE", 20),
  }
  local tb = BattleState.newWild(Game, "EKANS", 10)
  tb.enemy.trappingTurns = 3
  tb.enemy.trapMove = "WRAP"
  tb.enemy.trapDamage = 7
  local acts = {}
  function tb:act(fn) acts[#acts + 1] = fn end
  function tb:actNext(fn) acts[#acts + 1] = fn end
  function tb:sayNext() end
  function tb:animNext() end
  function tb:startGrowIn() end
  function tb:syncSides() end
  function tb:markParticipant() end
  function tb:restoreMimicked() end
  function tb:executeAction() end
  function tb:endOfTurn() end
  function tb:enemyAction() return { special = "bound" } end
  tb:resolveSwitch(Game.save.party[2])
  acts[1]() -- send-out clears foe trap
  acts[#acts]()
  eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
  eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
  check(tb:fightLockedAction(tb.player) == nil,
        "switch-in is free to choose a move")
end

-- (3) MIMIC runs MID-move (MimicEffect, effects.asm:1203-1273): the
-- move executes, MoveHitTest runs, and only on a hit does the player's
-- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link
-- battles roll a random slot (.getRandomMove).  The copied move
-- overwrites the used slot's ID in place (the PP byte is untouched,
-- :1261-1266) and the id snaps back when the battler leaves play.
local function queueScan(tb)
  local found = { texts = {} }
  for _, row in ipairs(tb.queue) do
    if row.mimicSelect then found.chooser = row.mimicSelect end
    if row.anim == "MIMIC" then found.anim = true end
    if row.text then table.insert(found.texts, row.text) end
  end
  return found
end
local function hasText(found, pat)
  for _, t in ipairs(found.texts) do if t:find(pat) then return true end end
  return false
end
do
  -- player Mimic HIT: the chooser row is queued only after the hit test
  local tb = freshBattle()
  tb.kind = "wild"
  tb.player.curMoves[1] = { id = "MIMIC", pp = 10 } -- aliases mon.moves
  tb.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } }
  tb.rng = function(a, b) return a end -- accuracy roll 0: hit
  tb.queue, tb.nextInsert = {}, 0
  tb:performMove(tb.player, tb.enemy, tb.player.curMoves[1])
  local found = queueScan(tb)
  check(found.chooser ~= nil, "player Mimic hit queues the mid-move chooser row")
  check(not found.anim, "no Mimic animation before the copy is picked")
  eq(tb.player.curMoves[1].id, "MIMIC", "the slot is untouched until the pick")
  -- apply the pick like the mimicSelect A-handler does
  tb.nextInsert = 0
  tb:applyMimic(found.chooser.user, found.chooser.target, found.chooser.moveInst, 2)
  eq(tb.player.curMoves[1].id, "SAND_ATTACK",
     "the pick overwrites Mimic's slot id (slot 2 copied)")
  eq(tb.player.curMoves[1].pp, 9,
     "the copy inherits Mimic's remaining PP (only the move id byte is written)")
  eq(tb.player.mon.moves[1].id, "SAND_ATTACK",
     "the battle slot aliases the party slot (DecrementPP hits both)")
  check(tb.player.curMoves[1].mimic == true, "the copied slot is flagged mimic")
  found = queueScan(tb)
  check(found.anim, "the Mimic animation plays after the copy")
  check(hasText(found, "learned"), "the 'learned MOVE!' text follows")
  -- the id snaps back when the battler leaves play; spent PP stays spent
  tb:restoreMimicked(tb.player)
  eq(tb.player.mon.moves[1].id, "MIMIC", "leaving play restores the party move id")
  eq(tb.player.mon.moves[1].pp, 9, "spent PP stays spent after the restore")

  -- player Mimic MISS: "But, it failed!", no chooser, no animation
  local tbm = freshBattle()
  tbm.kind = "wild"
  tbm.player.curMoves[1] = { id = "MIMIC", pp = 10 }
  tbm.enemy.curMoves = { { id = "GUST", pp = 35 } }
  tbm.rng = function(a, b) return b end -- accuracy roll 255: miss
  tbm.queue, tbm.nextInsert = {}, 0
  tbm:performMove(tbm.player, tbm.enemy, tbm.player.curMoves[1])
  local fm = queueScan(tbm)
  check(fm.chooser == nil, "a missed Mimic never opens the chooser")
  check(not fm.anim, "a missed Mimic plays no animation")
  check(hasText(fm, "But, it failed!"),
        "a missed Mimic prints PrintButItFailedText_")
  eq(tbm.player.curMoves[1].id, "MIMIC", "a missed Mimic copies nothing")

  -- a mid-Fly/Dig target also fails (.mimicMissed via INVULNERABLE)
  local tbi = freshBattle()
  tbi.kind = "wild"
  tbi.player.curMoves[1] = { id = "MIMIC", pp = 10 }
  tbi.enemy.curMoves = { { id = "GUST", pp = 35 } }
  tbi.enemy.invulnerable = true
  tbi.rng = function(a, b) return a end
  tbi.queue, tbi.nextInsert = {}, 0
  tbi:performMove(tbi.player, tbi.enemy, tbi.player.curMoves[1])
  local fi = queueScan(tbi)
  check(fi.chooser == nil and hasText(fi, "But, it failed!"),
        "Mimic fails outright against a mid-Fly/Dig target")

  -- enemy Mimic: random slot of the player, no menu (.getRandomMove)
  local tbe = freshBattle()
  tbe.kind = "wild"
  tbe.player.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } }
  tbe.enemy.curMoves[1] = { id = "MIMIC", pp = 10 }
  tbe.rng = mkseq({ 0, 2 }) -- accuracy hit, then random slot 2
  tbe.queue, tbe.nextInsert = {}, 0
  tbe:performMove(tbe.enemy, tbe.player, tbe.enemy.curMoves[1])
  local fe = queueScan(tbe)
  check(fe.chooser == nil, "enemy Mimic never opens a chooser")
  eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK",
     "enemy Mimic copies a random player move immediately")
  -- Gen 1 never decrements enemy PP, so the copied move inherits Mimic's
  -- full remaining PP (still 10). Player Mimic would leave 9.
  eq(tbe.enemy.curMoves[1].pp, 10, "enemy Mimic keeps full slot PP (no enemy drain)")
  check(fe.anim and hasText(fe, "learned"),
        "enemy Mimic still plays the animation and learned text")

-- === #94: gen1_faithful enemies never deplete PP; player still does ===
do
  local tb = freshBattle()
  check(tb.ruleset.enemyUnlimitedPP,
        "default ruleset grants enemy unlimited PP")
  local enemyMove = { id = "TACKLE", pp = 5 }
  local playerMove = { id = "TACKLE", pp = 5 }
  tb.queue, tb.nextInsert = {}, 0
  tb:performMove(tb.enemy, tb.player, enemyMove)
  eq(enemyMove.pp, 5, "gen1_faithful: enemy move PP is not decremented")
  tb.queue, tb.nextInsert = {}, 0
  tb:performMove(tb.player, tb.enemy, playerMove)
  eq(playerMove.pp, 4, "gen1_faithful: player move PP still decrements")

  -- modern_clean tracks enemy PP (Gen 2+ style)
  local modern = require("src.battle.rulesets.modern_clean")
  tb.ruleset = modern
  local enemyModern = { id = "TACKLE", pp = 5 }
  tb.queue, tb.nextInsert = {}, 0
  tb:performMove(tb.enemy, tb.player, enemyModern)
  eq(enemyModern.pp, 4, "modern_clean: enemy move PP decrements")
end

  -- link battle: the player's Mimic rolls random too (no chooser)
  local tbl = freshBattle()
  tbl.kind = "link"
  tbl.player.curMoves[1] = { id = "MIMIC", pp = 10 }
  tbl.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } }
  tbl.rng = mkseq({ 0, 1 }) -- accuracy hit, random slot 1
  tbl.queue, tbl.nextInsert = {}, 0
  tbl:performMove(tbl.player, tbl.enemy, tbl.player.curMoves[1])
  local fl = queueScan(tbl)
  check(fl.chooser == nil, "link Mimic skips the interactive chooser")
  eq(tbl.player.curMoves[1].id, "GUST", "link Mimic rolls random (slot 1)")
  tbl:restoreMimicked(tbl.player)

  -- the test-injection hook still short-circuits the player's menu
  local tbh = freshBattle()
  tbh.kind = "wild"
  tbh.player.curMoves[1] = { id = "MIMIC", pp = 10 }
  tbh.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } }
  tbh.mimicChoice = function(self, target) return 2 end
  tbh.rng = function(a, b) return a end
  tbh.queue, tbh.nextInsert = {}, 0
  tbh:performMove(tbh.player, tbh.enemy, tbh.player.curMoves[1])
  eq(tbh.player.curMoves[1].id, "SAND_ATTACK",
     "the mimicChoice hook applies the pick without a chooser row")
end

-- (3b) MIMIC chooser wiring through the queue: the mimicSelect row
-- flips the phase when it reaches the queue head, and the A-press
-- applies the highlighted slot (B never cancels: MoveSelectionMenu's
-- mimic menu watches only UP/DOWN/A, core.asm:2553-2557).
do
  local pressed = {}
  local tb = freshBattle()
  tb.game = { input = { wasPressed = function(_, k) return pressed[k] or false end,
                        isDown = function(_, k) return pressed[k] or false end },
              stack = { top = function() return tb end },
              save = Game.save }
  tb.kind = "wild"
  tb.player.curMoves[1] = { id = "MIMIC", pp = 10 }
  tb.enemy.curMoves = { { id = "GUST", pp = 35 }, { id = "SAND_ATTACK", pp = 15 } }
  tb.rng = function(a, b) return a end
  tb.queue, tb.nextInsert = {}, 0
  tb.phase = "messages"
  tb.afterQueue = "menu"
  tb:performMove(tb.player, tb.enemy, tb.player.curMoves[1])
  -- drain the queue past the announcement + 50-frame beat
  for _ = 1, 400 do
    if tb.phase ~= "messages" then break end
    pressed.a = true
    tb:updateQueue()
    if tb.current then tb.current = nil end -- fast-forward text rows
  end
  pressed.a = false
  eq(tb.phase, "mimicSelect", "the queued chooser row enters the mimicSelect phase")
  eq(#tb.mimicMoves, 2, "the chooser lists the enemy's moves")
  eq(tb.mimicIndex, 1, "the cursor starts on the first move")
  -- B does nothing (no cancel path in the mimic menu)
  pressed.b = true
  tb:update(1 / 60)
  pressed.b = false
  eq(tb.phase, "mimicSelect", "B does not leave the mimic menu")
  -- DOWN then A copies slot 2
  pressed.down = true
  tb:update(1 / 60)
  pressed.down = false
  eq(tb.mimicIndex, 2, "DOWN moves the chooser cursor")
  pressed.a = true
  tb:update(1 / 60)
  pressed.a = false
  eq(tb.phase, "messages", "A returns to the message queue")
  eq(tb.player.curMoves[1].id, "SAND_ATTACK", "A applies the highlighted slot")
  tb:restoreMimicked(tb.player)
end

-- (4) BIDE (regression): Gen 1 never rolls accuracy on release, so the
-- stored energy unleashes as bideDamage*2 even when an accuracy roll
-- would have missed (effects.asm:764-789 + core.asm:3481-3529).
do
  local tb = freshBattle()
  tb.enemy.mon.stats.hp = 200
  tb.enemy.mon.hp = 200
  local rngCalls = 0
  tb.rng = function(a, b) rngCalls = rngCalls + 1; return b end -- 255 -> would miss
  tb.player.bideTurns = 1
  tb.player.bideDamage = 50
  tb:continueBide(tb.player, tb.enemy)
  eq(tb.player.bideTurns, nil, "Bide releases on schedule")
  eq(tb.enemy.mon.hp, 100, "Bide release deals bideDamage*2 (2*50)")
  eq(rngCalls, 0, "Bide release consumes no rng for an accuracy check")
end

-- (5) OLD MAN catch tutorial (DisplayBattleMenu's old-man branch,
-- core.asm:2018-2050 + BagWasSelected:2193-2210 + ItemUseBall's
-- BATTLE_TYPE_OLD_MAN forks): no input is read at the battle menu --
-- the cursor hovers FIGHT for 80 frames, hops to ITEM for 50, and the
-- item menu (one POKé BALL x50) is forced.  The item list is scripted
-- too (DisplayListMenuID's old-man branch, home/list_menu.asm:65-80):
-- input is never read, the '▶' hovers POKé BALL for 80 frames, then the
-- auto A-press leaves the hollow '▷' on the row and the ball is used.
-- The old man NEVER attacks; the throw skips the catch calc entirely
-- ($43 = always caught) and the Weedle is not kept.
do
  local pressed = {}
  local stack = { states = {} }
  function stack:push(s) table.insert(self.states, s) end
  function stack:pop() return table.remove(self.states) end
  function stack:top() return self.states[#self.states] end
  local fg = {
    data = Data,
    save = require("src.core.SaveData").newGame(),
    input = { wasPressed = function(_, k) return pressed[k] or false end,
              isDown = function(_, k) return pressed[k] or false end },
    stack = stack,
  }
  fg.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
  local seen = {}
  local demo = BattleState.newWild(fg, "WEEDLE", 5)
  demo:makeOldManDemo()
  local finished = false
  demo.onFinish = function() finished = true end
  local origStart = demo.startMessage
  demo.startMessage = function(self, item)
    table.insert(seen, item.text)
    origStart(self, item)
  end
  stack:push(demo)
  demo:enter()
  check(demo.playerBackPic == nil or tostring(demo.playerBackPic.path or ""):find("oldman") ~= nil
        or demo.demo, "the demo uses the old man back pic slot") -- headless: pic may be nil
  -- intro text auto-advances on A
  for _ = 1, 300 do
    if demo.phase == "menu" then break end
    pressed.a = true
    demo:update(1 / 60)
  end
  pressed.a = false
  eq(demo.phase, "menu", "the demo reaches the battle menu")
  -- the scripted cursor ignores input and holds the menu for 130 frames
  local frames = 0
  for _ = 1, 200 do
    if stack:top() ~= demo then break end
    pressed.a = true -- must be ignored: the old man script reads no input
    if demo.phase == "menu" then frames = frames + 1 end
    demo:update(1 / 60)
  end
  pressed.a = false
  check(stack:top() ~= demo, "the ITEM menu is forced without input")
  eq(frames, 131, "FIGHT hover (80) + ITEM hover (50) frames before the bag")
  local bag = stack:top()
  eq(bag.items and #bag.items, 1, "the old man's bag lists exactly one item")
  eq(bag.items[1].label, "POKé BALL", "the item is a POKé BALL")
  eq(bag.items[1].right, "x50", "with quantity x50 (OldManItemList)")
  -- the list script (home/list_menu.asm:65-80): input is never read --
  -- B can't back out -- and the '▶' hovers POKé BALL for 80 frames
  local enemyHP = demo.enemy.mon.hp
  pressed.b = true
  local hover = 0
  for _ = 1, 80 do
    if stack:top() ~= bag then break end
    hover = hover + 1
    bag:update(1 / 60)
  end
  pressed.b = false
  check(stack:top() == bag, "the old man's bag reads no input (B can't back out)")
  eq(hover, 80, "the '▶' hovers POKé BALL for 80 frames")
  check(not bag.hollowIndex, "the cursor stays the filled '▶' through the hover")
  -- frame 81: the auto A-press leaves the hollow '▷' on the chosen row
  -- (PlaceUnfilledArrowMenuCursor) while ItemUseBall spins up
  bag:update(1 / 60)
  eq(bag.hollowIndex, 1, "the auto-A leaves the hollow '▷' on the POKé BALL row")
  check(stack:top() == bag, "the hollow '▷' is visible while the list is open")
  -- the throw follows without any input: OLD MAN throws, it ALWAYS
  -- catches, nothing is kept
  for _ = 1, 20 do
    if stack:top() ~= bag then break end
    bag:update(1 / 60)
  end
  check(stack:top() ~= bag, "the ball is thrown without input")
  -- Battle exit now rides Transition.battleReturn (MapEntryAfterBattle's
  -- GBFadeInFromWhite, home/overworld.asm:749-753): the battle pops itself
  -- and pushes the fade, which fires onFinish only once ITS update counts
  -- down -- so pump whatever sits on top of the stack, not the demo state.
  for _ = 1, 2000 do
    if finished then break end
    pressed.a = true
    local top = stack:top()
    if top then top:update(1 / 60) else break end
  end
  pressed.a = false
  check(finished, "the throw ends the demo battle")
  local function sawText(pat)
    for _, t in ipairs(seen) do if t and t:find(pat, 1, true) then return true end end
    return false
  end
  check(sawText("OLD MAN used\nPOKé BALL!"), "the throw is credited to OLD MAN")
  check(sawText("All right!\nWEEDLE was\ncaught!"), "the ball always catches (_ItemUseBallText05)")
  eq(demo.enemy.mon.hp, enemyHP, "the old man never attacks (Weedle at full HP)")
  eq(#fg.save.party, 1, "the caught Weedle is NOT added to the party")
  check(not (fg.save.pokedex and fg.save.pokedex.owned and fg.save.pokedex.owned.WEEDLE),
        "the caught Weedle is NOT added to the dex")
end

-- (5b) Yellow's SimulatedInputBattleItemList (core.asm:2316-2319) drops
-- the same canned bag to a single POKé BALL, x1 -- pokered's
-- OldManItemList (core.asm:2212-2214, checked above) stays x50.  Only
-- the item count changes; the scripted no-input menu flow is identical
-- and already covered above, so this jumps straight to the bag.
do
  local GameVersion = require("src.core.GameVersion")
  local oldVersion = GameVersion.get()
  GameVersion.set("yellow")
  local ok, err = pcall(function()
    local pressed = {}
    local stack = { states = {} }
    function stack:push(s) table.insert(self.states, s) end
    function stack:pop() return table.remove(self.states) end
    function stack:top() return self.states[#self.states] end
    local fg = {
      data = Data,
      save = require("src.core.SaveData").newGame(),
      input = { wasPressed = function(_, k) return pressed[k] or false end,
                isDown = function(_, k) return pressed[k] or false end },
      stack = stack,
    }
    fg.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
    local demo = BattleState.newWild(fg, "PIKACHU", 5)
    demo:makeOldManDemo("PROF.OAK")
    stack:push(demo)
    demo:enter()
    for _ = 1, 300 do
      if demo.phase == "menu" then break end
      pressed.a = true
      demo:update(1 / 60)
    end
    pressed.a = false
    eq(demo.phase, "menu", "Yellow: the demo reaches the battle menu")
    for _ = 1, 200 do
      if stack:top() ~= demo then break end
      demo:update(1 / 60)
    end
    local bag = stack:top()
    eq(bag.items and bag.items[1] and bag.items[1].right, "x1",
       "Yellow's old-man-style bag lists x1 (SimulatedInputBattleItemList)")
  end)
  GameVersion.set(oldVersion)
  if not ok then error(err, 0) end
end

S.finish()
