-- Talking a trainer into battle must start the encounter sting (#764).
-- TalkToTrainer (pokered home/trainers.asm:88) prints the before-battle
-- text and then `call EngageMapTrainer` -> PlayTrainerMusic
-- (home/trainers.asm:399): evil list, female list, male by default, rivals
-- excluded.  The port only ran the sting on the sight-line path
-- (startTrainerApproach), so a trainer challenged from the side or back --
-- and every scripted battle routed through ow:engageTrainer -- went into
-- the battle in map music.  Asserts the talk path now plays the class
-- sting, skips rivals, and does not restart it when the sight path
-- (self.engaging) already did.
-- ROM-free: stubs Game/TextBox/BattleState/Music around engageTrainer.
package.path = "./?.lua;./?/init.lua;" .. package.path

local T = require("tests.modkit")
local OW = require("src.world.OverworldController")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")

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 pushed = {}
local stackStub = {
  push = function(_, item) pushed[#pushed + 1] = item end,
}
local textBoxStub = {
  new = function(_, text, onDone) return { text = text, onDone = onDone } end,
  substitute = function(_, text) return text end,
}

-- Music / BattleState are required lazily at the call site; stub via
-- package.loaded (same trick as tests/engine/oaks_pc_flow.lua)
local plays = {}
local realMusic = package.loaded["src.core.Music"]
package.loaded["src.core.Music"] = {
  play = function(_, song) plays[#plays + 1] = song end,
}
local realBattle = package.loaded["src.battle.BattleState"]
package.loaded["src.battle.BattleState"] = {
  newTrainer = function() return {} end,
}

local fakeGame = {
  data = {
    text = {},
    trainerHeader = function() return nil end,
    resolveText = function() return "You looked at me\nfunny!" end,
  },
  stack = stackStub,
}
T.check(setUpvalue(OW.engageTrainer, "Game", fakeGame),
  "Game upvalue on engageTrainer")
T.check(setUpvalue(OW.engageTrainer, "TextBox", textBoxStub),
  "TextBox upvalue on engageTrainer")

-- pushBattle would touch the real stack machinery; the engagement is over
-- by the time it runs, so a no-op keeps the test on the music question
local fakeSelf = setmetatable({
  map = { def = { label = "Route24" } },
  pushBattle = function() end,
}, { __index = OW })

-- run engageTrainer for one class and return what the sting played
local function stingFor(cls, engaging)
  pushed, plays = {}, {}
  fakeSelf.engaging = engaging
  fakeSelf:engageTrainer({ id = "npc#1", def = { trainerClass = cls,
                                                 trainerParty = 1, index = 1 } })
  T.eq(#pushed, 1, "engageTrainer pushes the before-battle text")
  T.eq(#plays, 0, "no sting while the dialogue is still up (" .. cls .. ")")
  pushed[1].onDone() -- close the box; TalkToTrainer engages here
  return plays[1], #plays
end

-- PlayTrainerMusic's three buckets (data/trainers/encounter_types.asm)
T.eq(stingFor("OPP_LASS"), "Music_MeetFemaleTrainer",
  "female-list class plays the female sting")
T.eq(stingFor("OPP_ROCKET"), "Music_MeetEvilTrainer",
  "evil-list class plays the evil sting")
T.eq(stingFor("OPP_YOUNGSTER"), "Music_MeetMaleTrainer",
  "any other class defaults to the male sting")

-- the rivals `ret z` out of PlayTrainerMusic; their scripts run
-- MUSIC_MEET_RIVAL themselves (data/scripts/oaks_lab.lua)
local _, rivalCount = stingFor("OPP_RIVAL1")
T.eq(rivalCount, 0, "rival classes play no encounter sting here")

-- sight path already engaged: TrainerEngage started the sting before the
-- "!" bubble, and TalkToTrainer's BIT_SEEN_BY_TRAINER guard keeps the
-- talk path from restarting it
local _, seenCount = stingFor("OPP_LASS", true)
T.eq(seenCount, 0, "self.engaging suppresses a second sting")

-- A deferred preparation may cancel instead of constructing a battle. For a
-- sight trainer, that must leave a one-position latch: otherwise the still
-- undefeated adjacent trainer sees the stationary player again next frame and
-- immediately reopens the preparation screen.
local oldEvents, oldHooks, oldErrors = Runtime.events, Runtime.hooks,
  Runtime.errors
local cancelHooks = Hooks.new()
Runtime.install(oldEvents, cancelHooks, oldErrors)
cancelHooks:wrap("trainer.before_battle", function(_, _, _, continue)
  continue({ cancel = true })
  return true
end, 0, "cancel_probe")
local cancelNpc = { id = "npc#cancel", cellX = 0, cellY = -1,
  facing = "down", moving = false, def = { trainerClass = "OPP_LASS",
    trainerParty = 1, index = 1 } }
fakeSelf.player = { cellX = 0, cellY = 0, moving = false }
fakeSelf.map.id = "FIX_ROUTE"
fakeSelf.engaging = false
pushed, plays = {}, {}
local completed = 0
fakeSelf:engageTrainer(cancelNpc, function() completed = completed + 1 end)
pushed[1].onDone()
T.eq(completed, 1, "cancel completes the deferred encounter without a battle")
T.same(fakeSelf.cancelledTrainerSight, {
  npcId = "npc#cancel", playerX = 0, playerY = 0,
}, "cancel suppresses immediate sight re-entry at the current player cell")

local approaches = 0
fakeSelf.npcs = { cancelNpc }
fakeSelf.trainerDefeated = function() return false end
fakeSelf.startTrainerApproach = function() approaches = approaches + 1 end
fakeGame.stack.top = function() return fakeSelf end
fakeGame.data.trainerHeader = function() return { range = 2 } end
T.check(setUpvalue(OW.checkTrainerSight, "mapScripts", {
  talkScript = function() return nil end,
}), "mapScripts upvalue on checkTrainerSight")
fakeSelf:checkTrainerSight()
T.eq(approaches, 0,
  "a cancelled adjacent trainer cannot reacquire the stationary player")
fakeSelf.player.cellX = 1
fakeSelf:checkTrainerSight()
T.eq(fakeSelf.cancelledTrainerSight, nil,
  "moving one cell releases the cancelled sight latch")
fakeSelf.player.cellX = 0
fakeSelf:checkTrainerSight()
T.eq(approaches, 1,
  "returning to the sight line permits a fresh trainer challenge")
Runtime.install(oldEvents, oldHooks, oldErrors)

-- OverworldState is a singleton reused by StateStack. A title/load cycle must
-- clear this volatile latch too, or CONTINUE at the same map and cell inherits
-- the cancelled sight suppression from the previous session.
local Camera = require("src.render.Camera")
local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter")
local ScriptRunner = require("src.script.ScriptRunner")
local oldCameraNew, oldCollisionLoad = Camera.new, Collision.load
local oldEncounterLoad, oldRunnerNew = Encounter.load, ScriptRunner.new
local oldGameModule = package.loaded["src.core.Game"]
local oldScriptsModule = package.loaded["data.scripts.init"]
Camera.new = function() return {} end
Collision.load = function() end
Encounter.load = function() end
ScriptRunner.new = function() return {} end
package.loaded["src.core.Game"] = {
  data = {}, save = { lastOutdoor = "FIX_ROUTE" },
}
package.loaded["data.scripts.init"] = {}
local lifecycle = setmetatable({
  cancelledTrainerSight = {
    npcId = "FIX_ROUTE_obj_1", playerX = 0, playerY = 0,
  },
  setMap = function() end,
  refreshStandingOnWarp = function() end,
}, { __index = OW })
lifecycle:enter("FIX_ROUTE", 0, 0, "down", { via = "boot" })
T.eq(lifecycle.cancelledTrainerSight, nil,
  "fresh overworld entry clears a cancelled trainer sight latch")
Camera.new, Collision.load = oldCameraNew, oldCollisionLoad
Encounter.load, ScriptRunner.new = oldEncounterLoad, oldRunnerNew
package.loaded["src.core.Game"] = oldGameModule
package.loaded["data.scripts.init"] = oldScriptsModule

if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic
else package.loaded["src.core.Music"] = nil end
if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle
else package.loaded["src.battle.BattleState"] = nil end

T.finish("trainer_talk_sting_bug764")
