//go:build integration

package inttest

import (
	"context"
	"testing"
	"time"

	"github.com/google/uuid"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/accountmerge"
	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/game"
	"scrabble/backend/internal/link"
	"scrabble/backend/internal/session"
)

// --- merge test helpers ---

func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(),
		`INSERT INTO backend.account_stats (account_id, wins, losses, draws, max_game_points, max_word_points)
		 VALUES ($1,$2,$3,$4,$5,$6)
		 ON CONFLICT (account_id) DO UPDATE SET wins=$2, losses=$3, draws=$4, max_game_points=$5, max_word_points=$6`,
		id, w, l, d, mg, mw); err != nil {
		t.Fatalf("set stats: %v", err)
	}
}

func setStatsCounts(t *testing.T, id uuid.UUID, moves, hints int) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(),
		`UPDATE backend.account_stats SET moves=$2, hints_used=$3 WHERE account_id=$1`, id, moves, hints); err != nil {
		t.Fatalf("set stats counts: %v", err)
	}
}

func setBestMove(t *testing.T, id uuid.UUID, variant string, score int) {
	t.Helper()
	tiles := `[{"letter":"c","value":3,"blank":false}]`
	if _, err := testDB.ExecContext(context.Background(),
		`INSERT INTO backend.account_best_move (account_id, variant, score, tiles) VALUES ($1,$2,$3,$4)
		 ON CONFLICT (account_id, variant) DO UPDATE SET score=$3, tiles=$4`, id, variant, score, tiles); err != nil {
		t.Fatalf("set best move: %v", err)
	}
}

func bestMoveCount(t *testing.T, id uuid.UUID) int {
	t.Helper()
	var n int
	if err := testDB.QueryRowContext(context.Background(),
		`SELECT count(*) FROM backend.account_best_move WHERE account_id=$1`, id).Scan(&n); err != nil {
		t.Fatalf("best move count: %v", err)
	}
	return n
}

func bindEmailIdentity(t *testing.T, acc uuid.UUID, email string) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(),
		`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
		uuid.New(), acc, email); err != nil {
		t.Fatalf("bind email identity: %v", err)
	}
}

func insertFriendship(t *testing.T, a, b uuid.UUID, status string) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(),
		`INSERT INTO backend.friendships (requester_id, addressee_id, status) VALUES ($1,$2,$3)`, a, b, status); err != nil {
		t.Fatalf("insert friendship: %v", err)
	}
}

func mergedInto(t *testing.T, id uuid.UUID) uuid.UUID {
	t.Helper()
	var into *uuid.UUID
	if err := testDB.QueryRowContext(context.Background(),
		`SELECT merged_into FROM backend.accounts WHERE account_id=$1`, id).Scan(&into); err != nil {
		t.Fatalf("read merged_into: %v", err)
	}
	if into == nil {
		return uuid.Nil
	}
	return *into
}

func seatCount(t *testing.T, gameID, accountID uuid.UUID) int {
	t.Helper()
	var n int
	if err := testDB.QueryRowContext(context.Background(),
		`SELECT count(*) FROM backend.game_players WHERE game_id=$1 AND account_id=$2`, gameID, accountID).Scan(&n); err != nil {
		t.Fatalf("seat count: %v", err)
	}
	return n
}

func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID {
	t.Helper()
	g, err := newGameService().Create(context.Background(), game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: timeout, Seed: openingSeed(t),
	})
	if err != nil {
		t.Fatalf("seat game: %v", err)
	}
	return g.ID
}

func newLinkService(mailer account.Mailer) *link.Service {
	store := account.NewStore(testDB)
	emails := account.NewEmailService(store, mailer, "https://erudit-game.ru")
	sessions := session.NewService(session.NewStore(testDB), session.NewCache())
	return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
}

// TestAccountMergeCore folds every account-scoped artifact of a secondary into a
// primary: stats summed, wallet summed, paid flag ORed, identity repointed, a
// non-shared game transferred, a friend carried over, and the secondary tombstoned.
func TestAccountMergeCore(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	merger := accountmerge.NewMerger(testDB)
	merger.SetPayments(newPaymentsService())

	primary := provisionAccount(t)
	secondary := provisionAccount(t)
	friend := provisionAccount(t)

	setStats(t, primary, 1, 0, 0, 100, 90)
	setStats(t, secondary, 3, 1, 2, 400, 80)
	setStatsCounts(t, primary, 100, 5)
	setStatsCounts(t, secondary, 50, 8)
	seedBenefit(t, primary, "telegram", 2, false)
	seedBenefit(t, secondary, "telegram", 5, true) // hints sum, forever OR-s on merge
	// Best moves: secondary's scrabble_en (80) beats primary's (50) and is kept; secondary's
	// scrabble_ru (30) is new to primary and carried over.
	setBestMove(t, primary, "scrabble_en", 50)
	setBestMove(t, secondary, "scrabble_en", 80)
	setBestMove(t, secondary, "scrabble_ru", 30)

	email := "merge-" + uuid.NewString() + "@example.com"
	bindEmailIdentity(t, secondary, email)
	insertFriendship(t, secondary, friend, "accepted")
	gameID := seatGame(t, []uuid.UUID{secondary, friend}, 24*time.Hour)

	if err := merger.Merge(ctx, primary, secondary); err != nil {
		t.Fatalf("merge: %v", err)
	}

	w, l, d, mg, mw, found := readStats(t, primary)
	if !found || w != 4 || l != 1 || d != 2 || mg != 400 || mw != 90 {
		t.Errorf("primary stats = (%d,%d,%d,%d,%d found=%v), want (4,1,2,400,90 true)", w, l, d, mg, mw, found)
	}
	if _, _, _, _, _, found := readStats(t, secondary); found {
		t.Error("secondary stats row should be deleted after merge")
	}

	// The payments wallet merged by origin: hints sum (2+5) and the forever flag OR-s; the
	// deprecated accounts.hint_balance / paid_account columns are no longer touched by a merge.
	if hints, forever := readBenefit(t, primary, "telegram"); hints != 7 || !forever {
		t.Errorf("merged telegram benefit = hints %d forever %v, want 7/true", hints, forever)
	}
	if hints, _ := readBenefit(t, secondary, "telegram"); hints != 0 {
		t.Errorf("secondary benefit should be cleared after merge, got hints %d", hints)
	}

	if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary {
		t.Errorf("email owner = %s ok=%v, want primary %s", owner, ok, primary)
	}
	if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 0 {
		t.Error("non-shared game seat should transfer to primary")
	}
	if friends, _ := newSocialService().ListFriends(ctx, primary); len(friends) != 1 || friends[0] != friend {
		t.Errorf("primary friends = %v, want [%s]", friends, friend)
	}
	if mergedInto(t, secondary) != primary {
		t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary)
	}

	// The moves/hints counters sum, and the per-variant best moves merge (the higher score
	// per variant kept), with the secondary's best-move rows cleaned up.
	st, err := store.GetStats(ctx, primary)
	if err != nil {
		t.Fatalf("get merged stats: %v", err)
	}
	if st.Moves != 150 || st.HintsUsed != 13 {
		t.Errorf("primary moves/hints = %d/%d, want 150/13", st.Moves, st.HintsUsed)
	}
	best := map[string]int{}
	for _, b := range st.BestMoves {
		best[b.Variant] = b.Score
	}
	if len(st.BestMoves) != 2 || best["scrabble_en"] != 80 || best["scrabble_ru"] != 30 {
		t.Errorf("primary best moves = %+v, want scrabble_en:80 + scrabble_ru:30", st.BestMoves)
	}
	if bestMoveCount(t, secondary) != 0 {
		t.Error("secondary best-move rows should be deleted after merge")
	}
}

// TestAccountMergeActiveGameConflict refuses a merge when the two share an active
// game (one player cannot be merged against themselves).
func TestAccountMergeActiveGameConflict(t *testing.T) {
	ctx := context.Background()
	merger := accountmerge.NewMerger(testDB)
	primary := provisionAccount(t)
	secondary := provisionAccount(t)
	seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)

	if err := merger.Merge(ctx, primary, secondary); err != accountmerge.ErrActiveGameConflict {
		t.Fatalf("merge = %v, want ErrActiveGameConflict", err)
	}
	if mergedInto(t, secondary) != uuid.Nil {
		t.Error("a refused merge must not tombstone the secondary")
	}
}

// TestAccountMergeFinishedSharedGameKept allows a merge when the shared game is
// finished and leaves the secondary's seat in place (the tombstone keeps the
// no-cascade foreign key valid).
func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
	ctx := context.Background()
	merger := accountmerge.NewMerger(testDB)
	primary := provisionAccount(t)
	secondary := provisionAccount(t)
	gameID := seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)
	if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, gameID); err != nil {
		t.Fatalf("finish game: %v", err)
	}

	if err := merger.Merge(ctx, primary, secondary); err != nil {
		t.Fatalf("merge: %v", err)
	}
	if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 1 {
		t.Error("a shared finished game must keep both seats (secondary as tombstone)")
	}
	if mergedInto(t, secondary) != primary {
		t.Error("secondary should be tombstoned")
	}
}

// TestAccountMergeDedupesEmail keeps the primary's email when both accounts have one and
// journals the secondary's to the dossier (reason=merge), so the survivor never ends up
// with two email identities.
func TestAccountMergeDedupesEmail(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	merger := accountmerge.NewMerger(testDB)

	primary := provisionAccount(t)
	secondary := provisionAccount(t)
	primaryEmail := "keep-" + uuid.NewString() + "@example.com"
	secondaryEmail := "absorb-" + uuid.NewString() + "@example.com"
	bindEmailIdentity(t, primary, primaryEmail)
	bindEmailIdentity(t, secondary, secondaryEmail)

	if err := merger.Merge(ctx, primary, secondary); err != nil {
		t.Fatalf("merge: %v", err)
	}

	// The primary keeps its own email; the secondary's is gone from the live identities.
	if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, primaryEmail); !ok || owner != primary {
		t.Errorf("primary email owner = %s ok=%v, want primary %s", owner, ok, primary)
	}
	if _, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, secondaryEmail); ok {
		t.Error("the secondary's email must be removed (no duplicate email on the survivor)")
	}
	// The absorbed email stays in the legal dossier, tagged reason=merge.
	var reason string
	if err := testDB.QueryRowContext(ctx,
		`SELECT reason FROM backend.retained_identities WHERE account_id=$1 AND kind='email' AND external_id=$2`,
		secondary, secondaryEmail).Scan(&reason); err != nil {
		t.Fatalf("retained email row: %v", err)
	}
	if reason != "merge" {
		t.Errorf("retained reason = %q, want merge", reason)
	}
}

// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
func TestAccountLinkFreeEmail(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	mailer := &capturingMailer{}
	links := newLinkService(mailer)

	guest := provisionGuest(t)
	email := "fresh-" + uuid.NewString() + "@example.com"
	if err := links.RequestEmail(ctx, guest, email); err != nil {
		t.Fatalf("request: %v", err)
	}
	res, err := links.ConfirmEmail(ctx, guest, email, sixDigit.FindString(mailer.lastBody))
	if err != nil {
		t.Fatalf("confirm: %v", err)
	}
	if !res.Linked || res.MergeRequired {
		t.Fatalf("confirm = %+v, want linked", res)
	}
	acc, _ := store.GetByID(ctx, guest)
	if acc.IsGuest {
		t.Error("guest flag should clear once an identity is linked")
	}
	if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != guest {
		t.Errorf("email owner = %s, want the promoted guest %s", owner, guest)
	}
}

// TestAccountLinkEmailMergeIntoCaller merges the email's owner into the current
// (durable) account: the caller stays primary and keeps its session.
func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	mailer := &capturingMailer{}
	links := newLinkService(mailer)

	caller := provisionAccount(t)
	other := provisionAccount(t)
	email := "owned-" + uuid.NewString() + "@example.com"
	bindEmailIdentity(t, other, email)

	if err := links.RequestEmail(ctx, caller, email); err != nil {
		t.Fatalf("request: %v", err)
	}
	code := sixDigit.FindString(mailer.lastBody)
	confirm, err := links.ConfirmEmail(ctx, caller, email, code)
	if err != nil {
		t.Fatalf("confirm: %v", err)
	}
	if !confirm.MergeRequired || confirm.SecondaryID != other {
		t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
	}
	merge, err := links.MergeEmail(ctx, caller, email, code)
	if err != nil {
		t.Fatalf("merge: %v", err)
	}
	if merge.PrimaryID != caller || merge.SwitchedToken != "" {
		t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
	}
	if mergedInto(t, other) != caller {
		t.Error("other should be tombstoned into caller")
	}
	if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != caller {
		t.Errorf("email owner = %s, want caller", owner)
	}
}

// TestAccountLinkGuestInversion auto-merges a guest initiator into the durable account that
// owns the email AT THE CONFIRM STEP (no merge confirmation): the guest is retired, the durable
// account wins and a fresh session is minted for it (the client adopts the switch and lands on
// the durable account as if it simply logged in).
func TestAccountLinkGuestInversion(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	mailer := &capturingMailer{}
	links := newLinkService(mailer)

	durable := provisionAccount(t)
	email := "durable-" + uuid.NewString() + "@example.com"
	bindEmailIdentity(t, durable, email)
	guest := provisionGuest(t)

	if err := links.RequestEmail(ctx, guest, email); err != nil {
		t.Fatalf("request: %v", err)
	}
	code := sixDigit.FindString(mailer.lastBody)
	confirm, err := links.ConfirmEmail(ctx, guest, email, code)
	if err != nil {
		t.Fatalf("confirm: %v", err)
	}
	// A guest initiator does not get a MergeRequired confirmation — the merge runs inline.
	if !confirm.Merged || confirm.MergeRequired {
		t.Fatalf("confirm = %+v, want an inline (auto) merge", confirm)
	}
	if confirm.Merge.PrimaryID != durable {
		t.Fatalf("primary = %s, want durable %s", confirm.Merge.PrimaryID, durable)
	}
	if confirm.Merge.SwitchedToken == "" {
		t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
	}
	if mergedInto(t, guest) != durable {
		t.Error("the guest should be tombstoned into the durable account")
	}
	if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != durable {
		t.Errorf("email owner = %s, want durable", owner)
	}
}

// TestAccountLinkGuestAutoMergeActiveGameConflict guards that a guest's auto-merge is REFUSED
// (not silently swallowed) at the confirm step when the guest and the durable account share an
// active game: the caller surfaces ErrActiveGameConflict (mapped to a clear "finish that game
// first" message) rather than seating one player against themselves.
func TestAccountLinkGuestAutoMergeActiveGameConflict(t *testing.T) {
	ctx := context.Background()
	mailer := &capturingMailer{}
	links := newLinkService(mailer)

	durable := provisionAccount(t)
	email := "conflict-" + uuid.NewString() + "@example.com"
	bindEmailIdentity(t, durable, email)
	guest := provisionGuest(t)
	seatGame(t, []uuid.UUID{durable, guest}, 24*time.Hour) // an active game the two share

	if err := links.RequestEmail(ctx, guest, email); err != nil {
		t.Fatalf("request: %v", err)
	}
	code := sixDigit.FindString(mailer.lastBody)
	if _, err := links.ConfirmEmail(ctx, guest, email, code); err != accountmerge.ErrActiveGameConflict {
		t.Fatalf("confirm = %v, want ErrActiveGameConflict surfaced by the auto-merge", err)
	}
	if mergedInto(t, guest) != uuid.Nil {
		t.Error("a refused auto-merge must not tombstone the guest")
	}
}

// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	links := newLinkService(&capturingMailer{})

	guest := provisionGuest(t)
	vkID := "vk-" + uuid.NewString()
	res, err := links.ConfirmVK(ctx, guest, vkID)
	if err != nil {
		t.Fatalf("confirm vk: %v", err)
	}
	if !res.Linked || res.MergeRequired {
		t.Fatalf("confirm = %+v, want linked", res)
	}
	if acc, _ := store.GetByID(ctx, guest); acc.IsGuest {
		t.Error("guest flag should clear once VK is linked")
	}
	if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); !ok || owner != guest {
		t.Errorf("vk owner = %s, want the promoted guest %s", owner, guest)
	}
}

// TestAccountLinkVKMergeIntoCaller merges the account owning a VK identity into the
// current durable account: ConfirmVK previews the merge, MergeVK folds it, the caller
// stays primary and keeps its session, and the VK identity repoints to the caller.
func TestAccountLinkVKMergeIntoCaller(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	links := newLinkService(&capturingMailer{})

	caller := provisionAccount(t)
	other := provisionAccount(t)
	vkID := "vk-" + uuid.NewString()
	if err := store.AttachIdentity(ctx, other, account.KindVK, vkID, true); err != nil {
		t.Fatalf("seed vk on other: %v", err)
	}

	confirm, err := links.ConfirmVK(ctx, caller, vkID)
	if err != nil {
		t.Fatalf("confirm vk: %v", err)
	}
	if !confirm.MergeRequired || confirm.SecondaryID != other {
		t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
	}
	merge, err := links.MergeVK(ctx, caller, vkID)
	if err != nil {
		t.Fatalf("merge vk: %v", err)
	}
	if merge.PrimaryID != caller || merge.SwitchedToken != "" {
		t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
	}
	if mergedInto(t, other) != caller {
		t.Error("other should be tombstoned into caller")
	}
	if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); owner != caller {
		t.Errorf("vk owner = %s, want caller after merge", owner)
	}
}
