package lobby_test

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

	"galaxy/backend/internal/config"
	"galaxy/backend/internal/lobby"
	backendpg "galaxy/backend/internal/postgres"
	pgshared "galaxy/postgres"

	"github.com/google/uuid"
	testcontainers "github.com/testcontainers/testcontainers-go"
	tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"
)

const (
	testImage     = "postgres:16-alpine"
	testUser      = "galaxy"
	testPassword  = "galaxy"
	testDatabase  = "galaxy_backend"
	testSchema    = "backend"
	testStartup   = 90 * time.Second
	testOpTimeout = 10 * time.Second
)

func startPostgres(t *testing.T) *sql.DB {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
	t.Cleanup(cancel)

	pgContainer, err := tcpostgres.Run(ctx, testImage,
		tcpostgres.WithDatabase(testDatabase),
		tcpostgres.WithUsername(testUser),
		tcpostgres.WithPassword(testPassword),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).
				WithStartupTimeout(testStartup),
		),
	)
	if err != nil {
		t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
	}
	t.Cleanup(func() {
		if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
			t.Errorf("terminate postgres container: %v", termErr)
		}
	})
	baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
	if err != nil {
		t.Fatalf("connection string: %v", err)
	}
	scopedDSN, err := dsnWithSearchPath(baseDSN, testSchema)
	if err != nil {
		t.Fatalf("scope dsn: %v", err)
	}
	cfg := pgshared.DefaultConfig()
	cfg.PrimaryDSN = scopedDSN
	cfg.OperationTimeout = testOpTimeout
	db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
	if err != nil {
		t.Fatalf("open primary: %v", err)
	}
	t.Cleanup(func() {
		if err := db.Close(); err != nil {
			t.Errorf("close db: %v", err)
		}
	})
	if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
		t.Fatalf("ping: %v", err)
	}
	if err := backendpg.ApplyMigrations(ctx, db); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}
	return db
}

func dsnWithSearchPath(baseDSN, schema string) (string, error) {
	parsed, err := url.Parse(baseDSN)
	if err != nil {
		return "", err
	}
	values := parsed.Query()
	values.Set("search_path", schema)
	if values.Get("sslmode") == "" {
		values.Set("sslmode", "disable")
	}
	parsed.RawQuery = values.Encode()
	return parsed.String(), nil
}

type stubEntitlement struct {
	max int32
}

func (s stubEntitlement) GetMaxRegisteredRaceNames(_ context.Context, _ uuid.UUID) (int32, error) {
	return s.max, nil
}

func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service {
	t.Helper()
	store := lobby.NewStore(db)
	cache := lobby.NewCache()
	if err := cache.Warm(context.Background(), store); err != nil {
		t.Fatalf("warm cache: %v", err)
	}
	svc, err := lobby.NewService(lobby.Deps{
		Store:       store,
		Cache:       cache,
		Entitlement: stubEntitlement{max: max},
		Config: config.LobbyConfig{
			SweeperInterval:        time.Second,
			PendingRegistrationTTL: time.Hour,
			InviteDefaultTTL:       time.Hour,
		},
		Now: now,
	})
	if err != nil {
		t.Fatalf("new service: %v", err)
	}
	return svc
}

// seedAccount inserts a minimal accounts row so games / memberships
// referencing user_id can be created without violating any FK.
func seedAccount(t *testing.T, db *sql.DB, userID uuid.UUID) {
	t.Helper()
	_, err := db.ExecContext(context.Background(), `
		INSERT INTO backend.accounts (
			user_id, email, user_name, preferred_language, time_zone
		) VALUES ($1, $2, $3, 'en', 'UTC')
	`, userID, userID.String()+"@test.local", "user-"+userID.String()[:8])
	if err != nil {
		t.Fatalf("seed account %s: %v", userID, err)
	}
}

func TestEndToEndPrivateGameFlow(t *testing.T) {
	db := startPostgres(t)
	now := time.Now().UTC()
	clock := func() time.Time { return now }
	svc := newServiceForTest(t, db, clock, 5)

	owner := uuid.New()
	seedAccount(t, db, owner)

	game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{
		OwnerUserID:         &owner,
		Visibility:          lobby.VisibilityPrivate,
		GameName:            "End-to-End Game",
		MinPlayers:          1,
		MaxPlayers:          4,
		StartGapHours:       1,
		StartGapPlayers:     1,
		EnrollmentEndsAt:    now.Add(time.Hour),
		TurnSchedule:        "0 0 * * *",
		TargetEngineVersion: "1.0.0",
	})
	if err != nil {
		t.Fatalf("create game: %v", err)
	}
	if game.Status != lobby.GameStatusDraft {
		t.Fatalf("create game status = %q, want draft", game.Status)
	}
	if got, ok := svc.Cache().GetGame(game.GameID); !ok || got.GameID != game.GameID {
		t.Fatalf("game not cached after create")
	}

	if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil {
		t.Fatalf("open enrollment: %v", err)
	}

	// Approve a member to clear min_players.
	applicant := uuid.New()
	seedAccount(t, db, applicant)
	game = mustGet(t, svc, game.GameID)
	// public-only handler does not run on private games; bypass via direct
	// membership insert through the store to focus on state-machine.
	store := lobby.NewStore(db)
	canonicalPolicy, err := lobby.NewPolicy()
	if err != nil {
		t.Fatalf("new policy: %v", err)
	}
	canonical, err := canonicalPolicy.Canonical("PrivateRace")
	if err != nil {
		t.Fatalf("canonical: %v", err)
	}
	if _, err := db.ExecContext(context.Background(), `
		INSERT INTO backend.memberships (
			membership_id, game_id, user_id, race_name, canonical_key, status
		) VALUES ($1, $2, $3, $4, $5, 'active')
	`, uuid.New(), game.GameID, applicant, "PrivateRace", string(canonical)); err != nil {
		t.Fatalf("seed membership: %v", err)
	}
	// Re-warm cache so the new membership flows through MembershipsForGame.
	if err := svc.Cache().Warm(context.Background(), store); err != nil {
		t.Fatalf("re-warm cache: %v", err)
	}

	if _, err := svc.ReadyToStart(context.Background(), &owner, false, game.GameID); err != nil {
		t.Fatalf("ready-to-start: %v", err)
	}
	if _, err := svc.Start(context.Background(), &owner, false, game.GameID); err != nil {
		t.Fatalf("start: %v", err)
	}
	game = mustGet(t, svc, game.GameID)
	if game.Status != lobby.GameStatusStarting {
		t.Fatalf("after start status = %q, want starting", game.Status)
	}

	// Simulate runtime → running.
	if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{
		CurrentTurn:   1,
		RuntimeStatus: "running",
	}); err != nil {
		t.Fatalf("on-runtime-snapshot running: %v", err)
	}
	game = mustGet(t, svc, game.GameID)
	if game.Status != lobby.GameStatusRunning {
		t.Fatalf("after runtime snapshot status = %q, want running", game.Status)
	}

	if _, err := svc.Pause(context.Background(), &owner, false, game.GameID); err != nil {
		t.Fatalf("pause: %v", err)
	}
	if _, err := svc.Resume(context.Background(), &owner, false, game.GameID); err != nil {
		t.Fatalf("resume: %v", err)
	}
	if _, err := svc.Cancel(context.Background(), &owner, false, game.GameID); err != nil {
		t.Fatalf("cancel: %v", err)
	}
	game, err = svc.GetGame(context.Background(), game.GameID)
	if err != nil {
		t.Fatalf("get cancelled: %v", err)
	}
	if game.Status != lobby.GameStatusCancelled {
		t.Fatalf("after cancel status = %q, want cancelled", game.Status)
	}
}

// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
// bootstrap relies on: removing a game wipes every referencing row
// (memberships, applications, invites, runtime_records,
// player_mappings) in a single SQL statement. Before this is wired
// the developer's lobby pile up cancelled tiles between
// `make rebuild` cycles; with it, every boot starts from a clean
// slate.
func TestDeleteGameCascadesEverything(t *testing.T) {
	db := startPostgres(t)
	now := time.Now().UTC()
	clock := func() time.Time { return now }
	svc := newServiceForTest(t, db, clock, 5)

	owner := uuid.New()
	seedAccount(t, db, owner)
	game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{
		OwnerUserID:         &owner,
		Visibility:          lobby.VisibilityPrivate,
		GameName:            "Doomed",
		MinPlayers:          1,
		MaxPlayers:          4,
		StartGapHours:       1,
		StartGapPlayers:     1,
		EnrollmentEndsAt:    now.Add(time.Hour),
		TurnSchedule:        "0 0 * * *",
		TargetEngineVersion: "1.0.0",
	})
	if err != nil {
		t.Fatalf("create game: %v", err)
	}
	if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil {
		t.Fatalf("open enrollment: %v", err)
	}
	if _, err := svc.InsertMembershipDirect(context.Background(), lobby.InsertMembershipDirectInput{
		GameID:   game.GameID,
		UserID:   owner,
		RaceName: "Owner",
	}); err != nil {
		t.Fatalf("insert membership: %v", err)
	}

	if err := svc.DeleteGame(context.Background(), game.GameID); err != nil {
		t.Fatalf("delete game: %v", err)
	}

	// Verify cascade: the game must be gone, ListMyGames must drop
	// it, and re-deleting the same id is a no-op.
	if _, err := svc.GetGame(context.Background(), game.GameID); !errors.Is(err, lobby.ErrNotFound) {
		t.Fatalf("get after delete: err = %v, want ErrNotFound", err)
	}
	games, err := svc.ListMyGames(context.Background(), owner)
	if err != nil {
		t.Fatalf("list my games: %v", err)
	}
	for _, g := range games {
		if g.GameID == game.GameID {
			t.Fatalf("ListMyGames still lists the deleted game")
		}
	}
	if err := svc.DeleteGame(context.Background(), game.GameID); err != nil {
		t.Fatalf("delete idempotent: %v", err)
	}
}

func TestEndToEndPublicGameApplicationApproval(t *testing.T) {
	db := startPostgres(t)
	now := time.Now().UTC()
	clock := func() time.Time { return now }
	svc := newServiceForTest(t, db, clock, 5)

	game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{
		OwnerUserID:         nil,
		Visibility:          lobby.VisibilityPublic,
		GameName:            "Public Game",
		MinPlayers:          1,
		MaxPlayers:          8,
		StartGapHours:       1,
		StartGapPlayers:     1,
		EnrollmentEndsAt:    now.Add(time.Hour),
		TurnSchedule:        "0 0 * * *",
		TargetEngineVersion: "1.0.0",
	})
	if err != nil {
		t.Fatalf("create public game: %v", err)
	}
	// Move to enrollment_open via admin force-start path is wrong; use
	// transition via admin OpenEnrollment by passing callerIsAdmin=true.
	if _, err := svc.OpenEnrollment(context.Background(), nil, true, game.GameID); err != nil {
		t.Fatalf("open enrollment (admin): %v", err)
	}
	applicant := uuid.New()
	seedAccount(t, db, applicant)
	app, err := svc.SubmitApplication(context.Background(), lobby.SubmitApplicationInput{
		GameID:          game.GameID,
		ApplicantUserID: applicant,
		RaceName:        "AlphaCentauri",
	})
	if err != nil {
		t.Fatalf("submit application: %v", err)
	}
	if app.Status != lobby.ApplicationStatusPending {
		t.Fatalf("application status = %q, want pending", app.Status)
	}
	approved, err := svc.ApproveApplication(context.Background(), nil, true, game.GameID, app.ApplicationID)
	if err != nil {
		t.Fatalf("approve application: %v", err)
	}
	if approved.Status != lobby.ApplicationStatusApproved {
		t.Fatalf("approved status = %q, want approved", approved.Status)
	}
	memberships, err := svc.ListMembershipsForGame(context.Background(), game.GameID)
	if err != nil {
		t.Fatalf("list memberships: %v", err)
	}
	if len(memberships) != 1 || memberships[0].UserID != applicant {
		t.Fatalf("memberships = %+v, want one for %s", memberships, applicant)
	}
	// Re-applying the same race name from a different user must conflict.
	other := uuid.New()
	seedAccount(t, db, other)
	_, err = svc.SubmitApplication(context.Background(), lobby.SubmitApplicationInput{
		GameID:          game.GameID,
		ApplicantUserID: other,
		RaceName:        "AlphaCentauri",
	})
	if err != nil {
		t.Fatalf("second application setup: %v", err)
	}
	if _, err := svc.ApproveApplication(context.Background(), nil, true, game.GameID, secondApplication(t, db, game.GameID, other)); err == nil {
		t.Fatal("approving second application with same race name should conflict")
	} else if !errors.Is(err, lobby.ErrRaceNameTaken) {
		t.Fatalf("approve second application: err = %v, want ErrRaceNameTaken", err)
	}
}

func TestSweeperReleasesExpiredPendingRegistrations(t *testing.T) {
	db := startPostgres(t)
	now := time.Now().UTC()
	clock := func() time.Time { return now }
	svc := newServiceForTest(t, db, clock, 5)

	user := uuid.New()
	seedAccount(t, db, user)
	gameID := uuid.New()
	expired := now.Add(-time.Hour)
	if _, err := db.ExecContext(context.Background(), `
		INSERT INTO backend.race_names (
			name, canonical, status, owner_user_id, game_id, expires_at
		) VALUES ('Vega', 'vega', 'pending_registration', $1, $2, $3)
	`, user, gameID, expired); err != nil {
		t.Fatalf("seed pending row: %v", err)
	}

	sweeper := lobby.NewSweeper(svc)
	if err := sweeper.Tick(context.Background()); err != nil {
		t.Fatalf("sweeper tick: %v", err)
	}

	rows, err := lobby.NewStore(db).FindRaceNameByCanonical(context.Background(), "vega")
	if err != nil {
		t.Fatalf("find canonical after sweep: %v", err)
	}
	if len(rows) != 0 {
		t.Fatalf("expected pending row to be released, got %d rows", len(rows))
	}
}

func mustGet(t *testing.T, svc *lobby.Service, gameID uuid.UUID) lobby.GameRecord {
	t.Helper()
	g, err := svc.GetGame(context.Background(), gameID)
	if err != nil {
		t.Fatalf("get game %s: %v", gameID, err)
	}
	return g
}

// secondApplication looks up the second application id (the one
// submitted by `userID`) on `gameID`. The test seeds two applications
// in `TestEndToEndPublicGameApplicationApproval` and uses this helper
// to fetch the not-yet-decided one without coupling the test to insert
// order.
func secondApplication(t *testing.T, db *sql.DB, gameID, userID uuid.UUID) uuid.UUID {
	t.Helper()
	var id uuid.UUID
	if err := db.QueryRowContext(context.Background(), `
		SELECT application_id FROM backend.applications
		WHERE game_id = $1 AND applicant_user_id = $2
	`, gameID, userID).Scan(&id); err != nil {
		t.Fatalf("lookup second application: %v", err)
	}
	return id
}
