//go:build integration

package inttest

import (
	"context"
	"encoding/json"
	"errors"
	"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/gamelimits"
	"scrabble/backend/internal/lobby"
	"scrabble/backend/internal/server"
	"scrabble/backend/internal/social"
)

// The game-limit suite covers the per-tier, per-kind active-game caps (backend.config): the
// game_kind tag persisted per creation path, the tier resolution + counting rule
// (game.Service.AtGameLimit), the HTTP gate + lobby at_game_limit flag, the guest gate on friend
// actions, and the config hot-cache reflecting an admin edit. Accepting an invitation stays exempt.

// newGameLimits builds a gamelimits service over the shared pool and loads the seeded config
// (guest 1/1/0, durable 10/10/10).
func newGameLimits(t *testing.T) *gamelimits.Service {
	t.Helper()
	gl := gamelimits.NewService(gamelimits.NewStore(testDB))
	if err := gl.Load(context.Background()); err != nil {
		t.Fatalf("load game limits: %v", err)
	}
	return gl
}

// restoreDefaultLimits resets the single config row to the migration seed on cleanup, so a test that
// edited it never leaks its values into another (the row is a shared singleton).
func restoreDefaultLimits(t *testing.T, gl *gamelimits.Service) {
	t.Helper()
	t.Cleanup(func() {
		_ = gl.Update(context.Background(), gamelimits.Config{
			Guest:   gamelimits.Limits{VsAI: 1, Random: 1, Friends: 0},
			Durable: gamelimits.Limits{VsAI: 10, Random: 10, Friends: 10},
		})
	})
}

// gameKind reads a game's persisted game_kind tag.
func gameKind(t *testing.T, gameID uuid.UUID) int16 {
	t.Helper()
	var k int16
	if err := testDB.QueryRowContext(context.Background(),
		`SELECT game_kind FROM backend.games WHERE game_id=$1`, gameID).Scan(&k); err != nil {
		t.Fatalf("read game_kind: %v", err)
	}
	return k
}

// mustCreateKind creates an active game seating the accounts, tagged with kind, and returns its id.
func mustCreateKind(t *testing.T, games *game.Service, seats []uuid.UUID, kind gamelimits.Kind) uuid.UUID {
	t.Helper()
	g, err := games.Create(context.Background(), game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Kind: kind,
	})
	if err != nil {
		t.Fatalf("create game (kind=%d): %v", kind, err)
	}
	return g.ID
}

// startFriendGameID has inviter invite invitee to a friend game and the invitee accept, returning
// the started game's id.
func startFriendGameID(t *testing.T, inv *lobby.InvitationService, inviter, invitee uuid.UUID) uuid.UUID {
	t.Helper()
	ctx := context.Background()
	invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
	if err != nil {
		t.Fatalf("create invitation: %v", err)
	}
	got, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true)
	if err != nil {
		t.Fatalf("accept invitation: %v", err)
	}
	if got.GameID == nil {
		t.Fatal("accepted invitation has no game id")
	}
	return *got.GameID
}

// TestGameKindPersisted checks each creation path stamps the right game_kind: random (auto-match)
// =2, vs_ai =1, friend (by invitation) =3.
func TestGameKindPersisted(t *testing.T) {
	ctx := context.Background()
	clearOpenGames(t)
	games := newGameService()
	inv := newInvitationService()
	human, opp := provisionAccount(t), provisionAccount(t)

	rnd, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour}, time.Now().Add(time.Minute), nil)
	if err != nil {
		t.Fatalf("open random game: %v", err)
	}
	if k := gameKind(t, rnd.ID); k != int16(gamelimits.KindRandom) {
		t.Errorf("random game_kind = %d, want %d", k, gamelimits.KindRandom)
	}

	ai := mustCreateKind(t, games, []uuid.UUID{human, opp}, gamelimits.KindVsAI)
	if k := gameKind(t, ai); k != int16(gamelimits.KindVsAI) {
		t.Errorf("vs_ai game_kind = %d, want %d", k, gamelimits.KindVsAI)
	}

	friend := startFriendGameID(t, inv, human, opp)
	if k := gameKind(t, friend); k != int16(gamelimits.KindFriends) {
		t.Errorf("friend game_kind = %d, want %d", k, gamelimits.KindFriends)
	}
}

// TestGuestActiveGameLimitHTTP drives the guest random cap through the assembled server: the first
// auto-match opens a game, the lobby then flags at_game_limit, and a second enqueue is refused 409
// game_limit_reached. It also checks the guest vs_ai and friends caps resolve at the domain level.
func TestGuestActiveGameLimitHTTP(t *testing.T) {
	ctx := context.Background()
	clearOpenGames(t)
	gl := newGameLimits(t)
	games := newGameService()
	games.SetGameLimits(gl)
	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(),
		GameLimits:  gl,
	})

	guest := provisionGuest(t)
	if gamesListAtLimit(t, srv, guest) {
		t.Fatal("a fresh guest must be under the random game limit")
	}
	// The first auto-match opens a random game (guest random cap = 1).
	if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK {
		t.Fatalf("first enqueue = %d (%s), want 200", rec.Code, rec.Body.String())
	}
	// At the cap: the lobby flags it and a second enqueue is refused with the stable code.
	if !gamesListAtLimit(t, srv, guest) {
		t.Fatal("after one random game the guest must be at the limit")
	}
	if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
		t.Fatalf("second enqueue = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
	}

	// A guest is refused the friends flow outright at the HTTP edge (403 guest_forbidden).
	opp := provisionAccount(t)
	invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
	if rec := userPost(t, srv, "/api/v1/user/invitations", guest, invBody); rec.Code != http.StatusForbidden || errorCode(t, rec) != "guest_forbidden" {
		t.Fatalf("guest invitation = (%d, %q), want (403, guest_forbidden)", rec.Code, errorCode(t, rec))
	}

	// The guest vs_ai cap is 1: one vs_ai game puts the guest at the vs_ai limit.
	mustCreateKind(t, games, []uuid.UUID{guest, opp}, gamelimits.KindVsAI)
	if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindVsAI); err != nil || !at {
		t.Fatalf("guest vs_ai at-limit = (%v, %v), want (true, nil)", at, err)
	}
	// The guest friends cap is 0: a guest is always at the friends limit (the 403 gate blocks first).
	if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindFriends); err != nil || !at {
		t.Fatalf("guest friends at-limit = (%v, %v), want (true, nil)", at, err)
	}
}

// TestDurableTierHigherLimit checks a durable account resolves the durable tier, not the guest one:
// one random game leaves it well under the durable cap (10).
func TestDurableTierHigherLimit(t *testing.T) {
	ctx := context.Background()
	gl := newGameLimits(t)
	games := newGameService()
	games.SetGameLimits(gl)
	durable, opp := provisionAccount(t), provisionAccount(t)

	mustCreateKind(t, games, []uuid.UUID{durable, opp}, gamelimits.KindRandom)
	if at, err := games.AtGameLimit(ctx, durable, gamelimits.KindRandom); err != nil || at {
		t.Fatalf("durable random at-limit after one game = (%v, %v), want (false, nil)", at, err)
	}
}

// TestGuestForbiddenFriendActions checks a guest is refused every friend action server-side (the UI
// hides them; this is the source of truth): creating an invitation, sending a friend request, and
// redeeming a friend code.
func TestGuestForbiddenFriendActions(t *testing.T) {
	ctx := context.Background()
	guest := provisionGuest(t)
	other := provisionAccount(t)

	inv := newInvitationService()
	if _, err := inv.CreateInvitation(ctx, guest, []uuid.UUID{other}, englishInvite()); !errors.Is(err, lobby.ErrGuestForbidden) {
		t.Fatalf("guest CreateInvitation err = %v, want ErrGuestForbidden", err)
	}

	soc := newSocialService()
	if err := soc.SendFriendRequest(ctx, guest, other); !errors.Is(err, social.ErrGuestForbidden) {
		t.Fatalf("guest SendFriendRequest err = %v, want ErrGuestForbidden", err)
	}
	if _, err := soc.RedeemFriendCode(ctx, guest, "000000"); !errors.Is(err, social.ErrGuestForbidden) {
		t.Fatalf("guest RedeemFriendCode err = %v, want ErrGuestForbidden", err)
	}
}

// TestDurableFriendsCapAndConfigCache lowers the durable friends cap to 1 through the service (the
// admin-edit path), checks a durable inviter is refused a second friend game with 409, and that
// accepting an incoming invitation is still exempt from the cap.
func TestDurableFriendsCapAndConfigCache(t *testing.T) {
	ctx := context.Background()
	gl := newGameLimits(t)
	restoreDefaultLimits(t, gl)
	cfg := gl.Get()
	cfg.Durable.Friends = 1
	if err := gl.Update(ctx, cfg); err != nil {
		t.Fatalf("update durable friends cap: %v", err)
	}
	games := newGameService()
	games.SetGameLimits(gl)
	inv := lobby.NewInvitationService(lobby.NewStore(testDB), games, account.NewStore(testDB), newSocialService())

	inviter := provisionAccount(t)
	d2, d3 := provisionAccount(t), provisionAccount(t)

	// The inviter's first friend game reaches the (lowered) cap of 1.
	startFriendGameID(t, inv, inviter, d2)
	if at, err := games.AtGameLimit(ctx, inviter, gamelimits.KindFriends); err != nil || !at {
		t.Fatalf("inviter friends at-limit = (%v, %v), want (true, nil)", at, err)
	}
	// A second invitation is refused: the cache reflects the edit.
	if _, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{d3}, englishInvite()); !errors.Is(err, game.ErrGameLimitReached) {
		t.Fatalf("second invitation err = %v, want ErrGameLimitReached", err)
	}
	// Accept stays exempt: someone else invites the capped inviter, who accepts and the game starts.
	startFriendGameID(t, inv, d3, inviter)
}

// 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
}
