//go:build integration

package inttest

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/google/uuid"
	"go.uber.org/zap/zaptest"

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

// The game-limit suite covers the simultaneous quick-game cap (game.MaxActiveQuickGames):
// the counting rule (Store/Service.CountActiveQuickGames) and the HTTP gate that refuses a
// new game once the cap is reached, while accepting an incoming invitation stays allowed.

// TestCountActiveQuickGames checks the count includes active and open quick games (the
// honest-AI ones among them) and excludes finished games, friend games (created by
// invitation) and games the account is not seated in.
func TestCountActiveQuickGames(t *testing.T) {
	ctx := context.Background()
	clearOpenGames(t)
	games := newGameService()
	human := provisionAccount(t)
	opp := provisionAccount(t)

	if n := mustCount(t, games, human); n != 0 {
		t.Fatalf("fresh account count = %d, want 0", n)
	}

	// An open (awaiting-opponent) quick game counts.
	if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
		Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
	}, time.Now().Add(time.Minute), nil); err != nil {
		t.Fatalf("open quick game: %v", err)
	}
	// An active quick game and an honest-AI quick game both count (neither has an invitation row).
	mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 1)
	mustCreateQuick(t, games, []uuid.UUID{human, opp}, true, 2)

	// A finished quick game does NOT count.
	fin := mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 3)
	if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, fin); err != nil {
		t.Fatalf("finish game: %v", err)
	}
	// A game the human is not seated in does NOT count.
	mustCreateQuick(t, games, []uuid.UUID{opp, provisionAccount(t)}, false, 4)
	// A friend game (created by invitation) does NOT count, even though it is active.
	startFriendGame(t, human)

	if n := mustCount(t, games, human); n != 3 {
		t.Fatalf("active quick count = %d, want 3 (active + AI + open)", n)
	}
}

// TestGameLimitGate drives the cap through the assembled HTTP server: under the cap the lobby
// reports at_game_limit false; at the cap it flips to true and both new-game endpoints (quick
// enqueue and invitation creation) are refused with 409 game_limit_reached, while accepting an
// incoming invitation is still allowed.
func TestGameLimitGate(t *testing.T) {
	ctx := context.Background()
	games := newGameService()
	srv := server.New(":0", server.Deps{
		Logger:      zaptest.NewLogger(t),
		DB:          testDB,
		Accounts:    account.NewStore(testDB),
		Games:       games,
		Matchmaker:  newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
		Invitations: newInvitationService(),
	})

	human := provisionAccount(t)
	opp := provisionAccount(t)

	// Under the cap: the lobby reports the player is not limited.
	if gamesListAtLimit(t, srv, human) {
		t.Fatal("a fresh account must be under the game limit")
	}

	// Reach the cap with active quick games seating the human (no invitation row → quick).
	for i := 0; i < game.MaxActiveQuickGames; i++ {
		mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, int64(i+1))
	}

	// At the cap: the lobby flags it and both create paths are refused with the stable code.
	if !gamesListAtLimit(t, srv, human) {
		t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
	}
	// erudit_ru is in the default variant preferences, so the variant gate passes and the
	// game-limit gate is what fires here.
	if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
		t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
	}
	invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
	if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
		t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
	}

	// Accepting an incoming invitation is never blocked, even at the cap: another player invites
	// the capped human, who accepts over HTTP and the game starts (friend games do not count).
	inviter := provisionAccount(t)
	inv := newInvitationService()
	invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{human}, englishInvite())
	if err != nil {
		t.Fatalf("create invitation: %v", err)
	}
	if rec := userPost(t, srv, "/api/v1/user/invitations/"+invitation.ID.String()+"/accept", human, ""); rec.Code != http.StatusOK {
		t.Fatalf("accept at limit = %d (%s), want 200 — accept must bypass the cap", rec.Code, rec.Body.String())
	}
}

// mustCount returns the account's active-quick-game count, failing on error.
func mustCount(t *testing.T, games *game.Service, id uuid.UUID) int {
	t.Helper()
	n, err := games.CountActiveQuickGames(context.Background(), id)
	if err != nil {
		t.Fatalf("count active quick games: %v", err)
	}
	return n
}

// mustCreateQuick creates an active quick game (no invitation) seating the given accounts and
// returns its id; vsAI flags an honest-AI game (the service then applies the 7-day clock).
func mustCreateQuick(t *testing.T, games *game.Service, seats []uuid.UUID, vsAI bool, seed int64) uuid.UUID {
	t.Helper()
	p := game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed}
	if vsAI {
		p.VsAI = true
		p.TurnTimeout = 0 // the service applies the 7-day AI inactivity clock for vs_ai games
	}
	g, err := games.Create(context.Background(), p)
	if err != nil {
		t.Fatalf("create quick game (vsAI=%v): %v", vsAI, err)
	}
	return g.ID
}

// startFriendGame has a fresh inviter invite the given account to a friend game and the invitee
// accept it, so the (active) friend game exists with its game_invitations row.
func startFriendGame(t *testing.T, invitee uuid.UUID) {
	t.Helper()
	ctx := context.Background()
	inv := newInvitationService()
	inviter := provisionAccount(t)
	invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
	if err != nil {
		t.Fatalf("create invitation: %v", err)
	}
	if _, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true); err != nil {
		t.Fatalf("accept invitation: %v", err)
	}
}

// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
func gamesListAtLimit(t *testing.T, srv *server.Server, id uuid.UUID) bool {
	t.Helper()
	rec := userGet(t, srv, "/api/v1/user/games", id)
	if rec.Code != http.StatusOK {
		t.Fatalf("games.list status = %d, want 200", rec.Code)
	}
	var body struct {
		AtGameLimit bool `json:"at_game_limit"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode games.list: %v", err)
	}
	return body.AtGameLimit
}

// userPost issues an authenticated JSON POST (X-User-ID) against the assembled server.
func userPost(t *testing.T, srv *server.Server, path string, id uuid.UUID, body string) *httptest.ResponseRecorder {
	t.Helper()
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
	req.Header.Set("X-User-ID", id.String())
	req.Header.Set("Content-Type", "application/json")
	srv.Handler().ServeHTTP(rec, req)
	return rec
}
