//go:build integration

package inttest

import (
	"context"
	"testing"
	"time"

	"github.com/google/uuid"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/game"
)

// setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or
// idle) at a chosen instant, independent of wall time.
func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(),
		`UPDATE backend.games SET turn_started_at = $2 WHERE game_id = $1`, id, at); err != nil {
		t.Fatalf("set turn_started_at: %v", err)
	}
}

// isRobotAccount reports whether the account carries a robot identity.
func isRobotAccount(t *testing.T, id uuid.UUID) bool {
	t.Helper()
	var n int
	if err := testDB.QueryRowContext(context.Background(),
		`SELECT count(*) FROM backend.identities WHERE account_id = $1 AND kind = 'robot'`, id).Scan(&n); err != nil {
		t.Fatalf("count robot identity: %v", err)
	}
	return n > 0
}

// countNudges counts the nudges senderID has sent in a game.
func countNudges(t *testing.T, gameID, senderID uuid.UUID) int {
	t.Helper()
	var n int
	if err := testDB.QueryRowContext(context.Background(),
		`SELECT count(*) FROM backend.chat_messages WHERE game_id = $1 AND sender_id = $2 AND kind = 'nudge'`,
		gameID, senderID).Scan(&n); err != nil {
		t.Fatalf("count nudges: %v", err)
	}
	return n
}

// daytime is a fixed instant whose hour is awake for every sleep drift (the
// always-awake band is [10,21) local), used to drive robot moves deterministically.
var daytime = time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC)

// TestRobotPoolProvisionsRobotAccounts checks EnsurePool creates durable,
// chat/friend-blocked robot accounts (exercising the kind='robot' migration) and
// is idempotent.
func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
	ctx := context.Background()
	r := newRobotService(t, newGameService())
	if err := r.EnsurePool(ctx); err != nil {
		t.Fatalf("ensure pool: %v", err)
	}
	if err := r.EnsurePool(ctx); err != nil {
		t.Fatalf("ensure pool (idempotent): %v", err)
	}
	id, err := r.Pick(engine.VariantEnglish)
	if err != nil {
		t.Fatalf("pick: %v", err)
	}
	if !isRobotAccount(t, id) {
		t.Errorf("picked account %s is not a robot identity", id)
	}
	if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
		t.Errorf("scrabble_ru pick = (%s, %v), want a robot account", ru, err)
	}
	acc, err := account.NewStore(testDB).GetByID(ctx, id)
	if err != nil {
		t.Fatalf("get robot account: %v", err)
	}
	// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
	// expires, mirroring a human who ignores it.
	if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
		t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
			acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
	}
}

// TestRobotPlaysAutoMatchToEnd drives a robot through a full two-player game (the
// human plays greedily) and checks it finishes with a robot statistics row. The
// robot is forced due each turn by resetting the turn clock and driving at a fixed
// daytime instant, so the game does not depend on wall time.
func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	robots := newRobotService(t, svc)
	if err := robots.EnsurePool(ctx); err != nil {
		t.Fatalf("ensure pool: %v", err)
	}
	robotID, err := robots.Pick(engine.VariantEnglish)
	if err != nil {
		t.Fatalf("pick: %v", err)
	}
	human := provisionAccount(t)
	seed := openingSeed(t)

	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID},
		TurnTimeout: 24 * time.Hour, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	robotSeat := 1 // seats = [human, robot]

	finished := false
	for i := 0; i < 400 && !finished; i++ {
		_, toMove, status, err := svc.Participants(ctx, g.ID)
		if err != nil {
			t.Fatalf("participants: %v", err)
		}
		if status != game.StatusActive {
			finished = true
			break
		}
		if toMove == robotSeat {
			setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour)) // well past any sampled delay
			robots.Drive(ctx, daytime)
			continue
		}
		playHuman(t, ctx, svc, g.ID, human)
	}
	if !finished {
		t.Fatal("robot game did not finish within the move budget")
	}

	if _, _, _, mg, _, ok := readStats(t, robotID); !ok || mg < 0 {
		t.Errorf("robot must have a statistics row after a finished game (found=%v, maxGame=%d)", ok, mg)
	}
}

// TestMatchmakerSubstitutesRobotEndToEnd checks the reaper fills an open game's empty
// seat with a real robot account once its wait window has elapsed.
func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
	ctx := context.Background()
	clearOpenGames(t)
	robots := newRobotService(t, newGameService())
	if err := robots.EnsurePool(ctx); err != nil {
		t.Fatalf("ensure pool: %v", err)
	}
	// Zero wait and jitter so the opened game is immediately due for a robot.
	mm := newMatchmaker(t, robots, 0, 0)
	human := provisionAccount(t)

	r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true)
	if err != nil {
		t.Fatalf("enqueue: %v", err)
	}
	if r.Matched {
		t.Fatal("first enqueue must open a game awaiting an opponent")
	}

	mm.Reap(ctx, time.Now().Add(time.Second)) // past the (zero) wait window
	seats, _, status, err := newGameService().Participants(ctx, r.Game.ID)
	if err != nil {
		t.Fatalf("participants: %v", err)
	}
	if status != game.StatusActive || len(seats) != 2 {
		t.Fatalf("substituted game: status %q seats %v", status, seats)
	}
	var human0, robot0 bool
	for _, s := range seats {
		switch {
		case s == human:
			human0 = true
		case isRobotAccount(t, s):
			robot0 = true
		}
	}
	if !human0 || !robot0 {
		t.Errorf("substituted seats must be the human and a robot, got %v", seats)
	}
}

// TestRobotProactiveNudge checks the robot's flat proactive-nudge schedule on the human's
// turn: nothing before the 9 h floor of the 9-12 h gap, exactly one once past its 12 h ceiling.
func TestRobotProactiveNudge(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	robots := newRobotService(t, svc)
	if err := robots.EnsurePool(ctx); err != nil {
		t.Fatalf("ensure pool: %v", err)
	}
	robotID, err := robots.Pick(engine.VariantEnglish)
	if err != nil {
		t.Fatalf("pick: %v", err)
	}
	human := provisionAccount(t)
	seed := openingSeed(t)

	// Seat the human first so it is the human's turn and the robot is the awaiter.
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID},
		TurnTimeout: 24 * time.Hour, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}

	// A noon turn start. The drive instants below sit at UTC hours 20:00 and (next day) 12:00,
	// both in [10:00, 20:00] so the robot is awake for every ±3h sleep drift — the gap, not the
	// sleep window, decides the outcome. No nudge at 8 h idle (before the 9 h floor); exactly one
	// at 24 h idle (well past the 12 h ceiling, however the seed drew within 9-12 h).
	start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
	setTurnStarted(t, g.ID, start)

	robots.Drive(ctx, start.Add(8*time.Hour))
	if n := countNudges(t, g.ID, robotID); n != 0 {
		t.Errorf("robot nudges = %d at 8h idle, want 0 (before the 9h floor)", n)
	}
	robots.Drive(ctx, start.Add(24*time.Hour))
	if n := countNudges(t, g.ID, robotID); n != 1 {
		t.Errorf("robot nudges = %d at 24h idle, want 1 (past the 12h ceiling)", n)
	}
}

// playHuman makes a greedy human move: the top candidate, else an exchange, else a
// pass.
func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, human uuid.UUID) {
	t.Helper()
	cands, err := svc.Candidates(ctx, gameID, human)
	if err != nil {
		t.Fatalf("human candidates: %v", err)
	}
	if len(cands) > 0 {
		if _, err := svc.SubmitPlay(ctx, gameID, human, cands[0].Tiles); err != nil {
			t.Fatalf("human play: %v", err)
		}
		return
	}
	st, err := svc.GameState(ctx, gameID, human)
	if err != nil {
		t.Fatalf("human state: %v", err)
	}
	if len(st.Rack) > 0 && st.BagLen >= len(st.Rack) {
		if _, err := svc.Exchange(ctx, gameID, human, st.Rack); err != nil {
			t.Fatalf("human exchange: %v", err)
		}
		return
	}
	if _, err := svc.Pass(ctx, gameID, human); err != nil {
		t.Fatalf("human pass: %v", err)
	}
}

// countMoves returns the number of committed moves in a game's journal.
func countMoves(t *testing.T, gameID uuid.UUID) int {
	t.Helper()
	var n int
	if err := testDB.QueryRowContext(context.Background(),
		`SELECT count(*) FROM backend.game_moves WHERE game_id = $1`, gameID).Scan(&n); err != nil {
		t.Fatalf("count moves: %v", err)
	}
	return n
}

// setLastTwoMoveTimes rewrites the created_at of a game's two most recent journal entries
// so the endgame think-time anchor (their gap, the human's last-move think time) is a known
// value, independent of the millisecond spacing of the manufactured passes.
func setLastTwoMoveTimes(t *testing.T, gameID uuid.UUID, prevAt, lastAt time.Time) {
	t.Helper()
	ctx := context.Background()
	if _, err := testDB.ExecContext(ctx,
		`UPDATE backend.game_moves SET created_at = $2
		   WHERE game_id = $1 AND seq = (SELECT max(seq) FROM backend.game_moves WHERE game_id = $1)`,
		gameID, lastAt); err != nil {
		t.Fatalf("set last move time: %v", err)
	}
	if _, err := testDB.ExecContext(ctx,
		`UPDATE backend.game_moves SET created_at = $2
		   WHERE game_id = $1 AND seq = (SELECT max(seq) - 1 FROM backend.game_moves WHERE game_id = $1)`,
		gameID, prevAt); err != nil {
		t.Fatalf("set prev move time: %v", err)
	}
}

// TestRobotEndgamePassShrinksThinkTime checks the dead-drawn-endgame shrink end to end: when
// the two most recent moves are both passes, RobotTurns reports EndgamePass with the human's
// last-move think time (OppLastMove), and the driver answers on the shortened schedule — well
// before the normal late-game delay — rather than dragging out the decided game. The journal
// state is manufactured with direct passes so the test isolates the timing mechanism (the
// SQL anchor and the driver gate) from a full game to an empty bag.
func TestRobotEndgamePassShrinksThinkTime(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	robots := newRobotService(t, svc)
	if err := robots.EnsurePool(ctx); err != nil {
		t.Fatalf("ensure pool: %v", err)
	}
	robotID, err := robots.Pick(engine.VariantEnglish)
	if err != nil {
		t.Fatalf("pick: %v", err)
	}
	human := provisionAccount(t)
	seed := openingSeed(t)

	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID},
		TurnTimeout: 24 * time.Hour, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	const robotSeat = 1
	store := game.NewStore(testDB)

	turnOf := func(id uuid.UUID) game.RobotTurn {
		t.Helper()
		turns, err := store.RobotTurns(ctx, []uuid.UUID{robotID})
		if err != nil {
			t.Fatalf("robot turns: %v", err)
		}
		for _, rt := range turns {
			if rt.GameID == id {
				return rt
			}
		}
		t.Fatalf("no robot turn for game %s", id)
		return game.RobotTurn{}
	}

	// One pass so far (the human): not yet a double-pass endgame.
	if _, err := svc.Pass(ctx, g.ID, human); err != nil {
		t.Fatalf("human pass 1: %v", err)
	}
	if rt := turnOf(g.ID); rt.EndgamePass {
		t.Fatalf("EndgamePass after a single pass = true, want false")
	}

	// Robot then human pass: the two most recent moves are now both passes and it is the
	// robot's turn — the guaranteed-pass endgame state.
	if _, err := svc.Pass(ctx, g.ID, robotID); err != nil {
		t.Fatalf("robot pass: %v", err)
	}
	if _, err := svc.Pass(ctx, g.ID, human); err != nil {
		t.Fatalf("human pass 2: %v", err)
	}
	if _, toMove, _, err := svc.Participants(ctx, g.ID); err != nil || toMove != robotSeat {
		t.Fatalf("after two passes: toMove %d err %v, want robot seat %d", toMove, err, robotSeat)
	}

	// Anchor the human's last-move think time to 60s and start the robot's turn at daytime.
	setLastTwoMoveTimes(t, g.ID, daytime.Add(-60*time.Second), daytime)
	setTurnStarted(t, g.ID, daytime)

	rt := turnOf(g.ID)
	if !rt.EndgamePass {
		t.Fatalf("EndgamePass after a double pass = false, want true")
	}
	if d := rt.OppLastMove; d < 59*time.Second || d > 61*time.Second {
		t.Fatalf("OppLastMove = %s, want ~60s", d)
	}

	// The normal schedule for this move is at least the early band floor (~3.75 min); the
	// 60s-anchored endgame delay is at most 90s for every seed. Driving at +150s is past the
	// shrunk delay but well before the normal one, so the robot acts only because of the shrink.
	before := countMoves(t, g.ID)
	robots.Drive(ctx, daytime.Add(150*time.Second))
	if after := countMoves(t, g.ID); after != before+1 {
		t.Fatalf("robot did not act on the shrunk endgame schedule: moves %d → %d (want +1)", before, after)
	}
	if _, toMove, _, err := svc.Participants(ctx, g.ID); err != nil || toMove == robotSeat {
		t.Fatalf("after the shrunk move: still the robot's turn (toMove %d, err %v)", toMove, err)
	}
}
