//go:build integration

package inttest

import (
	"context"
	"database/sql"
	"errors"
	"testing"
	"time"

	"github.com/google/uuid"
	"go.opentelemetry.io/otel/metric/noop"
	"go.uber.org/zap"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/game"
	"scrabble/backend/internal/lobby"
	"scrabble/backend/internal/payments"
	"scrabble/backend/internal/robot"
	"scrabble/backend/internal/social"
)

// Shared fixtures for the Postgres-backed integration suite: the service
// constructors over the shared pool/registry, account provisioning, game
// assembly, and the stats reader. Helpers used by a single test file stay in
// that file; everything reused across files lives here.

// newGameService builds a game service over the shared pool and registry, with a
// deterministic first-move draw so the suite keeps a stable turn order.
func newGameService() *game.Service {
	svc := game.NewService(
		game.NewStore(testDB),
		account.NewStore(testDB),
		testRegistry,
		game.Config{
			DictDir:              dictDir(),
			DictVersion:          testDictVersion,
			TimeoutSweepInterval: time.Minute,
			CacheTTL:             time.Hour,
		},
		zap.NewNop(),
	)
	svc.SetFirstMoveEntropy(seatZeroFirstMove)
	svc.SetHintWallet(newPaymentsService())
	return svc
}

// newPaymentsService builds a payments service over the shared pool (its own in-process read
// cache), for wiring the game hint wallet and the account-merge fold in the integration suite.
func newPaymentsService() *payments.Service {
	return payments.NewService(payments.NewStore(testDB))
}

// seedBenefit writes a payments benefit row for an account+origin directly (bypassing the
// service), used to stand up wallet state a test then spends or merges. A non-zero hints count
// and/or the forever flag are set; a caller wanting a no-ads term sets it via seedBenefitUntil.
func seedBenefit(t *testing.T, id uuid.UUID, origin string, hints int, forever bool) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(),
		`INSERT INTO payments.benefits (account_id, origin, hints, ads_forever) VALUES ($1,$2,$3,$4)
		 ON CONFLICT (account_id, origin) DO UPDATE SET hints = EXCLUDED.hints, ads_forever = EXCLUDED.ads_forever`,
		id, origin, hints, forever); err != nil {
		t.Fatalf("seed benefit: %v", err)
	}
}

// readBenefit reads an account's benefit row for an origin: the hint count and the forever flag
// (both zero/false when the row is absent).
func readBenefit(t *testing.T, id uuid.UUID, origin string) (hints int, forever bool) {
	t.Helper()
	err := testDB.QueryRowContext(context.Background(),
		`SELECT hints, ads_forever FROM payments.benefits WHERE account_id=$1 AND origin=$2`, id, origin).
		Scan(&hints, &forever)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		t.Fatalf("read benefit: %v", err)
	}
	return hints, forever
}

// seatZeroFirstMove is a first-move-draw entropy factory that always elects the first
// listed account as the leader, keeping the integration suite's turn order stable
// despite the real draw's randomness: the first contender draws a blank — the best
// possible tile — and wins outright, the rest draw the first remaining tile. It is a
// factory so each game restarts the one-shot "blank" pick.
func seatZeroFirstMove() func(n int) (int, error) {
	first := true
	return func(n int) (int, error) {
		if first {
			first = false
			return n - 1, nil // a blank sits last in the bag → best rank
		}
		return 0, nil
	}
}

// seatOneFirstMove is a first-move-draw entropy factory that elects the second contender (in
// auto-match, the synthetic opponent) as the leader, so the caller is seated at seat 1: the
// first contender draws the first remaining tile and the second draws a blank, winning.
func seatOneFirstMove() func(n int) (int, error) {
	pick := 0
	return func(n int) (int, error) {
		i := 0
		if pick == 1 {
			i = n - 1 // the second contender draws a blank → best rank
		}
		pick++
		return i, nil
	}
}

// newSocialService builds a social service over the shared pool, reading game
// state through a real game service.
func newSocialService() *social.Service {
	return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService())
}

// newRobotService builds a robot service over games (shared so its moves and the
// test's human moves use the same live-game cache and per-game locks), a fresh
// social service for nudges, and a no-op meter.
func newRobotService(t *testing.T, games *game.Service) *robot.Service {
	t.Helper()
	return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
}

// newMatchmaker builds a matchmaker opening real games and substituting from robots
// after minWait plus a random jitter in [0, jitter).
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, minWait, jitter time.Duration) *lobby.Matchmaker {
	t.Helper()
	return lobby.NewMatchmaker(newGameService(), robots, minWait, jitter, zap.NewNop())
}

// clearOpenGames deletes every open (awaiting-opponent) game so a matchmaking test
// starts from a clean slate: the shared test database is FIFO-joined across tests, so a
// leftover open game would otherwise be joined (or opened-into) instead of a fresh one.
func clearOpenGames(t *testing.T) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(), `DELETE FROM backend.games WHERE status = 'open'`); err != nil {
		t.Fatalf("clear open games: %v", err)
	}
}

// provisionAccount creates a fresh durable account and returns its id.
func provisionAccount(t *testing.T) uuid.UUID {
	t.Helper()
	acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString())
	if err != nil {
		t.Fatalf("provision account: %v", err)
	}
	return acc.ID
}

// provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID {
	t.Helper()
	acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "", "")
	if err != nil {
		t.Fatalf("provision guest: %v", err)
	}
	if !acc.IsGuest {
		t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
	}
	return acc.ID
}

// openingSeed returns a seed whose fresh two-player English opening rack has a
// legal move, so a greedy mirror can drive a game.
func openingSeed(t *testing.T) int64 {
	t.Helper()
	for seed := int64(1); seed <= 200; seed++ {
		g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
		if err != nil {
			t.Fatalf("engine new: %v", err)
		}
		if _, ok := g.HintView(); ok {
			return seed
		}
	}
	t.Fatal("no opening seed found")
	return 0
}

// newMirror builds a parallel engine game with the same seed, used to compute
// legal moves to feed the service under test.
func newMirror(t *testing.T, seed int64, players int) *engine.Game {
	t.Helper()
	g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed})
	if err != nil {
		t.Fatalf("mirror new: %v", err)
	}
	return g
}

// newGameWithSeats creates a started game seating n fresh accounts and returns the
// game id and the seated account ids in seat order.
func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
	t.Helper()
	seats := make([]uuid.UUID, n)
	for i := range seats {
		seats[i] = provisionAccount(t)
	}
	g, err := newGameService().Create(context.Background(), game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
	})
	if err != nil {
		t.Fatalf("create game: %v", err)
	}
	return g.ID, seats
}

// newDraftGame creates a started two-player English game on an opening seed and returns the
// service, game id, seats, and the opening play (from a mirror) used to drive a real commit.
func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) {
	t.Helper()
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	seed := openingSeed(t)
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	hint, ok := newMirror(t, seed, 2).HintView()
	if !ok || len(hint.Tiles) == 0 {
		t.Fatal("no opening move")
	}
	return svc, g.ID, seats, hint
}

// readStats reads an account's statistics row.
func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) {
	t.Helper()
	row := testDB.QueryRowContext(context.Background(),
		`SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id)
	if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return 0, 0, 0, 0, 0, false
		}
		t.Fatalf("read stats: %v", err)
	}
	return wins, losses, draws, maxGame, maxWord, true
}
