//go:build integration

package inttest

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

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/pressly/goose/v3"
	testcontainers "github.com/testcontainers/testcontainers-go"
	tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"

	"scrabble/backend/internal/postgres"
	"scrabble/backend/internal/postgres/migrations"
)

// isPgCode reports whether err is a PostgreSQL server error with the given
// SQLSTATE code.
func isPgCode(err error, code string) bool {
	var pgErr *pgconn.PgError
	return errors.As(err, &pgErr) && pgErr.Code == code
}

// TestPaymentsSchemaAndSeeds checks the payments schema, its role and the fixed
// seed rows are present after migration.
func TestPaymentsSchemaAndSeeds(t *testing.T) {
	ctx := context.Background()

	var atoms int
	if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.catalog_atom").Scan(&atoms); err != nil {
		t.Fatalf("count catalog_atom: %v", err)
	}
	if atoms != 4 {
		t.Errorf("catalog_atom rows = %d, want 4 (chips/hints/noads_days/tournament)", atoms)
	}

	var cfg int
	if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&cfg); err != nil {
		t.Fatalf("count config: %v", err)
	}
	if cfg != 1 {
		t.Errorf("config rows = %d, want the single seeded row", cfg)
	}

	var roleExists bool
	if err := testDB.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
		t.Fatalf("check payments role: %v", err)
	}
	if !roleExists {
		t.Error("payments role missing")
	}
}

// TestPaymentsRoleConfinement proves the payments role is confined to its own
// schema: it can read payments.* but is denied on backend.*. The application
// itself connects as a superuser (which bypasses this), so this asserts the
// grants are correct as the stepping-stone to a real separate login; the runtime
// wall for the superuser pool is the import-boundary test in the payments package.
func TestPaymentsRoleConfinement(t *testing.T) {
	ctx := context.Background()
	tx, err := testDB.BeginTx(ctx, nil)
	if err != nil {
		t.Fatalf("begin tx: %v", err)
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE payments"); err != nil {
		t.Fatalf("SET LOCAL ROLE payments: %v", err)
	}

	// The payments role can read its own schema.
	var n int
	if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&n); err != nil {
		t.Errorf("payments role denied on payments.config (grants wrong): %v", err)
	}

	// The payments role must NOT reach the backend schema.
	err = tx.QueryRowContext(ctx, "SELECT count(*) FROM backend.accounts").Scan(&n)
	if err == nil {
		t.Fatal("payments role could read backend.accounts — cross-schema isolation broken")
	}
	if !isPgCode(err, "42501") {
		t.Errorf("reading backend.accounts as payments: got %v, want permission-denied (42501)", err)
	}
}

// TestPaymentsLedgerAppendOnly verifies the append-only trigger rejects any
// UPDATE or DELETE on the ledger (a superuser is bound by the trigger, unlike a
// mere privilege REVOKE).
func TestPaymentsLedgerAppendOnly(t *testing.T) {
	ctx := context.Background()
	id := uuid.New()
	if _, err := testDB.ExecContext(ctx,
		`INSERT INTO payments.ledger (ledger_id, account_id, kind, chips_delta) VALUES ($1, $2, 'admin_grant', 0)`,
		id, uuid.New()); err != nil {
		t.Fatalf("insert ledger row: %v", err)
	}

	if _, err := testDB.ExecContext(ctx, `UPDATE payments.ledger SET chips_delta = 1 WHERE ledger_id = $1`, id); err == nil {
		t.Error("UPDATE on payments.ledger succeeded — append-only violated")
	} else if !isPgCode(err, "P0001") {
		t.Errorf("UPDATE ledger: got %v, want the append-only exception (P0001)", err)
	}

	if _, err := testDB.ExecContext(ctx, `DELETE FROM payments.ledger WHERE ledger_id = $1`, id); err == nil {
		t.Error("DELETE on payments.ledger succeeded — append-only violated")
	} else if !isPgCode(err, "P0001") {
		t.Errorf("DELETE ledger: got %v, want the append-only exception (P0001)", err)
	}
}

// TestPaymentsCheckConstraints verifies the domain CHECKs bite: non-negative
// balances/hints/amount and the enum-like columns.
func TestPaymentsCheckConstraints(t *testing.T) {
	ctx := context.Background()

	prod := uuid.New()
	if _, err := testDB.ExecContext(ctx, `INSERT INTO payments.product (product_id, title) VALUES ($1, 'test')`, prod); err != nil {
		t.Fatalf("insert product: %v", err)
	}

	cases := []struct {
		name string
		sql  string
		args []any
	}{
		{"balances chips negative", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
		{"balances bad source", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'nope', 0)`, []any{uuid.New()}},
		{"benefits hints negative", `INSERT INTO payments.benefits (account_id, origin, hints) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
		{"ledger bad kind", `INSERT INTO payments.ledger (ledger_id, account_id, kind) VALUES ($1, $2, 'bogus')`, []any{uuid.New(), uuid.New()}},
		{"product_price amount negative", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'RUB', -1)`, []any{prod}},
		{"product_price bad currency", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'EUR', 100)`, []any{prod}},
	}
	for _, c := range cases {
		if _, err := testDB.ExecContext(ctx, c.sql, c.args...); err == nil {
			t.Errorf("%s: insert succeeded, want a CHECK violation", c.name)
		} else if !isPgCode(err, "23514") {
			t.Errorf("%s: got %v, want check_violation (23514)", c.name, err)
		}
	}
}

// TestPaymentsLedgerIdempotencyIndex verifies the partial unique index makes a
// (provider, provider_payment_id) pair collide once but leaves NULL-keyed rows
// unconstrained.
func TestPaymentsLedgerIdempotencyIndex(t *testing.T) {
	ctx := context.Background()
	insert := func(ppid *string) error {
		_, err := testDB.ExecContext(ctx,
			`INSERT INTO payments.ledger (ledger_id, account_id, kind, provider, provider_payment_id) VALUES ($1, $2, 'fund', 'robokassa', $3)`,
			uuid.New(), uuid.New(), ppid)
		return err
	}

	ppid := "inv-" + uuid.NewString()
	if err := insert(&ppid); err != nil {
		t.Fatalf("first insert: %v", err)
	}
	if err := insert(&ppid); err == nil {
		t.Error("duplicate (provider, provider_payment_id) inserted — idempotency index missing")
	} else if !isPgCode(err, "23505") {
		t.Errorf("duplicate insert: got %v, want unique_violation (23505)", err)
	}

	// A NULL provider_payment_id is outside the partial index — repeats allowed.
	if err := insert(nil); err != nil {
		t.Fatalf("first null-ppid insert: %v", err)
	}
	if err := insert(nil); err != nil {
		t.Errorf("second null-ppid insert should be allowed by the partial index: %v", err)
	}
}

// TestPaymentsMigrationReversible proves the migration is expand-contract: it
// applies, rolls fully back (schema, role, trigger and all), and re-applies
// cleanly — so a backend image rollback stays DB-safe. It uses its own container
// so the Down does not disturb the shared suite database.
func TestPaymentsMigrationReversible(t *testing.T) {
	ctx := context.Background()

	container, err := tcpostgres.Run(ctx, pgImage,
		tcpostgres.WithDatabase(pgDatabase),
		tcpostgres.WithUsername(pgUser),
		tcpostgres.WithPassword(pgPassword),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).
				WithStartupTimeout(containerStartup),
		),
	)
	if err != nil {
		t.Fatalf("start container: %v", err)
	}
	defer func() { _ = container.Terminate(context.Background()) }()

	baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
	if err != nil {
		t.Fatalf("connection string: %v", err)
	}
	dsn, err := withSearchPath(baseDSN, pgSchema)
	if err != nil {
		t.Fatalf("search path: %v", err)
	}
	cfg := postgres.DefaultConfig()
	cfg.DSN = dsn
	db, err := postgres.Open(ctx, cfg)
	if err != nil {
		t.Fatalf("open pool: %v", err)
	}
	defer func() { _ = db.Close() }()

	// Up: apply every migration, including the payments foundation.
	if err := postgres.ApplyMigrations(ctx, db); err != nil {
		t.Fatalf("apply up: %v", err)
	}
	if !schemaExists(ctx, t, db, "payments") {
		t.Fatal("payments schema absent after up")
	}

	// Down the payments migration and assert a clean teardown.
	goose.SetBaseFS(migrations.Migrations())
	defer goose.SetBaseFS(nil)
	if err := goose.SetDialect("postgres"); err != nil {
		t.Fatalf("set dialect: %v", err)
	}
	if err := goose.DownToContext(ctx, db, ".", 9); err != nil {
		t.Fatalf("down to 9: %v", err)
	}
	if schemaExists(ctx, t, db, "payments") {
		t.Error("payments schema survived the down migration")
	}
	var roleExists bool
	if err := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
		t.Fatalf("check role after down: %v", err)
	}
	if roleExists {
		t.Error("payments role survived the down migration")
	}

	// Re-apply and assert it comes back cleanly.
	if err := goose.UpContext(ctx, db, "."); err != nil {
		t.Fatalf("re-apply up: %v", err)
	}
	if !schemaExists(ctx, t, db, "payments") {
		t.Fatal("payments schema absent after re-apply")
	}
}

// schemaExists reports whether a schema of the given name is present.
func schemaExists(ctx context.Context, t *testing.T, db *sql.DB, name string) bool {
	t.Helper()
	var exists bool
	if err := db.QueryRowContext(ctx,
		"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = $1)", name).Scan(&exists); err != nil {
		t.Fatalf("check schema %s: %v", name, err)
	}
	return exists
}
