-- Parity / regression: a box mon has no stat block, so the engine derives one
-- on demand (#233 STATS in the box, #304 withdraw).  macros/ram.asm's
-- box_struct is a prefix of party_struct that stops before MON_STATS;
-- status_screen.asm:64-77 recomputes on BOX_DATA before it draws, and
-- add_mon.asm's _MoveMon runs CalcStats on the way back to the party.  The
-- formula itself is home/move_mon.asm CalcStats, ported in Stats.lua.
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.pokemon and Data.pokemon.ODDISH) then Data:load() end
local S = require("tests.harness").suite("parity box mon stats")
local check, eq = S.check, S.eq

local Stats = require("src.pokemon.Stats")
local SaveData = require("src.core.SaveData")
local Boxes = require("src.pokemon.Boxes")
local HudTiles = require("src.render.HudTiles")

-- A mon shaped like GenSave.decodeMon's box output: no `stats` key at all.
-- Gen 1 has no HP DV byte, it is the low bit of each of the other four
-- (home/pokemon.asm GetMonHeader), so 13/11/7/5 being odd makes the HP DV
-- 15.  The .sav round trip below only compares because of that.
local DVS = { hp = 15, attack = 13, defense = 11, speed = 7, special = 5 }
local STATEXP = { hp = 1200, attack = 900, defense = 400, speed = 0, special = 250 }

local function boxShapedMon(species, level, hp)
  return {
    species = species, level = level, exp = 1000,
    dvs = { hp = DVS.hp, attack = DVS.attack, defense = DVS.defense,
            speed = DVS.speed, special = DVS.special },
    statExp = { hp = STATEXP.hp, attack = STATEXP.attack,
                defense = STATEXP.defense, speed = STATEXP.speed,
                special = STATEXP.special },
    hp = hp, status = nil, otId = 12345, catchRate = 45,
    moves = { { id = "ABSORB", pp = 20 } },
    typeBytes = { 22, 3 },
  }
end

-- ================================================================== 1
-- Stats.ensure gives the same numbers as Stats.calc and never leaves a
-- current HP above the maximum it derived.
do
  local def = Data.pokemon.ODDISH
  local want = Stats.calc(def, 13, DVS, STATEXP)

  local mon = boxShapedMon("ODDISH", 13, 999) -- a PKHeX-tampered current HP
  eq(mon.stats, nil, "premise: a box-shaped mon starts with no stat block")
  Stats.ensure(def, mon)
  check(type(mon.stats) == "table", "ensure gives the box mon a stat block")
  for _, key in ipairs(Stats.ORDER) do
    eq(mon.stats[key], want[key], "ensure's " .. key .. " matches CalcStats")
  end
  eq(mon.hp, want.hp, "a tampered 999 HP clamps to the recomputed maximum")

  -- a stored HP under the maximum is what box_struct really holds; keep it
  local hurt = boxShapedMon("ODDISH", 13, 7)
  Stats.ensure(def, hurt)
  eq(hurt.hp, 7, "a stored current HP below the max is kept as stored")
  eq(hurt.stats.hp, want.hp, "and the maximum is the derived one")

  -- a fainted box mon stays fainted (the REVIVE case in the party menu)
  local fainted = boxShapedMon("ODDISH", 13, 0)
  Stats.ensure(def, fainted)
  eq(fainted.hp, 0, "a fainted box mon stays at 0 HP")

  -- idempotent, so a vanilla save round-trips with the numbers it was saved
  -- with rather than recomputed ones
  local party = boxShapedMon("ODDISH", 13, 7)
  party.stats = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 }
  local before = party.stats
  Stats.ensure(def, party)
  check(party.stats == before, "ensure leaves an existing stat block alone")
  eq(party.stats.hp, 1, "and does not clamp HP against a block it did not build")
  eq(party.hp, 7, "nor rewrite the stored HP")

  -- a species the merged data does not know must not raise here: validate's
  -- own quarantine pass owns that mon, and ensure runs before it
  local unknown = boxShapedMon("NOT_A_SPECIES", 13, 7)
  Stats.ensure(nil, unknown)
  eq(unknown.stats, nil, "an unknown species leaves stats nil instead of raising")
end

-- ================================================================== 2
-- The real .sav path: encode -> decode is what an imported battery save does
-- to a box mon (SaveFileIO -> SaveConvert.importSav -> GenSave.decode).
do
  local GenSave = require("src.save_convert.GenSave")
  GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())

  local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
  Boxes.ensure(save)
  -- the same mon in the party and in box 1, so the two structs can be
  -- compared against each other after the round trip
  save.party = { boxShapedMon("ODDISH", 13, 20) }
  Stats.ensure(Data.pokemon.ODDISH, save.party[1])
  save.boxes[1] = { boxShapedMon("ODDISH", 13, 20) }
  Stats.ensure(Data.pokemon.ODDISH, save.boxes[1][1])

  local bytes = GenSave.encode(save, Data, nil)
  eq(#bytes, GenSave.SAVE_SIZE, "the fixture save encodes to a 32768-byte .sav")

  local imported = GenSave.decode(bytes, Data)
  check(type(imported.party[1]) == "table", "the .sav decodes a party mon")
  check(type((imported.boxes[1] or {})[1]) == "table", "and a box 1 mon")
  local pmon, bmon = imported.party[1], imported.boxes[1][1]
  check(type(pmon.stats) == "table",
        "premise: party_struct carries stats, so the party mon has them")
  eq(bmon.stats, nil,
     "premise: box_struct does not, so the imported box mon has none (#233)")

  -- Game:restoreSave runs this on every load, which is the only thing that
  -- reaches a save already sitting on disk
  SaveData.validate(imported, Data)
  -- guarded: without the repair every line below raises the reported crash
  -- itself, which would hide the rest of the suite
  if check(type(bmon.stats) == "table", "validate repairs the imported box mon") then
    local want = Stats.calc(Data.pokemon[bmon.species], bmon.level, bmon.dvs,
                            bmon.statExp)
    for _, key in ipairs(Stats.ORDER) do
      eq(bmon.stats[key], want[key], "repaired box " .. key .. " matches CalcStats")
    end
    eq(bmon.stats.hp, pmon.stats.hp,
       "the box copy ends up with the same max HP the party copy stored")
    check(bmon.hp <= bmon.stats.hp, "current HP can never sit above the maximum")
    -- the exact expression src/render/HudTiles.lua drawHPBar died on
    check(type(bmon.stats.hp) == "number",
          "mon.stats.hp is a number (the nil index)")
  end

  -- the call SummaryMenu:draw and PartyMenu:draw both make (#233 / #304)
  local okDraw, err = pcall(HudTiles.drawHPBar, Data, 11, 3, bmon, 1)
  check(okDraw, "drawHPBar over a repaired box mon does not raise: " .. tostring(err))

  -- box_struct has no stat fields, so the repair must not move a byte of the
  -- exported .sav
  local reexported = GenSave.encode(imported, Data)
  eq(#reexported, GenSave.SAVE_SIZE, "the repaired save re-exports to 32768 bytes")
  local firstDiff
  for i = 1, #bytes do
    if bytes:byte(i) ~= reexported:byte(i) then firstDiff = i break end
  end
  eq(firstDiff, nil,
     "the stat repair leaves the .sav byte-identical on export (box_struct "
     .. "has no stat fields)")
end

-- ================================================================== 3
-- Every list a stats-less mon can sit in: party, all twelve boxes, and the
-- daycare (status_screen.asm treats DAYCARE_DATA like BOX_DATA).
do
  local save = SaveData.newGame()
  Boxes.ensure(save)
  save.party = { boxShapedMon("PIDGEY", 9, 5) }
  save.boxes[1] = { boxShapedMon("RATTATA", 4, 3) }
  save.boxes[7] = { boxShapedMon("ZUBAT", 22, 40) }
  save.daycare = { mon = boxShapedMon("ODDISH", 13, 6) }

  SaveData.validate(save, Data)
  check(type(save.party[1].stats) == "table", "validate repairs a party slot")
  check(type(save.boxes[1][1].stats) == "table", "validate repairs box 1")
  check(type(save.boxes[7][1].stats) == "table", "validate repairs box 7")
  check(type(save.daycare.mon.stats) == "table", "validate repairs the daycare mon")

  -- the level clamp runs first, so the derived stats use a sane level
  local wild = boxShapedMon("PIDGEY", 250, 5)
  local save2 = SaveData.newGame()
  Boxes.ensure(save2)
  save2.boxes[1] = { wild }
  SaveData.validate(save2, Data)
  eq(wild.level, 100, "an out-of-range level clamps to 100")
  eq(wild.stats and wild.stats.hp,
     Stats.calc(Data.pokemon.PIDGEY, 100, wild.dvs, wild.statExp).hp,
     "and the derived stats use the clamped level, not the stored one")
end

-- ================================================================== 4
-- #233's own site: SummaryMenu.new ports status_screen.asm's
-- .DontRecalculate branch, and Bill's PC hands it the box mon directly.
do
  local realSound = package.loaded["src.core.Sound"]
  package.loaded["src.core.Sound"] = { play = function() end,
                                       playCry = function() end }
  local SummaryMenu = require("src.ui.SummaryMenu")
  local mon = boxShapedMon("ODDISH", 13, 20)
  local game = { data = Data, save = SaveData.newGame() }
  local ok, screen = pcall(SummaryMenu.new, game, mon)
  check(ok, "opening STATS on a stats-less box mon does not raise: "
            .. tostring(screen))
  check(type(mon.stats) == "table",
        "SummaryMenu.new recalculates the block before it draws (#233)")
  if ok and screen then
    local okDraw, err = pcall(function()
      HudTiles.drawHPBar(Data, 11, 3, screen.mon, 1)
    end)
    check(okDraw, "and its HP bar draws: " .. tostring(err))
  end
  package.loaded["src.core.Sound"] = realSound
end

-- ================================================================== 5
-- #304's own site: the box -> party transfer, which is the only thing that
-- covers a mon a mod drops into a box mid-session.  withdraw is a
-- file-local, reached through upvalues as tests/parity_bills_pc.lua does.
do
  local BoxMenu = require("src.ui.BoxMenu")

  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 withdraw = getUpvalue(BoxMenu.new, "withdraw")
  check(type(withdraw) == "function", "found BoxMenu's withdraw upvalue")

  if type(withdraw) == "function" then
    -- capture the list menu's opts instead of building a real one
    local captured
    check(setUpvalue(withdraw, "ListMenu", {
      new = function(_, _, items, opts)
        captured = { items = items, opts = opts }
        return { kind = "list", items = items, close = function() end }
      end,
    }), "stubbed ListMenu on withdraw")
    -- the WITHDRAW row is what fires onAction; run it straight
    check(setUpvalue(withdraw, "monSubmenu",
                     function(_, _, _, onAction) onAction() end),
          "stubbed monSubmenu on withdraw")
    check(setUpvalue(withdraw, "afterTransfer", function() end),
          "stubbed afterTransfer on withdraw")

    local realSound = package.loaded["src.core.Sound"]
    package.loaded["src.core.Sound"] = { play = function() end,
                                         playCry = function() end }

    local save = SaveData.newGame()
    Boxes.ensure(save)
    save.party = {}
    save.boxes[1] = { boxShapedMon("ODDISH", 13, 20) }
    local mon = save.boxes[1][1]
    local game = { data = Data, save = save,
                   stack = { push = function() end, pop = function() end } }

    withdraw(game)
    check(captured ~= nil, "withdraw opened its box list")
    if captured then
      -- engine/menus/players_pc.asm: the list ends in CANCEL (#1847)
      eq(#captured.items, 2, "the list has the one box mon in it, plus CANCEL")
      check(captured.items[2] and captured.items[2].cancel,
            "the last row is the CANCEL terminator")
      captured.opts.onChoose(captured.items[1],
                             { index = 1, close = function() end })
      eq(#save.boxes[1], 0, "the mon left the box")
      eq(#save.party, 1, "and landed in the party")
      eq(save.party[1], mon, "it is the same mon table")
      check(type(mon.stats) == "table",
            "withdraw ran CalcStats on the way out (#304)")
      local want = Stats.calc(Data.pokemon.ODDISH, 13, mon.dvs, mon.statExp)
      eq(mon.stats and mon.stats.hp, want.hp,
         "the withdrawn mon's max HP is the derived one")
      local okDraw, err = pcall(HudTiles.drawHPBar, Data, 5, 5, mon, 2)
      check(okDraw, "the party row's HP bar draws for it: " .. tostring(err))
    end

    package.loaded["src.core.Sound"] = realSound
    -- run_tests.lua dofiles the parity suites in one interpreter, so a later
    -- suite would inherit these stubs
    package.loaded["src.ui.BoxMenu"] = nil
  end
end

S.finish()
