-- Parity test,  Vermilion Gym trash can puzzle.
-- Self-contained: run via `luajit tests/parity_trashcans.lua`; also
-- dofile'd by tests/run_tests.lua's aggregator.
--
-- Covers engine/events/hidden_events/vermilion_gym_trash.asm
-- (GymTrashScript + the GymTrashCans table, bug included) and
-- scripts/VermilionCity.asm (VermilionCity_Script
-- .setFirstLockTrashCanIndex):
--   * the first-lock can is rolled on EVERY Vermilion City map load
--     (Random & $0e -> a random even can), not lazily in the gym
--   * the second-lock can comes from the GymTrashCans row for the
--     first can: `mask AND random-byte` minus 1 indexes the candidate
--     bytes, so a zero AND underflows ($ff) into the bank's zero
--     padding and lands the switch in can 0 regardless (the documented
--     bug); mask 2 can only reach candidate 2, mask 4 only candidate 4
--   * a wrong second can resets EVENT_1ST_LOCK_OPENED and immediately
--     re-rolls the first can
--   * opening the second lock prints only VermilionGymTrashSuccessText3
--     (SuccessText2 is unused in pokered) and opens the door block
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 trashcans")
local check, eq = S.check, S.eq

local OW = require("src.world.OverworldController")
local SaveData = require("src.core.SaveData")
local story = require("data.scripts.story")

-- trashCanSwitch closes over the module-locals `Game` and `TextBox`
-- (set during real boot); rewire them through the debug library, the
-- same trick as parity_D.lua.
local function getUpvalue(fn, name)
  local i = 1
  while true do
    local n, v = debug.getupvalue(fn, i)
    if not n then return nil end
    if n == name then return v end
    i = i + 1
  end
end
local function setUpvalue(fn, name, val)
  local i = 1
  while true do
    local n = debug.getupvalue(fn, i)
    if not n then return false end
    if n == name then debug.setupvalue(fn, i, val); return true end
    i = i + 1
  end
end

local shown = {}
local textBoxStub = {
  new = function(_, text, onDone)
    shown[#shown + 1] = text
    if onDone then onDone() end
    return { text = text }
  end,
}
local realTextBox = getUpvalue(OW.trashCanSwitch, "TextBox")
textBoxStub.soundOpts = realTextBox.soundOpts
local fakeGame = { data = Data, save = SaveData.newGame(), stack = { push = function() end } }
check(setUpvalue(OW.trashCanSwitch, "TextBox", textBoxStub), "TextBox upvalue rewired")
check(setUpvalue(OW.trashCanSwitch, "Game", fakeGame), "Game upvalue rewired")

-- scripted love.math.random: feed(...) queues the next return values
local realRandom = love.math.random
local queue = {}
local function feed(...) queue = { ... } end
love.math.random = function(a, b)
  if #queue > 0 then return table.remove(queue, 1) end
  return realRandom(a, b)
end

local replaced = {}
local fakeSelf = setmetatable({
  map = { id = "VERMILION_GYM" },
  replaceBlock = function(_, x, y, b) replaced[#replaced + 1] = { x = x, y = y, b = b } end,
}, { __index = OW })

local function fresh()
  fakeGame.save = SaveData.newGame()
  shown = {}
  replaced = {}
end

local t = Data.text

-- === Vermilion City map load rolls (and re-rolls) the first-lock can ===
do
  fresh()
  check(story.VERMILION_CITY.onEnter ~= nil, "VERMILION_CITY.onEnter registered")
  feed(3) -- Random & $0e ported as random(0,7)*2
  story.VERMILION_CITY.onEnter(fakeGame, {})
  eq(fakeGame.save.trashPuzzle.first, 6, "map load rolls the first-lock can (even index)")
  feed(0)
  story.VERMILION_CITY.onEnter(fakeGame, {})
  eq(fakeGame.save.trashPuzzle.first, 0, "every map load re-rolls, not just the first")
  -- the roll is unconditional: mid-second-stage state survives, only
  -- the (unread) first index changes
  fakeGame.save.flags.EVENT_1ST_LOCK_OPENED = true
  fakeGame.save.trashPuzzle.second = 3
  feed(5)
  story.VERMILION_CITY.onEnter(fakeGame, {})
  eq(fakeGame.save.trashPuzzle.first, 10, "re-roll happens even after the 1st lock opened")
  check(fakeGame.save.flags.EVENT_1ST_LOCK_OPENED, "map load leaves EVENT_1ST_LOCK_OPENED alone")
  eq(fakeGame.save.trashPuzzle.second, 3, "map load leaves the second-lock can alone")
end

-- === wrong first can: plain trash text, nothing opens ===
do
  fresh()
  fakeGame.save.trashPuzzle = { first = 0 }
  fakeSelf:trashCanSwitch(2)
  check(not fakeGame.save.flags.EVENT_1ST_LOCK_OPENED, "wrong first can opens nothing")
  eq(shown[#shown], t._VermilionGymTrashText, "wrong first can shows VermilionGymTrashText")
end

-- === right first can: EVENT_1ST_LOCK_OPENED + GymTrashCans second roll ===
-- can 0's row is `mask 2, candidates 1,3`: AND result 2 -> byte offset 1
-- -> candidate 2 (=can 3); candidate 1 is unreachable with mask 2
do
  fresh()
  fakeGame.save.trashPuzzle = { first = 0 }
  feed(2) -- random byte: band(2, mask 2) = 2
  fakeSelf:trashCanSwitch(0)
  check(fakeGame.save.flags.EVENT_1ST_LOCK_OPENED == true, "right can sets EVENT_1ST_LOCK_OPENED")
  eq(fakeGame.save.trashPuzzle.second, 3, "mask 2 reaches only the 2nd candidate (can 3)")
  eq(shown[#shown], t._VermilionGymTrashSuccessText1, "first lock shows SuccessText1")
end

-- zero AND result: `dec a` underflows and the read lands in zero
-- padding -> the second switch is can 0 regardless of adjacency
do
  fresh()
  fakeGame.save.trashPuzzle = { first = 0 }
  feed(13) -- band(13, mask 2) = 0
  fakeSelf:trashCanSwitch(0)
  eq(fakeGame.save.trashPuzzle.second, 0, "zero AND puts the second switch in can 0 (the bug)")
end

-- can 4's row is `mask 4, candidates 1,3,5,7`: only AND result 4
-- (-> 4th candidate, can 7) or the can-0 bug are possible
do
  fresh()
  fakeGame.save.trashPuzzle = { first = 4 }
  feed(4) -- band(4, mask 4) = 4
  fakeSelf:trashCanSwitch(4)
  eq(fakeGame.save.trashPuzzle.second, 7, "mask 4 reaches only the 4th candidate (can 7)")
  fresh()
  fakeGame.save.trashPuzzle = { first = 4 }
  feed(3) -- band(3, mask 4) = 0
  fakeSelf:trashCanSwitch(4)
  eq(fakeGame.save.trashPuzzle.second, 0, "mask 4 with a zero AND falls into can 0")
end

-- can 6's row is `mask 3, candidates 3,7,9`: results 1-3 map onto all
-- three candidates
do
  for _, case in ipairs({ { 1, 3 }, { 2, 7 }, { 3, 9 }, { 4, 0 } }) do
    fresh()
    fakeGame.save.trashPuzzle = { first = 6 }
    feed(case[1]) -- band(case[1], mask 3): 4 -> 0 (bug), else itself
    fakeSelf:trashCanSwitch(6)
    eq(fakeGame.save.trashPuzzle.second, case[2],
       ("mask 3, AND=%d -> second can %d"):format(case[1] % 4, case[2]))
  end
end

-- === wrong second can: relock + IMMEDIATE first-can re-roll ===
do
  fresh()
  fakeGame.save.flags.EVENT_1ST_LOCK_OPENED = true
  fakeGame.save.trashPuzzle = { first = 0, second = 3 }
  feed(5) -- the fail path's Random & $e -> can 10
  fakeSelf:trashCanSwitch(1)
  check(not fakeGame.save.flags.EVENT_1ST_LOCK_OPENED, "fail resets EVENT_1ST_LOCK_OPENED")
  eq(fakeGame.save.trashPuzzle.first, 10, "fail immediately re-rolls the first can")
  eq(fakeGame.save.trashPuzzle.second, nil, "fail clears the second-lock can")
  eq(shown[#shown], t._VermilionGymTrashFailText, "fail shows VermilionGymTrashFailText")
end

-- === right second can: puzzle done, door block opens, only Text3 ===
do
  fresh()
  fakeGame.save.flags.EVENT_1ST_LOCK_OPENED = true
  fakeGame.save.trashPuzzle = { first = 0, second = 3 }
  fakeSelf:trashCanSwitch(3)
  check(fakeGame.save.flags.EVENT_2ND_LOCK_OPENED == true, "second lock sets EVENT_2ND_LOCK_OPENED")
  eq(shown[#shown], t._VermilionGymTrashSuccessText3,
     "only SuccessText3 prints (SuccessText2 is unused in pokered)")
  check(replaced[1] and replaced[1].x == 2 and replaced[1].y == 2 and replaced[1].b == 5,
        "door block (2,2) replaced with the clear floor block")
  -- solved puzzle: every can is plain trash from now on
  fakeSelf:trashCanSwitch(0)
  eq(shown[#shown], t._VermilionGymTrashText, "solved puzzle shows plain trash text")
end

-- === legacy saves: pre-rewrite `opened1` migrates to the event flag ===
do
  fresh()
  fakeGame.save.trashPuzzle = { first = 0, opened1 = true, second = 3 }
  fakeSelf:trashCanSwitch(3)
  check(fakeGame.save.flags.EVENT_2ND_LOCK_OPENED == true,
        "legacy opened1 save still completes the puzzle")
end

love.math.random = realRandom
setUpvalue(OW.trashCanSwitch, "TextBox", realTextBox)

S.finish()
