package plugin

import (
	"encoding/json"
	"strings"
	"testing"
	"time"

	"maunium.net/go/mautrix/id"
)

// N3/P5 — the session layer.
//
// Everything here exercises a seated party, which production cannot yet build:
// P6 supplies the invite. The point of testing it now is that P5's mistakes are
// silent ones — a buff landing on the wrong sheet, a latch that resets on every
// step, a solo row that grew a key — and none of them announce themselves.

// ── Seat addressing ──────────────────────────────────────────────────────────

// partySession builds a 3-seat roster in memory, with each seat's HP and
// statuses distinguishable so a mix-up cannot pass.
func partySession(phase string, seatHP ...int) *CombatSession {
	s := turnSession(phase, seatHP[0], 100)
	s.UserID = "@leader:x"
	for i, hp := range seatHP[1:] {
		s.Participants = append(s.Participants, CombatParticipant{
			Seat: i + 1, UserID: memberID(i + 1), HP: hp, HPMax: hp + 10,
		})
	}
	s.rosterSize = len(seatHP)
	return s
}

func memberID(seat int) string {
	return string(rune('a'+seat-1)) + "member:x"
}

func TestSeatAccessors_AddressTheRightRow(t *testing.T) {
	s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
	s.PlayerHPMax = 90

	for seat, want := range []int{40, 55, 70} {
		if got := s.seatHP(seat); got != want {
			t.Errorf("seatHP(%d) = %d, want %d", seat, got, want)
		}
	}
	if got := s.seatHPMax(0); got != 90 {
		t.Errorf("seatHPMax(0) = %d, want the session row's 90", got)
	}
	if got := s.seatHPMax(2); got != 80 {
		t.Errorf("seatHPMax(2) = %d, want the participant row's 80", got)
	}
	if got := s.seatUserID(0); got != "@leader:x" {
		t.Errorf("seatUserID(0) = %q, want the session owner", got)
	}
	if got := s.seatUserID(1); got != memberID(1) {
		t.Errorf("seatUserID(1) = %q, want %q", got, memberID(1))
	}
}

func TestSeatOf_FindsMembersAndRejectsStrangers(t *testing.T) {
	s := partySession(CombatPhasePlayerTurn, 40, 55, 70)

	if seat, ok := s.seatOf("@leader:x"); !ok || seat != 0 {
		t.Errorf("seatOf(leader) = (%d,%v), want (0,true)", seat, ok)
	}
	if seat, ok := s.seatOf(id.UserID(memberID(2))); !ok || seat != 2 {
		t.Errorf("seatOf(member 2) = (%d,%v), want (2,true)", seat, ok)
	}
	if _, ok := s.seatOf("@nobody:x"); ok {
		t.Error("seatOf(stranger) reported a seat — an outsider could act in the fight")
	}
}

// A member's mid-fight buff must land on their own sheet. Before P5 the cast
// path folded every delta into the session's embedded ActorStatuses — seat 0 —
// so a party member casting Shield on themselves armoured the leader instead.
func TestActorStatusesPtr_WritesToTheCastingSeat(t *testing.T) {
	s := partySession(CombatPhasePlayerTurn, 40, 55, 70)

	s.actorStatusesPtr(2).applyBuffDelta(turnBuffDelta{dAC: 5})

	if got := s.Statuses.BuffACBonus; got != 0 {
		t.Errorf("seat 0 picked up seat 2's buff: BuffACBonus = %d, want 0", got)
	}
	if got := s.Participants[1].Statuses.BuffACBonus; got != 5 {
		t.Errorf("seat 2's BuffACBonus = %d, want 5", got)
	}
}

// ── The autopilot latch ──────────────────────────────────────────────────────

// The latch is per-seat session state with no combatState counterpart, so it
// rides through the engine on snapshotActor's carry-over of the prior snapshot —
// the same road the Buff* deltas take. If it did not, an away member would be
// handed the wheel back on every single step and the party would stall anew each
// round.
func TestSnapshotActor_CarriesTheAutopilotLatchAcrossAStep(t *testing.T) {
	c := basePlayer()
	prior := ActorStatuses{Autopilot: true, BuffACBonus: 3}

	got := snapshotActor(newActor(&c), prior)

	if !got.Autopilot {
		t.Error("snapshotActor dropped the autopilot latch — an away member unlatches every step")
	}
	if got.BuffACBonus != 3 {
		t.Errorf("snapshotActor dropped a carried-over buff: BuffACBonus = %d, want 3", got.BuffACBonus)
	}
}

func TestSeatNeedsNoHuman(t *testing.T) {
	s := partySession(CombatPhasePlayerTurn, 40, 0, 70)
	s.Participants[1].Statuses.Autopilot = true

	if s.seatNeedsNoHuman(0) {
		t.Error("a standing, unlatched seat needs its human")
	}
	if !s.seatNeedsNoHuman(1) {
		t.Error("a downed seat must not block the round")
	}
	if !s.seatNeedsNoHuman(2) {
		t.Error("a latched seat must resolve without waiting")
	}
}

// ── Wire compatibility ───────────────────────────────────────────────────────

// Two fields landed on persisted structs in P5. Both must vanish from a solo
// row, which is every row prod holds today: statuses_json and turn_log_json are
// decoded by readers that predate them, and a fight in flight across the deploy
// must resume byte-identically.
func TestP5Fields_StayOffSoloRows(t *testing.T) {
	raw, err := json.Marshal(CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 1}})
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(string(raw), "autopilot") {
		t.Errorf("a solo statuses_json carries the autopilot key: %s", raw)
	}

	raw, err = json.Marshal(CombatEvent{Actor: "player", Action: "hit", Damage: 4})
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(string(raw), "Seat") {
		t.Errorf("a solo turn_log event carries the Seat key: %s", raw)
	}

	// …and both must survive the round trip when they are set.
	raw, _ = json.Marshal(ActorStatuses{Autopilot: true})
	var back ActorStatuses
	if err := json.Unmarshal(raw, &back); err != nil {
		t.Fatal(err)
	}
	if !back.Autopilot {
		t.Errorf("autopilot did not round-trip: %s", raw)
	}
}

// ── Event attribution ────────────────────────────────────────────────────────

// biasedParty builds a roster whose initiative order is fixed at [0,1,2,enemy],
// so a test can park the cursor on a known seat.
func biasedParty() ([]*Combatant, *Combatant) {
	a, b, c := basePlayer(), basePlayer(), basePlayer()
	a.Name, b.Name, c.Name = "Ada", "Bram", "Cass"
	a.Mods.InitiativeBias, b.Mods.InitiativeBias, c.Mods.InitiativeBias = 300, 200, 100
	e := baseEnemy()
	e.Stats.Speed = 1
	return []*Combatant{&a, &b, &c}, &e
}

func TestTurnEngine_StampsEventsWithTheSeatThatActed(t *testing.T) {
	players, enemy := biasedParty()
	sess := partySession(CombatPhasePlayerTurn, 100, 100, 100)
	sess.Statuses.TurnIdx = 1 // Bram's turn

	events, err := partyStep(sess, players, enemy, PlayerAction{Kind: ActionAttack})
	if err != nil {
		t.Fatal(err)
	}
	if len(events) == 0 {
		t.Fatal("Bram's turn produced no events")
	}
	for _, e := range events {
		if e.Seat != 1 {
			t.Errorf("event %+v stamped seat %d, want 1 (Bram swung)", e, e.Seat)
		}
	}
}

// Solo events must all be seat 0 — that is what keeps the field omitted from
// every solo turn_log_json.
func TestTurnEngine_SoloEventsAreAllSeatZero(t *testing.T) {
	sess := turnSession(CombatPhasePlayerTurn, 100, 100)
	p, e := basePlayer(), baseEnemy()

	events, err := partyStep(sess, []*Combatant{&p}, &e, PlayerAction{Kind: ActionAttack})
	if err != nil {
		t.Fatal(err)
	}
	for _, ev := range events {
		if ev.Seat != 0 {
			t.Errorf("solo event %+v stamped seat %d, want 0", ev, ev.Seat)
		}
	}
}

// The flavor pool speaks in the second person. A round must therefore read
// differently to each member: your own swing is "You score 9 damage", your
// ally's is "**Cass** hits Rat for 9". Getting this backwards tells three people
// they each personally landed the same blow.
func TestRenderPartyTurnRound_SecondPersonForTheReaderThirdForAllies(t *testing.T) {
	events := []CombatEvent{
		{Actor: "player", Action: "hit", Damage: 7, Seat: 0},
		{Actor: "player", Action: "hit", Damage: 9, Seat: 2},
	}
	names := []string{"Ada", "Bram", "Cass"}

	// The reader's own line comes from the flavor pool, whose phrasings vary
	// ("You score 9 damage", "A measured strike. 9 damage."). What is invariant
	// is that it never names them in the third person, and that the ally's line
	// always does.
	ada := RenderPartyTurnRound(events, names, "Rat", 0)
	if strings.Contains(ada, "**Ada**") {
		t.Errorf("Ada's own swing was rendered in the third person:\n%s", ada)
	}
	if !strings.Contains(ada, "**Cass** hits Rat for 9.") {
		t.Errorf("Ada should read Cass's swing in the third person:\n%s", ada)
	}
	if strings.Contains(ada, "Bram") {
		t.Errorf("Bram did nothing this round but was named:\n%s", ada)
	}

	cass := RenderPartyTurnRound(events, names, "Rat", 2)
	if strings.Contains(cass, "**Cass**") {
		t.Errorf("Cass's own swing was rendered in the third person:\n%s", cass)
	}
	if !strings.Contains(cass, "**Ada** hits Rat for 7.") {
		t.Errorf("Cass should read Ada's swing in the third person:\n%s", cass)
	}
}

// A downed ally is the one thing a member must not miss in their own DM.
func TestRenderAllySeatEvent_FlagsAnAllyGoingDown(t *testing.T) {
	got := renderAllySeatEvent(
		CombatEvent{Actor: "enemy", Action: "hit", Damage: 12, PlayerHP: 0, Seat: 1}, "Bram", "Rat")
	if !strings.Contains(got, "is down") {
		t.Errorf("an ally dropping to 0 HP should be called out, got %q", got)
	}

	standing := renderAllySeatEvent(
		CombatEvent{Actor: "enemy", Action: "hit", Damage: 3, PlayerHP: 20, Seat: 1}, "Bram", "Rat")
	if strings.Contains(standing, "is down") {
		t.Errorf("a standing ally was reported down: %q", standing)
	}
}

// An event whose seat has fallen off the roster falls back to seat 0 rather
// than panicking on a replayed turn_log.
func TestRenderPartyTurnRound_OutOfRangeSeatFallsBack(t *testing.T) {
	got := RenderPartyTurnRound(
		[]CombatEvent{{Actor: "player", Action: "hit", Damage: 3, Seat: 9}}, []string{"Ada"}, "Rat", 0)
	if got == "" || strings.Contains(got, "without a clean blow") {
		t.Errorf("out-of-range seat should still render a line, got:\n%s", got)
	}
}

// ── The turn deadline ────────────────────────────────────────────────────────

func TestTurnDeadlineLapsed(t *testing.T) {
	fresh := time.Now().UTC()
	stale := fresh.Add(-partyTurnDeadline - time.Second)

	cases := []struct {
		name  string
		phase string
		at    time.Time
		want  bool
	}{
		{"fresh player turn", CombatPhasePlayerTurn, fresh, false},
		{"stale player turn", CombatPhasePlayerTurn, stale, true},
		// The enemy's turn belongs to the engine, not to a player who might be
		// away — a fight parked there is mid-resolution, not stalled on anyone.
		{"stale enemy turn", CombatPhaseEnemyTurn, stale, false},
		{"stale round end", CombatPhaseRoundEnd, stale, false},
	}
	for _, tc := range cases {
		s := partySession(tc.phase, 40, 40)
		s.LastActionAt = tc.at
		if got := turnDeadlineLapsed(s); got != tc.want {
			t.Errorf("%s: turnDeadlineLapsed = %v, want %v", tc.name, got, tc.want)
		}
	}
}

// ── Locking ──────────────────────────────────────────────────────────────────

// A solo fight's owner is the sender. sync.Mutex is not reentrant, so taking the
// fight lock and then the user lock would deadlock the very command every solo
// player types. This test hangs rather than fails if that regresses.
func TestLockCombatFight_SoloTakesExactlyOneLock(t *testing.T) {
	p := &AdventurePlugin{}
	done := make(chan struct{})
	go func() {
		release := p.lockCombatFight("@solo:x", "@solo:x")
		release()
		close(done)
	}()
	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("lockCombatFight self-deadlocked on a solo fight")
	}
}

// A party member's command holds both the fight's lock and their own, so no
// other command of theirs races the round they are resolving.
func TestLockCombatFight_PartyHoldsBothLocks(t *testing.T) {
	p := &AdventurePlugin{}
	release := p.lockCombatFight("@leader:x", "@member:x")

	if p.advUserLock("@leader:x").TryLock() {
		t.Error("the fight's lock (seat 0) was not held")
	}
	if p.advUserLock("@member:x").TryLock() {
		t.Error("the acting member's own lock was not held")
	}
	release()

	if !p.advUserLock("@leader:x").TryLock() {
		t.Error("release() left the fight's lock held")
	}
	if !p.advUserLock("@member:x").TryLock() {
		t.Error("release() left the member's lock held")
	}
}

func TestNotYourTurnMsg_NamesWhoWeAreWaitingOn(t *testing.T) {
	players, _ := biasedParty()
	if got := notYourTurnMsg(players, 1, true); !strings.Contains(got, "Bram") {
		t.Errorf("message should name the acting player, got %q", got)
	}
	if got := notYourTurnMsg(players, 0, false); strings.Contains(got, "Ada") {
		t.Errorf("nobody is on the clock mid-resolution, got %q", got)
	}
}

// ── Starting a fight ─────────────────────────────────────────────────────────

// enemyWithHP is the monster the seats below face. Only its HP pool and its
// Speed matter here: the pool sizes the fight, the speed feeds initiative.
func enemyWithHP(hp int) *Combatant {
	e := baseEnemy()
	e.Stats.MaxHP = hp
	return &e
}

// partySeats pairs each combatant with a seat setup, as buildFightSeats does.
func partySeats(players []*Combatant, hp int) []CombatSeatSetup {
	seats := make([]CombatSeatSetup, len(players))
	for i, c := range players {
		seats[i] = CombatSeatSetup{UserID: id.UserID(memberID(i)), HP: hp, HPMax: hp, C: c}
	}
	seats[0].UserID = "@leader:x"
	return seats
}

func TestStartPartyCombatSession_SoloWritesNoParticipantRows(t *testing.T) {
	setupEmptyTestDB(t)

	sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60),
		[]CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}})
	if err != nil {
		t.Fatal(err)
	}
	if sess.IsParty() || sess.RosterSize() != 1 {
		t.Fatalf("solo session reports a roster of %d", sess.RosterSize())
	}
	ps, err := loadCombatParticipants(sess.SessionID)
	if err != nil {
		t.Fatal(err)
	}
	if len(ps) != 0 {
		t.Errorf("solo fight wrote %d participant rows, want 0", len(ps))
	}
}

// Each seat's fight-start one-shots are read off their own combatant. A party
// Abjurer brings their own Arcane Ward; the leader does not inherit it.
func TestStartPartyCombatSession_SeedsEachSeatsOwnOneShots(t *testing.T) {
	setupEmptyTestDB(t)

	leader, abjurer := basePlayer(), basePlayer()
	sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60), []CombatSeatSetup{
		{UserID: "@leader:x", HP: 40, HPMax: 40, C: &leader},
		{UserID: "@abjurer:x", HP: 30, HPMax: 30, C: &abjurer,
			Mods: CombatModifiers{ArcaneWardHP: 12, WardCharges: 2}},
	})
	if err != nil {
		t.Fatal(err)
	}
	if !sess.IsParty() || sess.RosterSize() != 2 {
		t.Fatalf("roster size = %d, want 2", sess.RosterSize())
	}
	if sess.Statuses.ArcaneWardHP != 0 {
		t.Errorf("the leader inherited the abjurer's ward: %d", sess.Statuses.ArcaneWardHP)
	}

	// …and it must be there after a round trip through the DB.
	back, err := getCombatSession(sess.SessionID)
	if err != nil {
		t.Fatal(err)
	}
	if got := back.actorStatusesForSeat(1); got.ArcaneWardHP != 12 || got.WardCharges != 2 {
		t.Errorf("seat 1 statuses after reload = %+v, want ArcaneWardHP 12 / WardCharges 2", got)
	}
	if back.seatHP(1) != 30 || back.seatUserID(1) != "@abjurer:x" {
		t.Errorf("seat 1 = %d HP / %s", back.seatHP(1), back.seatUserID(1))
	}
}

// A monster that wins initiative swings first. startCombatSession parks every
// fight on a player_turn, which is right for the hardcoded solo order and wrong
// for a party: the resume path would snap the cursor to the first player slot
// and the enemy would silently forfeit round 1.
func TestStartPartyCombatSession_EnemyThatWinsInitiativeOpensTheRound(t *testing.T) {
	setupEmptyTestDB(t)
	players, enemy := biasedParty()
	// Invert the bias biasedParty stacks: the monster now outruns everyone.
	for _, c := range players {
		c.Mods.InitiativeBias = -1000
	}
	enemy.Stats.Speed = 500

	sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
		partySeats(players, 100))
	if err != nil {
		t.Fatal(err)
	}
	if sess.Phase != CombatPhaseEnemyTurn {
		t.Fatalf("round 1 opened on phase %q; the enemy won initiative and must act first", sess.Phase)
	}
	if order := turnOrder(sess, 1, players, enemy); order[0] != enemySeat {
		t.Fatalf("turn order = %v, want the enemy at the head", order)
	}

	// And the phase survives the round trip, since a resume reads it back.
	back, err := getCombatSession(sess.SessionID)
	if err != nil {
		t.Fatal(err)
	}
	if back.Phase != CombatPhaseEnemyTurn {
		t.Errorf("reloaded phase = %q, want the enemy still on the clock", back.Phase)
	}
}

// The other half of the same rule: when the party wins, nothing moved.
func TestStartPartyCombatSession_PartyThatWinsInitiativeStillOpensOnAPlayer(t *testing.T) {
	setupEmptyTestDB(t)
	players, enemy := biasedParty()

	sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
		partySeats(players, 100))
	if err != nil {
		t.Fatal(err)
	}
	if sess.Phase != CombatPhasePlayerTurn || sess.Statuses.TurnIdx != 0 {
		t.Fatalf("round 1 opened on %q/%d, want player_turn at the head of the order",
			sess.Phase, sess.Statuses.TurnIdx)
	}
}

// ── The round driver ─────────────────────────────────────────────────────────

// The old loop rested on any player_turn. A party's downed seat still holds a
// player_turn, so the round would come to rest on a corpse and the fight would
// wait forever for a dead member to type `!attack`.
func TestSettleCombatSession_StepsPastADownedSeat(t *testing.T) {
	setupEmptyTestDB(t)
	players, enemy := biasedParty()

	sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
		partySeats(players, 100))
	if err != nil {
		t.Fatal(err)
	}

	// Bram is down, and it is his turn. biasedParty stacks initiative so the
	// party leads the round; force the phase back to it in case the roll disagrees.
	sess.Participants[0].HP = 0
	sess.Phase = CombatPhasePlayerTurn
	sess.Statuses.TurnIdx = 1

	if _, err := settleCombatSession(sess, players, enemy); err != nil {
		t.Fatal(err)
	}
	if !sess.IsActive() {
		t.Fatalf("fight ended; two members were still standing (status %q)", sess.Status)
	}
	seat, waiting := actingSeat(sess, players, enemy)
	if !waiting {
		t.Fatal("settle came to rest on no player's turn")
	}
	if seat != 2 {
		t.Errorf("settled on seat %d, want seat 2 — seat 1 is down and forfeits", seat)
	}
}

// Settling a session already parked on a standing player's turn must do nothing
// at all. Every solo fight sits exactly there between commands, and a stray step
// would resolve a round the player never asked for.
func TestSettleCombatSession_IsANoOpOnASoloPlayerTurn(t *testing.T) {
	setupEmptyTestDB(t)
	p, e := basePlayer(), baseEnemy()

	sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60),
		[]CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}})
	if err != nil {
		t.Fatal(err)
	}
	before := *sess

	events, err := settleCombatSession(sess, []*Combatant{&p}, &e)
	if err != nil {
		t.Fatal(err)
	}
	if len(events) != 0 {
		t.Errorf("settle emitted %d events on an idle solo turn", len(events))
	}
	if sess.Round != before.Round || sess.Phase != before.Phase || sess.PlayerHP != before.PlayerHP {
		t.Errorf("settle advanced an idle solo fight: %d/%s/%d -> %d/%s/%d",
			before.Round, before.Phase, before.PlayerHP, sess.Round, sess.Phase, sess.PlayerHP)
	}
}
