//go:build integration

package inttest

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

	"github.com/google/uuid"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/accountdelete"
)

// lastLoginIP reads an account's stamped last-login IP ("" when unset).
func lastLoginIP(t *testing.T, accountID uuid.UUID) string {
	t.Helper()
	var ip sql.NullString
	err := testDB.QueryRowContext(context.Background(),
		"SELECT last_login_ip FROM accounts WHERE account_id = $1", accountID).Scan(&ip)
	if err != nil {
		t.Fatalf("read last_login_ip %s: %v", accountID, err)
	}
	return ip.String
}

// retainedRow is one row of the retention journal, read directly for assertions.
type retainedRow struct {
	kind, externalID, reason string
}

// retainedRows reads the retention journal for an account, oldest detach first.
func retainedRows(t *testing.T, accountID uuid.UUID) []retainedRow {
	t.Helper()
	rows, err := testDB.QueryContext(context.Background(),
		"SELECT kind, external_id, reason FROM retained_identities WHERE account_id = $1 ORDER BY detached_at",
		accountID)
	if err != nil {
		t.Fatalf("query retained_identities %s: %v", accountID, err)
	}
	defer rows.Close()
	var out []retainedRow
	for rows.Next() {
		var r retainedRow
		if err := rows.Scan(&r.kind, &r.externalID, &r.reason); err != nil {
			t.Fatalf("scan retained row: %v", err)
		}
		out = append(out, r)
	}
	return out
}

// TestUnlinkJournalsRetainedIdentity: detaching a provider records it in the retention
// journal (reason=unlink) before the live identity is removed.
func TestUnlinkJournalsRetainedIdentity(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)

	tgExt := "tg-" + uuid.NewString()
	acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt)
	if err != nil {
		t.Fatalf("provision: %v", err)
	}
	email := "keep-" + uuid.NewString() + "@example.com"
	if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
		t.Fatalf("attach email: %v", err)
	}

	if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
		t.Fatalf("unlink telegram: %v", err)
	}
	got := retainedRows(t, acc.ID)
	if len(got) != 1 || got[0].kind != account.KindTelegram || got[0].externalID != tgExt || got[0].reason != "unlink" {
		t.Fatalf("retained rows = %+v, want one unlink telegram %q", got, tgExt)
	}
}

// TestChangeEmailJournalsOldAddress: an email change records the outgoing address in the
// retention journal (reason=change).
func TestChangeEmailJournalsOldAddress(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)
	}
	oldAddr := "old-" + uuid.NewString() + "@example.com"
	if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil {
		t.Fatalf("attach old email: %v", err)
	}
	newAddr := "new-" + uuid.NewString() + "@example.com"
	if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
		t.Fatalf("request change: %v", err)
	}
	if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, sixDigit.FindString(mailer.lastBody)); err != nil {
		t.Fatalf("confirm change: %v", err)
	}
	got := retainedRows(t, acc.ID)
	if len(got) != 1 || got[0].kind != account.KindEmail || got[0].externalID != oldAddr || got[0].reason != "change" {
		t.Fatalf("retained rows = %+v, want one change email %q", got, oldAddr)
	}
}

// TestStampLastLoginThrottles: the first cold-load stamp writes the IP; a second within
// the hour is a no-op (throttled), so it costs at most one write per account per hour.
func TestStampLastLoginThrottles(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
	if err != nil {
		t.Fatalf("provision: %v", err)
	}
	if err := store.StampLastLogin(ctx, acc.ID, "1.2.3.4"); err != nil {
		t.Fatalf("first stamp: %v", err)
	}
	if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" {
		t.Fatalf("first stamp ip = %q, want 1.2.3.4", got)
	}
	if err := store.StampLastLogin(ctx, acc.ID, "9.9.9.9"); err != nil {
		t.Fatalf("second stamp: %v", err)
	}
	if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" {
		t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got)
	}
}

// TestReapExpiredRetention: the reaper keeps journal rows newer than the cutoff and purges
// older ones, and drops a long-deleted account's feedback thread + dossier PII.
func TestReapExpiredRetention(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	deleter := accountdelete.NewDeleter(testDB)

	// An unlinked provider leaves a journal row detached "now".
	tgExt := "tg-" + uuid.NewString()
	acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt)
	if err != nil {
		t.Fatalf("provision: %v", err)
	}
	if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "keep-"+uuid.NewString()+"@example.com", true); err != nil {
		t.Fatalf("attach email: %v", err)
	}
	if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
		t.Fatalf("unlink: %v", err)
	}
	// A cutoff before the detach keeps the row.
	if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(-time.Hour)); err != nil {
		t.Fatalf("reap (early cutoff): %v", err)
	}
	if got := retainedRows(t, acc.ID); len(got) != 1 {
		t.Fatalf("journal after early-cutoff reap = %+v, want kept", got)
	}
	// A cutoff after the detach purges it.
	if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil {
		t.Fatalf("reap (late cutoff): %v", err)
	}
	if got := retainedRows(t, acc.ID); len(got) != 0 {
		t.Fatalf("journal after late-cutoff reap = %+v, want purged", got)
	}

	// A deleted account past the cutoff loses its feedback thread and dossier PII.
	del, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Иван", "")
	if err != nil {
		t.Fatalf("provision deletee: %v", err)
	}
	if _, err := testDB.ExecContext(ctx,
		"INSERT INTO feedback_messages (message_id, account_id, body, channel) VALUES ($1, $2, 'hi', 'web')",
		uuid.New(), del.ID); err != nil {
		t.Fatalf("insert feedback: %v", err)
	}
	if err := deleter.AnonymizeAndTombstone(ctx, del.ID); err != nil {
		t.Fatalf("delete: %v", err)
	}
	if _, fb, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil || fb == 0 {
		t.Fatalf("reap deleted = (fb %d, err %v), want fb>=1", fb, err)
	}
	if name, _ := deletedFields(t, del.ID); name != "" {
		t.Errorf("deleted_display_name after reap = %q, want cleared", name)
	}
	var fbCount int
	if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM feedback_messages WHERE account_id = $1", del.ID).Scan(&fbCount); err != nil {
		t.Fatalf("count feedback: %v", err)
	}
	if fbCount != 0 {
		t.Errorf("feedback rows after reap = %d, want 0", fbCount)
	}
}
