//go:build integration

package inttest

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"net/http"
	"strings"
	"testing"
	"time"

	"github.com/google/uuid"
	"go.uber.org/zap/zaptest"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/accountdelete"
	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/game"
	"scrabble/backend/internal/server"
	"scrabble/backend/internal/social"
)

// deletedFields reads a tombstoned account's retained real name and its deleted_at.
func deletedFields(t *testing.T, accountID uuid.UUID) (name string, deletedAt sql.NullTime) {
	t.Helper()
	var dn sql.NullString
	err := testDB.QueryRowContext(context.Background(),
		"SELECT deleted_display_name, deleted_at FROM accounts WHERE account_id = $1", accountID).
		Scan(&dn, &deletedAt)
	if err != nil {
		t.Fatalf("read deleted fields %s: %v", accountID, err)
	}
	return dn.String, deletedAt
}

// TestAnonymizeAndTombstone: deletion journals + frees the credentials, tombstones the
// account, scrubs the live name while retaining the real one, and frees the creds for a
// new account to reuse.
func TestAnonymizeAndTombstone(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	deleter := accountdelete.NewDeleter(testDB)

	acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
	if err != nil {
		t.Fatalf("provision: %v", err)
	}
	email := "del-" + uuid.NewString() + "@example.com"
	if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
		t.Fatalf("attach email: %v", err)
	}
	before, err := store.GetByID(ctx, acc.ID)
	if err != nil {
		t.Fatalf("load before: %v", err)
	}

	if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
		t.Fatalf("delete: %v", err)
	}

	// The live identities are gone.
	if ids, err := store.Identities(ctx, acc.ID); err != nil || len(ids) != 0 {
		t.Fatalf("identities after delete = %+v (err %v), want none", ids, err)
	}
	// Both credentials are journalled with reason=delete.
	got := retainedRows(t, acc.ID)
	if len(got) != 2 {
		t.Fatalf("retained rows = %+v, want 2", got)
	}
	for _, r := range got {
		if r.reason != "delete" {
			t.Errorf("retained reason = %q, want delete", r.reason)
		}
	}
	// The live name is scrubbed; the real one is retained; deleted_at is set.
	after, err := store.GetByID(ctx, acc.ID)
	if err != nil {
		t.Fatalf("load after: %v", err)
	}
	if after.DisplayName != accountdelete.AnonymizedName {
		t.Errorf("live display name = %q, want %q", after.DisplayName, accountdelete.AnonymizedName)
	}
	name, deletedAt := deletedFields(t, acc.ID)
	if name != before.DisplayName {
		t.Errorf("retained name = %q, want %q", name, before.DisplayName)
	}
	if !deletedAt.Valid || time.Since(deletedAt.Time) > time.Minute {
		t.Errorf("deleted_at = %+v, want a recent timestamp", deletedAt)
	}
	// The credentials are free: a new account can claim the same email.
	other, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Other", "")
	if err != nil {
		t.Fatalf("provision other: %v", err)
	}
	if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, email, true); err != nil {
		t.Fatalf("email should be free after deletion, got: %v", err)
	}
}

// TestDeletionDossierReaders: after deletion the admin readers expose the credential
// journal and the tombstone dossier.
func TestDeletionDossierReaders(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	deleter := accountdelete.NewDeleter(testDB)

	acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
	if err != nil {
		t.Fatalf("provision: %v", err)
	}
	if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "dos-"+uuid.NewString()+"@example.com", true); err != nil {
		t.Fatalf("attach email: %v", err)
	}
	if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
		t.Fatalf("delete: %v", err)
	}

	rets, err := store.RetainedIdentities(ctx, acc.ID)
	if err != nil || len(rets) != 2 {
		t.Fatalf("RetainedIdentities = (%+v, %v), want 2 rows", rets, err)
	}
	for _, r := range rets {
		if r.Reason != "delete" {
			t.Errorf("retained reason = %q, want delete", r.Reason)
		}
	}
	info, err := store.DeletionInfo(ctx, acc.ID)
	if err != nil {
		t.Fatalf("DeletionInfo: %v", err)
	}
	if info.DeletedAt == nil {
		t.Error("DeletionInfo.DeletedAt should be set")
	}
	if info.DeletedDisplayName != "Иван" {
		t.Errorf("DeletionInfo.DeletedDisplayName = %q, want Иван", info.DeletedDisplayName)
	}
}

// listHasID reports whether the user list contains accountID.
func listHasID(items []account.UserListItem, id uuid.UUID) bool {
	for _, it := range items {
		if it.ID == id {
			return true
		}
	}
	return false
}

// TestUserListDeletedFilter: a tombstoned account is hidden from the default People list and
// shown only under the Deleted scope.
func TestUserListDeletedFilter(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	deleter := accountdelete.NewDeleter(testDB)

	live, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Live", "")
	if err != nil {
		t.Fatalf("provision live: %v", err)
	}
	goneTg := "tg-" + uuid.NewString()
	gone, _, err := store.ProvisionTelegram(ctx, goneTg, "en", "", "Gone", "")
	if err != nil {
		t.Fatalf("provision gone: %v", err)
	}
	goneEmail := "gone-" + uuid.NewString() + "@example.com"
	if err := store.AttachIdentity(ctx, gone.ID, account.KindEmail, goneEmail, true); err != nil {
		t.Fatalf("attach gone email: %v", err)
	}
	if err := deleter.AnonymizeAndTombstone(ctx, gone.ID); err != nil {
		t.Fatalf("delete: %v", err)
	}

	people, err := store.ListUsers(ctx, account.UserFilter{}, 5000, 0)
	if err != nil {
		t.Fatalf("list people: %v", err)
	}
	if listHasID(people, gone.ID) {
		t.Error("a deleted account must not appear in the default People list")
	}
	if !listHasID(people, live.ID) {
		t.Error("a live account must appear in the default People list")
	}
	deleted, err := store.ListUsers(ctx, account.UserFilter{Deleted: true}, 5000, 0)
	if err != nil {
		t.Fatalf("list deleted: %v", err)
	}
	if !listHasID(deleted, gone.ID) {
		t.Error("a deleted account must appear in the Deleted list")
	}
	if listHasID(deleted, live.ID) {
		t.Error("a live account must not appear in the Deleted list")
	}

	// A search spans both lists and reaches the retention journal: a deleted account is
	// still found by the email and external id it held (both moved to retained_identities
	// on deletion, out of the live identities table).
	byEmail, err := store.ListUsers(ctx, account.UserFilter{EmailExact: goneEmail}, 5000, 0)
	if err != nil {
		t.Fatalf("search by email: %v", err)
	}
	if !listHasID(byEmail, gone.ID) {
		t.Error("a deleted account must be found by the email it held (retention journal)")
	}
	byExt, err := store.ListUsers(ctx, account.UserFilter{ExternalIDMask: goneTg}, 5000, 0)
	if err != nil {
		t.Fatalf("search by external id: %v", err)
	}
	if !listHasID(byExt, gone.ID) {
		t.Error("a deleted account must be found by the external id it held (retention journal)")
	}
}

// TestConfirmCodeClearsGuest: confirming an email on a guest via ConfirmCode promotes it to
// a durable account (defence-in-depth — no confirmed-email path leaves is_guest set).
func TestConfirmCodeClearsGuest(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	mailer := &capturingMailer{}
	svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")

	guest, err := store.ProvisionGuest(ctx, "", "")
	if err != nil {
		t.Fatalf("provision guest: %v", err)
	}
	email := "cc-" + uuid.NewString() + "@example.com"
	if err := svc.RequestCode(ctx, guest.ID, email); err != nil {
		t.Fatalf("request code: %v", err)
	}
	if _, err := svc.ConfirmCode(ctx, guest.ID, email, sixDigit.FindString(mailer.lastBody)); err != nil {
		t.Fatalf("confirm code: %v", err)
	}
	after, err := store.GetByID(ctx, guest.ID)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	if after.IsGuest {
		t.Error("confirming an email must clear the guest flag")
	}
}

// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
// opponent.
func TestDropAllRobotGames(t *testing.T) {
	ctx := context.Background()
	gsvc := newGameService()
	robots := newRobotService(t, gsvc)
	if err := robots.EnsurePool(ctx); err != nil {
		t.Fatalf("ensure pool: %v", err)
	}
	mm := newMatchmaker(t, robots, time.Minute, 0)
	deleter := accountdelete.NewDeleter(testDB)

	user := provisionAccount(t)
	other := provisionAccount(t)

	aiRes, err := mm.StartVsAI(ctx, user, engine.VariantEnglish, true)
	if err != nil {
		t.Fatalf("start vs AI: %v", err)
	}
	humanGame, err := gsvc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: []uuid.UUID{user, other}, TurnTimeout: time.Hour, Seed: 1,
	})
	if err != nil {
		t.Fatalf("create human game: %v", err)
	}

	n, err := deleter.DropAllRobotGames(ctx, user)
	if err != nil {
		t.Fatalf("drop: %v", err)
	}
	if n != 1 {
		t.Fatalf("dropped %d games, want 1 (the vs-AI game)", n)
	}
	if _, err := gsvc.GameByID(ctx, aiRes.Game.ID); err == nil {
		t.Error("the vs-AI game should be dropped")
	}
	if _, err := gsvc.GameByID(ctx, humanGame.ID); err != nil {
		t.Errorf("the human game should be kept, got: %v", err)
	}
}

// TestDeletedAccountRefusesSocialActions: once an opponent's account is deleted, neither a
// friend request nor a block may target it — the client hides both controls on a deleted
// seat, and the server refuses them for an older client. The seat's deleted mark that drives
// that hiding is resolved by Store.DeletedIDs.
func TestDeletedAccountRefusesSocialActions(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	svc := newSocialService()
	deleter := accountdelete.NewDeleter(testDB)

	_, seats := newGameWithSeats(t, 2) // a shared game: the befriend-an-opponent gate
	viewer, gone := seats[0], seats[1]

	if err := svc.SendFriendRequest(ctx, viewer, gone); err != nil {
		t.Fatalf("friend request before deletion = %v, want nil", err)
	}
	if err := deleter.AnonymizeAndTombstone(ctx, gone); err != nil {
		t.Fatalf("delete: %v", err)
	}

	// Deletion dropped the pending request with the rest of the account's social rows, so
	// this is a fresh request against the tombstone — exactly what an older client sends
	// after the 🤝 reappears on the finished game's scoreboard.
	if err := svc.SendFriendRequest(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) {
		t.Errorf("friend request to a deleted account = %v, want ErrAccountDeleted", err)
	}
	if err := svc.Block(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) {
		t.Errorf("block of a deleted account = %v, want ErrAccountDeleted", err)
	}
	// The live opponent stays actionable — the guard is per-account, not a blanket switch.
	live := provisionAccount(t)
	if err := svc.Block(ctx, viewer, live); err != nil {
		t.Errorf("block of a live account = %v, want nil", err)
	}

	deleted, err := store.DeletedIDs(ctx, []uuid.UUID{viewer, gone})
	if err != nil {
		t.Fatalf("deleted ids: %v", err)
	}
	if !deleted[gone] {
		t.Error("the deleted account must be marked deleted")
	}
	if deleted[viewer] {
		t.Error("a live account must not be marked deleted")
	}
}

// TestGameStateMarksDeletedSeat: the per-viewer game state marks the seat of a deleted
// opponent, which is what hides the in-game social controls (add-friend, block, chat, nudge)
// on the client; the viewer's own live seat stays unmarked.
func TestGameStateMarksDeletedSeat(t *testing.T) {
	ctx := context.Background()
	games := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	g, err := games.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
	})
	if err != nil {
		t.Fatalf("create game: %v", err)
	}
	srv := server.New(":0", server.Deps{
		Logger:   zaptest.NewLogger(t),
		DB:       testDB,
		Accounts: account.NewStore(testDB),
		Games:    games,
	})
	viewer, gone := seats[0], seats[1]

	if marks := stateSeatDeleted(t, srv, g.ID, viewer); marks[0] || marks[1] {
		t.Fatalf("before deletion no seat may be marked deleted, got %v", marks)
	}
	if err := accountdelete.NewDeleter(testDB).AnonymizeAndTombstone(ctx, gone); err != nil {
		t.Fatalf("delete: %v", err)
	}
	marks := stateSeatDeleted(t, srv, g.ID, viewer)
	if marks[0] {
		t.Error("the viewer's own live seat must not be marked deleted")
	}
	if !marks[1] {
		t.Error("the deleted opponent's seat must be marked deleted")
	}
}

// stateSeatDeleted fetches viewer's game state and returns the seats' deleted marks, indexed
// by seat.
func stateSeatDeleted(t *testing.T, srv *server.Server, gameID, viewer uuid.UUID) map[int]bool {
	t.Helper()
	rec := userGet(t, srv, "/api/v1/user/games/"+gameID.String()+"/state", viewer)
	if rec.Code != http.StatusOK {
		t.Fatalf("game state = %d (%s), want 200", rec.Code, rec.Body.String())
	}
	var body struct {
		Game struct {
			Seats []struct {
				Seat    int  `json:"seat"`
				Deleted bool `json:"deleted"`
			} `json:"seats"`
		} `json:"game"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode game state: %v", err)
	}
	out := map[int]bool{}
	for _, s := range body.Game.Seats {
		out[s.Seat] = s.Deleted
	}
	return out
}

// TestDeleteStepUpEmail: an email account's delete code verifies (wrong code rejected, no
// deeplink in the mail); a platform-only account has no email and cannot request a code.
func TestDeleteStepUpEmail(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	mailer := &capturingMailer{}
	svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")

	acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
	if err != nil {
		t.Fatalf("provision: %v", err)
	}
	if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "del-"+uuid.NewString()+"@example.com", true); err != nil {
		t.Fatalf("attach email: %v", err)
	}
	if has, err := svc.HasEmail(ctx, acc.ID); err != nil || !has {
		t.Fatalf("HasEmail = (%v, %v), want true", has, err)
	}
	if err := svc.RequestDeleteCode(ctx, acc.ID); err != nil {
		t.Fatalf("request delete code: %v", err)
	}
	if strings.Contains(mailer.lastBody, "/confirm/") {
		t.Error("a delete email must not carry a one-tap deeplink")
	}
	code := sixDigit.FindString(mailer.lastBody)
	if err := svc.VerifyDeleteCode(ctx, acc.ID, "000000"); err == nil {
		t.Error("a wrong delete code must be rejected")
	}
	if err := svc.VerifyDeleteCode(ctx, acc.ID, code); err != nil {
		t.Fatalf("verify correct delete code: %v", err)
	}

	noEmail, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
	if err != nil {
		t.Fatalf("provision no-email: %v", err)
	}
	if has, _ := svc.HasEmail(ctx, noEmail.ID); has {
		t.Error("HasEmail must be false for a platform-only account")
	}
	if err := svc.RequestDeleteCode(ctx, noEmail.ID); !errors.Is(err, account.ErrNoEmail) {
		t.Errorf("request delete for no-email account = %v, want ErrNoEmail", err)
	}
}
