//go:build integration

package inttest

import (
	"context"
	"errors"
	"regexp"
	"slices"
	"testing"
	"time"

	"github.com/google/uuid"

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

// TestAccountProvisionByIdentity covers find-or-create semantics, distinct
// accounts per identity, GetByID, and the identity confirmed flag per kind.
func TestAccountProvisionByIdentity(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)

	tgExternal := "tg-" + uuid.NewString()
	first, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExternal)
	if err != nil {
		t.Fatalf("provision telegram: %v", err)
	}
	if first.ID == uuid.Nil {
		t.Fatal("expected a non-nil account id")
	}
	if first.PreferredLanguage != "en" {
		t.Errorf("PreferredLanguage = %q, want default en", first.PreferredLanguage)
	}
	if first.TimeZone != "UTC" {
		t.Errorf("TimeZone = %q, want default UTC", first.TimeZone)
	}

	// Re-provisioning the same identity returns the same account.
	again, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExternal)
	if err != nil {
		t.Fatalf("re-provision telegram: %v", err)
	}
	if again.ID != first.ID {
		t.Errorf("re-provision id = %s, want %s", again.ID, first.ID)
	}

	// A different identity yields a different account.
	other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
	if err != nil {
		t.Fatalf("provision other telegram: %v", err)
	}
	if other.ID == first.ID {
		t.Error("distinct identity must map to a distinct account")
	}

	// GetByID round-trips, and a random id reports ErrNotFound.
	got, err := store.GetByID(ctx, first.ID)
	if err != nil {
		t.Fatalf("get by id: %v", err)
	}
	if got.ID != first.ID {
		t.Errorf("get id = %s, want %s", got.ID, first.ID)
	}
	if _, err := store.GetByID(ctx, uuid.New()); !errors.Is(err, account.ErrNotFound) {
		t.Errorf("get missing = %v, want ErrNotFound", err)
	}

	// A platform identity is confirmed; an email identity starts unconfirmed.
	if c := identityConfirmed(t, account.KindTelegram, tgExternal); !c {
		t.Error("telegram identity must be confirmed")
	}
	emailExternal := "e-" + uuid.NewString() + "@example.com"
	if _, err := store.ProvisionByIdentity(ctx, account.KindEmail, emailExternal); err != nil {
		t.Fatalf("provision email: %v", err)
	}
	if c := identityConfirmed(t, account.KindEmail, emailExternal); c {
		t.Error("email identity must start unconfirmed")
	}
}

// TestGetStatsZeroForFreshAccount checks that an account with no finished games
// reads back the zero statistics rather than an error (the stats screen).
func TestGetStatsZeroForFreshAccount(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	id := provisionAccount(t)
	st, err := store.GetStats(ctx, id)
	if err != nil {
		t.Fatalf("get stats: %v", err)
	}
	zero := st.Wins == 0 && st.Losses == 0 && st.Draws == 0 && st.MaxGamePoints == 0 && st.MaxWordPoints == 0
	if !zero || len(st.BestMoves) != 0 {
		t.Fatalf("fresh stats = %+v, want zero", st)
	}
}

// identityConfirmed reads the confirmed flag for one identity directly.
func identityConfirmed(t *testing.T, kind, externalID string) bool {
	t.Helper()
	var confirmed bool
	err := testDB.QueryRowContext(context.Background(),
		"SELECT confirmed FROM identities WHERE kind = $1 AND external_id = $2",
		kind, externalID,
	).Scan(&confirmed)
	if err != nil {
		t.Fatalf("read confirmed for (%s, %s): %v", kind, externalID, err)
	}
	return confirmed
}

// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
// seeds the new account's language, display name and time zone from the launch
// fields / detected offset, defaults the in-app-only flag on, and never overwrites
// an existing account on a later login (language and zone seeding).
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	ext := "tg-" + uuid.NewString()

	acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван", "+03:00")
	if err != nil {
		t.Fatalf("provision telegram: %v", err)
	}
	if !created {
		t.Error("created = false on first contact, want true")
	}
	if acc.PreferredLanguage != "ru" {
		t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
	}
	if acc.DisplayName != "Иван" {
		t.Errorf("DisplayName = %q, want Иван", acc.DisplayName)
	}
	if acc.TimeZone != "+03:00" {
		t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
	}
	if !acc.NotificationsInAppOnly {
		t.Error("NotificationsInAppOnly should default to true")
	}

	// A later login with different fields returns the same account, unchanged.
	again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other", "+09:00")
	if err != nil {
		t.Fatalf("re-provision telegram: %v", err)
	}
	if created {
		t.Error("created = true on a repeat login, want false")
	}
	if again.ID != acc.ID {
		t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
	}
	if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" || again.TimeZone != "+03:00" {
		t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
	}
}

// TestProvisionVKSeedsNewAccountOnly checks VK first contact seeds the new account's
// language, display name and time zone from the launch fields / detected offset, records
// the vk identity as confirmed (a platform identity), and never overwrites an existing
// account on a later launch. It also exercises the widened identities.kind CHECK — a
// 'vk' row must insert.
func TestProvisionVKSeedsNewAccountOnly(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	ext := "vk-" + uuid.NewString()

	acc, created, err := store.ProvisionVK(ctx, ext, "ru", "Иван Петров", "+03:00")
	if err != nil {
		t.Fatalf("provision vk: %v", err)
	}
	if !created {
		t.Error("created = false on first contact, want true")
	}
	if acc.PreferredLanguage != "ru" {
		t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
	}
	if acc.DisplayName != "Иван Петров" {
		t.Errorf("DisplayName = %q, want Иван Петров", acc.DisplayName)
	}
	if acc.TimeZone != "+03:00" {
		t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
	}
	// A VK identity is a platform identity: confirmed on insert.
	if !identityConfirmed(t, account.KindVK, ext) {
		t.Error("vk identity must be confirmed")
	}

	// A later launch with different fields returns the same account, unchanged.
	again, created, err := store.ProvisionVK(ctx, ext, "en", "Other Name", "+09:00")
	if err != nil {
		t.Fatalf("re-provision vk: %v", err)
	}
	if created {
		t.Error("created = true on a repeat launch, want false")
	}
	if again.ID != acc.ID {
		t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
	}
	if again.PreferredLanguage != "ru" || again.DisplayName != "Иван Петров" || again.TimeZone != "+03:00" {
		t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
	}
}

// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
// distinct from the unset "UTC" default), a guest is seeded the same way, and a
// missing or malformed offset falls back to the "UTC" column default rather than
// being guessed at.
func TestProvisionSeedsTimeZone(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)

	// A detected zero offset is written as "+00:00" — we record that the zone was
	// detected (and equals UTC), distinct from the "UTC" default meaning "unknown".
	utcDetected, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Zero", "+00:00")
	if err != nil {
		t.Fatalf("provision telegram +00:00: %v", err)
	}
	if utcDetected.TimeZone != "+00:00" {
		t.Errorf("TimeZone = %q, want the seeded +00:00", utcDetected.TimeZone)
	}

	// A malformed offset is dropped: the account keeps the UTC default.
	bad, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Bad", "not-a-zone")
	if err != nil {
		t.Fatalf("provision telegram bad tz: %v", err)
	}
	if bad.TimeZone != "UTC" {
		t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone)
	}

	// A guest is seeded its detected offset and interface language; an empty offset keeps the
	// UTC default and an empty/unsupported language keeps the 'en' default.
	guest, err := store.ProvisionGuest(ctx, "-05:30", "ru")
	if err != nil {
		t.Fatalf("provision guest: %v", err)
	}
	if guest.TimeZone != "-05:30" {
		t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone)
	}
	if guest.PreferredLanguage != "ru" {
		t.Errorf("guest PreferredLanguage = %q, want the seeded ru", guest.PreferredLanguage)
	}
	// A guest's display name is "Guest" + a random six-digit suffix (distinct to opponents).
	if m, _ := regexp.MatchString(`^Guest\d{6}$`, guest.DisplayName); !m {
		t.Errorf("guest DisplayName = %q, want Guest + 6 digits", guest.DisplayName)
	}
	plainGuest, err := store.ProvisionGuest(ctx, "", "")
	if err != nil {
		t.Fatalf("provision plain guest: %v", err)
	}
	if plainGuest.TimeZone != "UTC" {
		t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone)
	}
	if plainGuest.PreferredLanguage != "en" {
		t.Errorf("plain guest PreferredLanguage = %q, want en default", plainGuest.PreferredLanguage)
	}
	// An unsupported locale falls back to the 'en' default rather than persisting a junk value.
	frGuest, err := store.ProvisionGuest(ctx, "", "fr-FR")
	if err != nil {
		t.Fatalf("provision fr guest: %v", err)
	}
	if frGuest.PreferredLanguage != "en" {
		t.Errorf("unsupported-locale guest PreferredLanguage = %q, want en default", frGuest.PreferredLanguage)
	}
}

// TestVariantPreferenceDefaults covers the variant set an account is born with and how
// registration widens it: a registered account starts on both Russian-alphabet games
// (the column default), a guest on Эрудит alone, and promoting the guest lifts it to the
// registered default — but only while the guest still carries that untouched set, and
// never for an account that is already durable.
func TestVariantPreferenceDefaults(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)

	equal := func(t *testing.T, what string, got, want []string) {
		t.Helper()
		if !slices.Equal(got, want) {
			t.Errorf("%s = %v, want %v", what, got, want)
		}
	}

	// A registered account (any identity kind) takes the column default.
	durable, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Player", "")
	if err != nil {
		t.Fatalf("provision telegram: %v", err)
	}
	equal(t, "registered account variants", durable.VariantPreferences, account.DefaultVariantPreferences)

	// A guest is deliberately narrower.
	guest, err := store.ProvisionGuest(ctx, "", "ru")
	if err != nil {
		t.Fatalf("provision guest: %v", err)
	}
	equal(t, "guest variants", guest.VariantPreferences, account.GuestVariantPreferences)

	// Registering the guest promotes it to a durable account on the registered default.
	if err := store.ClearGuest(ctx, guest.ID); err != nil {
		t.Fatalf("clear guest: %v", err)
	}
	promoted, err := store.GetByID(ctx, guest.ID)
	if err != nil {
		t.Fatalf("reload promoted guest: %v", err)
	}
	if promoted.IsGuest {
		t.Error("promoted guest is still flagged is_guest")
	}
	equal(t, "promoted guest variants", promoted.VariantPreferences, account.DefaultVariantPreferences)

	// A guest who picked their own set keeps it through the promotion.
	picky, err := store.ProvisionGuest(ctx, "", "en")
	if err != nil {
		t.Fatalf("provision picky guest: %v", err)
	}
	if _, err := store.SetVariantPreferences(ctx, picky.ID, []string{"scrabble_en"}); err != nil {
		t.Fatalf("set picky guest variants: %v", err)
	}
	if err := store.ClearGuest(ctx, picky.ID); err != nil {
		t.Fatalf("clear picky guest: %v", err)
	}
	reloaded, err := store.GetByID(ctx, picky.ID)
	if err != nil {
		t.Fatalf("reload picky guest: %v", err)
	}
	equal(t, "picky guest variants", reloaded.VariantPreferences, []string{"scrabble_en"})

	// An account that is already durable is never rewritten — an established player who
	// settled on Эрудит alone stays there.
	if _, err := store.SetVariantPreferences(ctx, durable.ID, []string{"erudit_ru"}); err != nil {
		t.Fatalf("narrow durable variants: %v", err)
	}
	if err := store.ClearGuest(ctx, durable.ID); err != nil {
		t.Fatalf("clear guest on a durable account: %v", err)
	}
	established, err := store.GetByID(ctx, durable.ID)
	if err != nil {
		t.Fatalf("reload durable account: %v", err)
	}
	equal(t, "established account variants", established.VariantPreferences, []string{"erudit_ru"})
}

// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
// client language falls back to the account default rather than failing the
// language CHECK.
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
	ctx := context.Background()
	acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "", "")
	if err != nil {
		t.Fatalf("provision telegram: %v", err)
	}
	if acc.PreferredLanguage != "en" {
		t.Errorf("PreferredLanguage = %q, want default en", acc.PreferredLanguage)
	}
}

// TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account
// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
// the operator clear takes a fresh timestamp.
func TestHighRateFlagRoundTrip(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "")
	if err != nil {
		t.Fatalf("provision telegram: %v", err)
	}
	if !acc.FlaggedHighRateAt.IsZero() {
		t.Fatalf("fresh FlaggedHighRateAt = %v, want zero", acc.FlaggedHighRateAt)
	}

	first := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
	set, err := store.FlagHighRate(ctx, acc.ID, first)
	if err != nil {
		t.Fatalf("flag: %v", err)
	}
	if !set {
		t.Fatal("first FlagHighRate reported not set")
	}
	if set, err = store.FlagHighRate(ctx, acc.ID, first.Add(time.Hour)); err != nil {
		t.Fatalf("re-flag: %v", err)
	} else if set {
		t.Fatal("second FlagHighRate must not overwrite the marker")
	}
	got, err := store.GetByID(ctx, acc.ID)
	if err != nil {
		t.Fatalf("get by id: %v", err)
	}
	if !got.FlaggedHighRateAt.Equal(first) {
		t.Errorf("FlaggedHighRateAt = %v, want %v", got.FlaggedHighRateAt, first)
	}

	if err := store.ClearHighRateFlag(ctx, acc.ID); err != nil {
		t.Fatalf("clear: %v", err)
	}
	if got, err = store.GetByID(ctx, acc.ID); err != nil {
		t.Fatalf("get by id: %v", err)
	} else if !got.FlaggedHighRateAt.IsZero() {
		t.Errorf("cleared FlaggedHighRateAt = %v, want zero", got.FlaggedHighRateAt)
	}

	second := first.Add(24 * time.Hour)
	if set, err = store.FlagHighRate(ctx, acc.ID, second); err != nil || !set {
		t.Fatalf("re-flag after clear = (%v, %v), want (true, nil)", set, err)
	}
	if got, err = store.GetByID(ctx, acc.ID); err != nil {
		t.Fatalf("get by id: %v", err)
	} else if !got.FlaggedHighRateAt.Equal(second) {
		t.Errorf("re-flagged FlaggedHighRateAt = %v, want %v", got.FlaggedHighRateAt, second)
	}
}

// TestIdentityExternalID covers the reverse identity lookup the push-target route
// uses: it returns the external_id for the matching kind and ErrNotFound otherwise,
// including for a guest that carries no identity.
func TestIdentityExternalID(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	ext := "tg-" + uuid.NewString()
	acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User", "")
	if err != nil {
		t.Fatalf("provision telegram: %v", err)
	}
	got, err := store.IdentityExternalID(ctx, acc.ID, account.KindTelegram)
	if err != nil {
		t.Fatalf("identity external id: %v", err)
	}
	if got != ext {
		t.Errorf("external id = %q, want %q", got, ext)
	}
	if _, err := store.IdentityExternalID(ctx, acc.ID, account.KindEmail); !errors.Is(err, account.ErrNotFound) {
		t.Errorf("email lookup = %v, want ErrNotFound", err)
	}
	guest := provisionGuest(t)
	if _, err := store.IdentityExternalID(ctx, guest, account.KindTelegram); !errors.Is(err, account.ErrNotFound) {
		t.Errorf("guest lookup = %v, want ErrNotFound", err)
	}
}

// TestNotificationsInAppOnlyRoundTrip checks the profile flag persists
// through UpdateProfile and reads back through GetByID.
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "")
	if err != nil {
		t.Fatalf("provision telegram: %v", err)
	}
	if !acc.NotificationsInAppOnly {
		t.Fatal("default should be in-app-only true")
	}
	updated, err := store.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
		DisplayName:            "Player",
		PreferredLanguage:      "en",
		TimeZone:               "UTC",
		NotificationsInAppOnly: false,
		VariantPreferences:     []string{"erudit_ru"},
	})
	if err != nil {
		t.Fatalf("update profile: %v", err)
	}
	if updated.NotificationsInAppOnly {
		t.Error("update did not clear NotificationsInAppOnly")
	}
	got, err := store.GetByID(ctx, acc.ID)
	if err != nil {
		t.Fatalf("get by id: %v", err)
	}
	if got.NotificationsInAppOnly {
		t.Error("GetByID still reports in-app-only after clearing")
	}
}

// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
// against a robot to a natural end and checks the guest holds a seat (the
// game_players foreign key is satisfied) yet accrues no statistics, while the
// durable robot opponent does.
func TestGuestAutoMatchLeavesNoStats(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)
	}
	guest := provisionGuest(t)
	seed := openingSeed(t)

	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID},
		TurnTimeout: 24 * time.Hour, Seed: seed,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	const robotSeat = 1 // seats = [guest, 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))
			robots.Drive(ctx, daytime)
			continue
		}
		playHuman(t, ctx, svc, g.ID, guest)
	}
	if !finished {
		t.Fatal("guest game did not finish within the move budget")
	}

	if _, _, _, _, _, ok := readStats(t, guest); ok {
		t.Error("a guest must not accrue a statistics row")
	}
	if _, _, _, _, _, ok := readStats(t, robotID); !ok {
		t.Error("the durable robot opponent should have a statistics row")
	}
}
