package plugin

// Initiative + N-body turn-engine tests (N3/P3).
//
// The solo path is pinned bit-for-bit by TestCombatCharacterization for
// auto-resolve, and by the fixed-order assertions below for the turn engine.
// Everything else here exercises seats the production code cannot reach until
// P4 gives a session its participant rows.

import (
	"testing"
)

// partyStep drives one engine step over a roster without touching the DB.
func partyStep(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
	seat := enemySeat
	if sess.Phase == CombatPhasePlayerTurn {
		order := turnOrder(sess, sess.Round, players, enemy)
		seat = order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)]
	}
	te := resumeTurnEngine(sess, players, enemy, combatSessionStepRNG(sess, seat))
	events, err := te.step(action)
	if err != nil {
		return nil, err
	}
	te.commit()
	return events, nil
}

func TestTurnOrder_SoloIsAlwaysPlayerThenEnemy(t *testing.T) {
	sess := turnSession(CombatPhasePlayerTurn, 100, 100)
	p, e := basePlayer(), baseEnemy()
	// A blisteringly fast monster still swings second: the turn engine has
	// never rolled initiative for a duel, and P3 must not start.
	e.Stats.Speed = 999
	p.Mods.InitiativeBias = -50

	for round := 1; round <= 5; round++ {
		got := turnOrder(sess, round, []*Combatant{&p}, &e)
		if len(got) != 2 || got[0] != 0 || got[1] != enemySeat {
			t.Fatalf("round %d solo order = %v, want [0 %d]", round, got, enemySeat)
		}
	}
}

func TestTurnOrder_PartySeatsEveryoneOnceAndIsDeterministic(t *testing.T) {
	sess := turnSession(CombatPhasePlayerTurn, 100, 100)
	a, b, c := basePlayer(), basePlayer(), basePlayer()
	e := baseEnemy()
	players := []*Combatant{&a, &b, &c}

	order := turnOrder(sess, 3, players, &e)
	if len(order) != 4 {
		t.Fatalf("order = %v, want 4 slots (3 players + enemy)", order)
	}
	seen := map[int]bool{}
	for _, seat := range order {
		if seen[seat] {
			t.Fatalf("seat %d appears twice in %v", seat, order)
		}
		seen[seat] = true
	}
	if !seen[0] || !seen[1] || !seen[2] || !seen[enemySeat] {
		t.Fatalf("order %v does not seat every combatant", order)
	}

	// Same (session, round) must rebuild the same order — that is what lets a
	// resumed fight land on the seat it left off on.
	again := turnOrder(sess, 3, players, &e)
	for i := range order {
		if order[i] != again[i] {
			t.Fatalf("order not stable across rebuilds: %v vs %v", order, again)
		}
	}
}

func TestTurnOrder_InitiativeBiasWins(t *testing.T) {
	sess := turnSession(CombatPhasePlayerTurn, 100, 100)
	slow, fast := basePlayer(), basePlayer()
	fast.Mods.InitiativeBias = 100 // dwarfs the d10 spread
	e := baseEnemy()

	order := turnOrder(sess, 1, []*Combatant{&slow, &fast}, &e)
	if order[0] != 1 {
		t.Errorf("order = %v, want the biased seat 1 first", order)
	}
}

func TestCombatSessionStepRNG_SeatZeroMatchesLegacyStream(t *testing.T) {
	sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhasePlayerTurn}
	if combatSessionStepRNG(sess, 0).Uint64() != combatSessionRNG(sess).Uint64() {
		t.Error("seat 0 must draw the pre-roster stream — the solo turn engine depends on it")
	}
	// The enemy sentinel shares seat 0's seed; its phase already separates it.
	if combatSessionStepRNG(sess, enemySeat).Uint64() != combatSessionRNG(sess).Uint64() {
		t.Error("the enemy sentinel must not perturb the seed")
	}
	// Party members share a (round, phase) pair, so only the seed keeps their
	// d20s apart.
	if combatSessionStepRNG(sess, 1).Uint64() == combatSessionStepRNG(sess, 0).Uint64() {
		t.Error("seats 0 and 1 drew the same stream in the same phase")
	}
	if combatSessionStepRNG(sess, 1).Uint64() == combatSessionStepRNG(sess, 2).Uint64() {
		t.Error("seats 1 and 2 drew the same stream in the same phase")
	}
}

func TestTurnIdxForPhase_ReconcilesARowWrittenBeforeTheCursorExisted(t *testing.T) {
	solo := []int{0, enemySeat}
	// A fight suspended mid-enemy-turn decodes TurnIdx as 0. Phase is the older
	// field and wins, so the cursor must snap to the enemy's slot — otherwise
	// the engine re-runs the enemy turn forever.
	if got := turnIdxForPhase(solo, 0, CombatPhaseEnemyTurn); got != 1 {
		t.Errorf("legacy enemy_turn row reconciled to idx %d, want 1", got)
	}
	if got := turnIdxForPhase(solo, 0, CombatPhasePlayerTurn); got != 0 {
		t.Errorf("player_turn row = idx %d, want 0", got)
	}
	// An out-of-range cursor (roster shrank) falls back to the phase.
	if got := turnIdxForPhase(solo, 7, CombatPhaseEnemyTurn); got != 1 {
		t.Errorf("out-of-range cursor = idx %d, want 1", got)
	}
	// A party order keeps an already-consistent cursor exactly where it is.
	party := []int{1, enemySeat, 0}
	if got := turnIdxForPhase(party, 2, CombatPhasePlayerTurn); got != 2 {
		t.Errorf("consistent cursor moved to idx %d, want 2", got)
	}
}

func TestTurnEngine_SoloRoundIsPlayerEnemyRoundEnd(t *testing.T) {
	sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
	p, e := basePlayer(), baseEnemy()
	players := []*Combatant{&p}

	for _, want := range []string{CombatPhaseEnemyTurn, CombatPhaseRoundEnd, CombatPhasePlayerTurn} {
		if _, err := partyStep(sess, players, &e, PlayerAction{Kind: ActionAttack}); err != nil {
			t.Fatal(err)
		}
		if sess.Phase != want {
			t.Fatalf("phase = %q, want %q", sess.Phase, want)
		}
	}
	if sess.Round != 2 {
		t.Errorf("round = %d, want 2 after one full round", sess.Round)
	}
	if sess.Statuses.TurnIdx != 0 {
		t.Errorf("solo cursor = %d, want 0 — it should never leave the first slot at a player turn", sess.Statuses.TurnIdx)
	}
}

func TestTurnEngine_PartyRoundGivesEverySeatATurn(t *testing.T) {
	sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
	a, b := basePlayer(), basePlayer()
	e := baseEnemy()
	e.Stats.MaxHP = 100000
	players := []*Combatant{&a, &b}
	order := turnOrder(sess, 1, players, &e)

	// Walk the round slot by slot, asserting each phase matches the order.
	for i, seat := range order {
		if sess.Phase != phaseForSeat(seat) {
			t.Fatalf("slot %d: phase = %q, want %q for seat %d", i, sess.Phase, phaseForSeat(seat), seat)
		}
		if sess.Statuses.TurnIdx != i {
			t.Fatalf("slot %d: cursor = %d", i, sess.Statuses.TurnIdx)
		}
		if _, err := partyStep(sess, players, &e, PlayerAction{Kind: ActionAttack}); err != nil {
			t.Fatal(err)
		}
	}
	if sess.Phase != CombatPhaseRoundEnd {
		t.Fatalf("phase after every seat acted = %q, want round_end", sess.Phase)
	}
	if _, err := partyStep(sess, players, &e, PlayerAction{}); err != nil {
		t.Fatal(err)
	}
	if sess.Round != 2 || sess.Statuses.TurnIdx != 0 {
		t.Errorf("round_end left round=%d cursor=%d, want 2 and 0", sess.Round, sess.Statuses.TurnIdx)
	}
}

func TestTurnEngine_DownedSeatForfeitsItsTurnSilently(t *testing.T) {
	// Force a party order whose first slot is a player, then kill that player.
	sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
	a, b := basePlayer(), basePlayer()
	e := baseEnemy()
	players := []*Combatant{&a, &b}
	order := turnOrder(sess, 1, players, &e)
	first := order[0]
	if first == enemySeat {
		t.Skip("this round's initiative put the enemy first; the seat-skip path is covered by the next slot")
	}

	te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, first))
	te.st.actors[first].playerHP = 0
	events, err := te.step(PlayerAction{Kind: ActionAttack})
	if err != nil {
		t.Fatal(err)
	}
	if len(events) != 0 {
		t.Errorf("a downed seat emitted %d events, want none", len(events))
	}
	if !sess.IsActive() {
		t.Error("one downed member ended the fight while another still stands")
	}
	if sess.Statuses.TurnIdx != 1 {
		t.Errorf("cursor = %d, want the next slot", sess.Statuses.TurnIdx)
	}
}

func TestTurnEngine_EnemyOnlySwingsAtStandingMembers(t *testing.T) {
	sess := turnSession(CombatPhaseEnemyTurn, 10000, 10000)
	a, b := basePlayer(), basePlayer()
	a.Stats.MaxHP, b.Stats.MaxHP = 10000, 10000
	e := baseEnemy()
	players := []*Combatant{&a, &b}

	// Seat 0 is down. Every enemy turn must land on seat 1, never on the corpse,
	// and never end the fight.
	for round := 1; round <= 25; round++ {
		sess.Round = round
		sess.Phase = CombatPhaseEnemyTurn
		sess.Statuses.TurnIdx = 1
		te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
		te.st.actors[0].playerHP = 0
		before := te.st.actors[1].playerHP
		te.step(PlayerAction{})
		if te.st.actors[0].playerHP != 0 {
			t.Fatal("the enemy healed a corpse")
		}
		if !sess.IsActive() {
			t.Fatalf("round %d: fight ended with a member still standing (%d HP)", round, before)
		}
	}
}

func TestTurnEngine_RosterWipeEndsTheFight(t *testing.T) {
	sess := turnSession(CombatPhaseEnemyTurn, 1, 10000)
	a, b := basePlayer(), basePlayer()
	e := baseEnemy()
	players := []*Combatant{&a, &b}

	te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
	te.st.actors[0].playerHP = 0
	te.st.actors[1].playerHP = 0
	if _, err := te.step(PlayerAction{}); err != nil {
		t.Fatal(err)
	}
	if sess.Status != CombatStatusLost {
		t.Errorf("status = %q, want %q once the whole roster is down", sess.Status, CombatStatusLost)
	}
}

// commit persists seat 0, not whoever the cursor happens to be parked on. The
// enemy turn parks it on its target; round_end walks it across the roster.
func TestTurnEngine_CommitPersistsSeatZeroNotTheCursor(t *testing.T) {
	sess := turnSession(CombatPhaseRoundEnd, 500, 10000)
	a, b := basePlayer(), basePlayer()
	e := baseEnemy()
	players := []*Combatant{&a, &b}

	te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
	te.st.actors[0].playerHP = 300
	te.st.actors[1].playerHP = 77
	te.st.actors[1].luckyUsed = true
	if _, err := te.step(PlayerAction{}); err != nil {
		t.Fatal(err)
	}
	te.commit()

	if sess.PlayerHP != 300 {
		t.Errorf("persisted PlayerHP = %d, want seat 0's 300", sess.PlayerHP)
	}
	if sess.Statuses.LuckyUsed {
		t.Error("seat 1's consumed Lucky reroll leaked onto the session row")
	}
}

// A lingering concentration pulse that lands the killing blow must still give a
// revive-armed boss (survive_at_1 / T6 Valdris's phylactery rebirth) its chance
// to stand back up — the round-end tick routes the kill through enemyDown, not a
// raw enemyHP<=0 read. Regression for the concentration-bypass gap found in the
// P8 Layer-2 review.
func TestTurnEngine_ConcentrationKillHonorsRebirth(t *testing.T) {
	// A charged rebirth: the pulse drops the enemy, a charge spends, it revives.
	sess := turnSession(CombatPhaseRoundEnd, 500, 30)
	p := basePlayer()
	e := baseEnemy()
	te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
	te.st.concentrationDmg = 100 // lethal against 30 HP
	te.st.enemyReviveCharges = 1
	te.st.enemyReviveHP = 40
	if _, err := te.step(PlayerAction{}); err != nil {
		t.Fatal(err)
	}
	te.commit()

	if !sess.IsActive() {
		t.Fatalf("a concentration kill ended the fight (%q) while a rebirth charge was armed", sess.Status)
	}
	if sess.EnemyHP != 40 {
		t.Errorf("revived EnemyHP = %d, want the 40-HP revive pool", sess.EnemyHP)
	}
	if sess.Statuses.EnemyReviveCharges != 0 {
		t.Errorf("post-revive charges = %d, want 0 (one spent)", sess.Statuses.EnemyReviveCharges)
	}
	rebirths := 0
	for _, ev := range sess.TurnLog {
		if ev.Action == "phylactery_rebirth" {
			rebirths++
		}
	}
	if rebirths != 1 {
		t.Errorf("phylactery_rebirth events = %d, want 1", rebirths)
	}

	// With no charge left, the same pulse ends the fight cleanly (the win path
	// is not broken by the enemyDown routing).
	mortal := turnSession(CombatPhaseRoundEnd, 500, 30)
	p2 := basePlayer()
	e2 := baseEnemy()
	te2 := resumeTurnEngine(mortal, []*Combatant{&p2}, &e2, combatSessionStepRNG(mortal, enemySeat))
	te2.st.concentrationDmg = 100
	if _, err := te2.step(PlayerAction{}); err != nil {
		t.Fatal(err)
	}
	te2.commit()
	if mortal.Status != CombatStatusWon {
		t.Errorf("charge-less concentration kill status = %q, want %q", mortal.Status, CombatStatusWon)
	}
}
