package plugin

import (
	"math/rand/v2"
	"testing"
)

// Misty's two round-end procs on the turn-based surface.
//
// Both were built onto every turn-based combatant by DerivePlayerStats and then
// never read: stepRoundEnd had no counterpart to endOfRoundForSeat. The buff was
// simply lost. The debuff was an exploit — a player who declined Misty escaped
// her crowd entirely by fighting with !attack instead of letting the room
// auto-resolve, which needed no discovery at all.
//
// seatEndOfRound is the shared hook. These pin that it fires, that it cannot be
// dodged, and that a character with no Misty history is not touched at all —
// the property that keeps the sim corpus and the golden file still.

// mistyState seats one combatant at hp, cursor armed, with a seeded RNG.
func mistyState(t *testing.T, player *Combatant, hp int) *combatState {
	t.Helper()
	st := testCombatState(hp, 100, 1, rand.New(rand.NewPCG(7, 7)))
	st.actor.c = player
	st.actor.hpMax = player.Stats.MaxHP
	return st
}

// The two procs at certainty, so no test depends on a roll.
func mistyCursed(maxHP, dmg int) *Combatant {
	return &Combatant{
		Name:  "Cursed",
		Stats: CombatStats{MaxHP: maxHP, AC: 10},
		Mods:  CombatModifiers{CrowdRevengeProc: 1.0, CrowdRevengeDmg: dmg},
	}
}

func mistyBuffed(maxHP, heal int) *Combatant {
	return &Combatant{
		Name:  "Buffed",
		Stats: CombatStats{MaxHP: maxHP, AC: 10},
		Mods:  CombatModifiers{MistyHealProc: 1.0, MistyHealAmt: heal},
	}
}

// ── the exploit ──────────────────────────────────────────────────────────────

// The debuff must land at round end in a manual fight, exactly as it does when
// the room auto-resolves. Before seatEndOfRound, !attack was a clean escape.
func TestSeatEndOfRound_CrowdRevengeCannotBeDodgedByFightingManually(t *testing.T) {
	st := mistyState(t, mistyCursed(40, 6), 40)
	over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})

	if over {
		t.Fatal("a 6-damage swing against 40 HP should not end the fight")
	}
	if st.playerHP != 34 {
		t.Errorf("playerHP = %d, want 34 — Misty's crowd took its swing", st.playerHP)
	}
	if !hasAction(st.events, "crowd_revenge") {
		t.Error("no crowd_revenge event on the log")
	}
}

// The debuff can be lethal, and a lethal one with nobody else standing ends the
// fight rather than leaving a corpse to take the next round's turn.
func TestSeatEndOfRound_CrowdRevengeCanEndASoloFight(t *testing.T) {
	st := mistyState(t, mistyCursed(40, 30), 3)
	over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})

	if st.playerHP != 0 {
		t.Fatalf("playerHP = %d, want 0", st.playerHP)
	}
	if !over {
		t.Error("a solo character dropped by the crowd should end the fight")
	}
}

// ── the buff ─────────────────────────────────────────────────────────────────

// The heal fires, caps at max HP, and marks the result so the achievement can
// be granted.
func TestSeatEndOfRound_MistyHealFiresAndCaps(t *testing.T) {
	st := mistyState(t, mistyBuffed(40, 12), 34)
	var res CombatResult
	seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &res)

	if st.playerHP != 40 {
		t.Errorf("playerHP = %d, want 40 — the heal caps at max HP", st.playerHP)
	}
	if !res.MistyHealed {
		t.Error("MistyHealed flag not set")
	}
	if !hasAction(st.events, "misty_heal") {
		t.Error("no misty_heal event on the log")
	}
}

// A character the crowd just dropped is not then healed back up by the same
// hook. The heal is for the living.
func TestSeatEndOfRound_NoHealForACharacterTheCrowdJustDropped(t *testing.T) {
	c := mistyCursed(40, 40)
	c.Mods.MistyHealProc = 1.0
	c.Mods.MistyHealAmt = 20

	st := mistyState(t, c, 10)
	st.actors = append(st.actors, &actor{playerHP: 5}) // an ally keeps the fight alive
	over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})

	if over {
		t.Fatal("an ally is still standing — the fight is not over")
	}
	if st.playerHP != 0 {
		t.Errorf("playerHP = %d, want 0 — the heal must not resurrect them", st.playerHP)
	}
	if hasAction(st.events, "misty_heal") {
		t.Error("misty_heal fired on a downed character")
	}
}

// ── the property that protects the corpus ────────────────────────────────────

// A character with no Misty history is untouched: no HP change, no events, and
// — because both procs short-circuit before st.randFloat() — no dice drawn. If
// the hook drew even one float, every simulated fight would diverge and
// combat_characterization.golden would move.
func TestSeatEndOfRound_UnbuffedCharacterIsUntouchedAndDrawsNoDice(t *testing.T) {
	plain := &Combatant{Name: "Plain", Stats: CombatStats{MaxHP: 40, AC: 10}}

	st := mistyState(t, plain, 40)
	control := mistyState(t, plain, 40) // same seed, never passed to the hook

	if over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{}); over {
		t.Fatal("an unbuffed character cannot be dropped by a hook that does nothing")
	}
	if st.playerHP != 40 || len(st.events) != 0 {
		t.Errorf("unbuffed character was touched: hp=%d events=%d", st.playerHP, len(st.events))
	}
	// The RNG streams must still be in lockstep: the hook consumed nothing.
	if got, want := st.randFloat(), control.randFloat(); got != want {
		t.Errorf("seatEndOfRound consumed RNG: next float %v, want %v", got, want)
	}
}

// ── the wiring ───────────────────────────────────────────────────────────────

// The unit tests above call seatEndOfRound directly, which proves the hook is
// correct but not that stepRoundEnd calls it. This drives the real session API
// through a round_end step. Comment the call out of stepRoundEnd and this test
// reports PlayerHP=40 — the exploit, exactly as it shipped.
func TestStepRoundEnd_WiresSeatEndOfRound(t *testing.T) {
	setupEmptyTestDB(t)
	player := mistyCursed(40, 6)
	enemy := &Combatant{Name: "Target", Stats: CombatStats{MaxHP: 100, AC: 10}}
	sess := &CombatSession{
		SessionID: "wiring", UserID: "@u:example.org", Status: CombatStatusActive,
		PlayerHP: 40, PlayerHPMax: 40, EnemyHP: 100,
		Phase: CombatPhaseRoundEnd, Round: 1,
	}
	if _, err := advanceCombatSession(sess, player, enemy, PlayerAction{}); err != nil {
		t.Fatal(err)
	}
	if sess.PlayerHP != 34 {
		t.Errorf("PlayerHP = %d, want 34 — stepRoundEnd never ran the hook", sess.PlayerHP)
	}
	if !hasAction(sess.TurnLog, "crowd_revenge") {
		t.Error("no crowd_revenge event persisted to the session log")
	}
}
