//go:build postgres

// Integration tests for the authd Store. Run with:
//
//   go test -tags=postgres ./internal/authd/
//
// Requires a running Postgres with the 009_auth migration applied
// to the database. The TEST_AUTH_DSN env var must be set:
//
//   TEST_AUTH_DSN=postgres://postgres:testing@localhost:5432/test_auth?sslmode=disable
//
// These tests use the 'auth' schema directly (no separate test
// schema). Each test TRUNCATEs all the auth tables in setup so
// they don't see each other's data.

package authd

import (
	"context"
	"errors"
	"os"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"golang.org/x/crypto/bcrypt"

	"git3.techno-world.net/lrosales/broad-announce/internal/postgres"
)

func setupTestDB(t *testing.T) *postgres.Pool {
	t.Helper()
	dsn := os.Getenv("TEST_AUTH_DSN")
	if dsn == "" {
		t.Skip("TEST_AUTH_DSN not set; skipping integration tests")
	}
	cfg, err := pgxpool.ParseConfig(dsn)
	if err != nil {
		t.Fatalf("parse dsn: %v", err)
	}
	pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	pool, err := pgxpool.NewWithConfig(pingCtx, cfg)
	if err != nil {
		t.Fatalf("new: %v", err)
	}
	if err := pool.Ping(pingCtx); err != nil {
		pool.Close()
		t.Fatalf("ping: %v", err)
	}
	t.Cleanup(func() { pool.Close() })

	// Apply the migration if it hasn't been yet (idempotent — uses
	// CREATE TABLE IF NOT EXISTS).
	migPath := findMigration(t)
	migSQL, err := os.ReadFile(migPath)
	if err != nil {
		t.Fatalf("read migration: %v", err)
	}
	if _, err := pool.Exec(context.Background(), string(migSQL)); err != nil {
		t.Fatalf("apply migration: %v", err)
	}

	// Truncate all auth tables so each test starts fresh. CASCADE
	// handles FK dependencies.
	if _, err := pool.Exec(context.Background(),
		"TRUNCATE auth.audit_log, auth.sessions, auth.refresh_tokens, auth.magic_links, auth.users, auth.tenants RESTART IDENTITY CASCADE"); err != nil {
		t.Fatalf("truncate: %v", err)
	}
	return pool
}

func findMigration(t *testing.T) string {
	t.Helper()
	candidates := []string{
		"../../migrations/009_auth.up.sql",
		"migrations/009_auth.up.sql",
	}
	for _, p := range candidates {
		if _, err := os.Stat(p); err == nil {
			return p
		}
	}
	t.Fatal("cannot find migrations/009_auth.up.sql")
	return ""
}

func newTestAuthd(t *testing.T, pool *postgres.Pool) *Authd {
	t.Helper()
	cfg := DefaultConfig()
	cfg.JWTSecret = []byte("test-secret-with-32-bytes-min-len")
	// Use bcrypt MinCost for tests — production uses 12.
	cfg.BcryptCost = bcrypt.MinCost
	a, err := New(pool, cfg)
	if err != nil {
		t.Fatalf("new: %v", err)
	}
	return a
}

func TestStore_CreateAndGetUser(t *testing.T) {
	pool := setupTestDB(t)
	s := NewStore(pool)
	ctx := context.Background()

	id, err := s.CreateUser(ctx, "", "super@x.test", "super_admin")
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	u, err := s.GetUserByID(ctx, id)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if u.Email != "super@x.test" {
		t.Errorf("email = %q, want super@x.test", u.Email)
	}
	if u.Role != "super_admin" {
		t.Errorf("role = %q, want super_admin", u.Role)
	}
	if u.Status != "pending" {
		t.Errorf("status = %q, want pending", u.Status)
	}
}

func TestStore_DuplicateEmailRejected(t *testing.T) {
	pool := setupTestDB(t)
	s := NewStore(pool)
	ctx := context.Background()

	_, err := s.CreateUser(ctx, "", "dup@x.test", "super_admin")
	if err != nil {
		t.Fatalf("first create: %v", err)
	}
	_, err = s.CreateUser(ctx, "", "dup@x.test", "super_admin")
	if err == nil {
		t.Error("expected duplicate-email error, got nil")
	}
}

func TestAuthd_MagicLink_IssueConsume(t *testing.T) {
	pool := setupTestDB(t)
	a := newTestAuthd(t, pool)
	s := a.store
	ctx := context.Background()

	uid, _ := s.CreateUser(ctx, "", "m@x.test", "super_admin")
	token, exp, err := a.IssueMagicLink(ctx, uid, "invite")
	if err != nil {
		t.Fatalf("issue: %v", err)
	}
	if len(token) != 64 {
		t.Errorf("token length = %d, want 64", len(token))
	}
	if !exp.After(time.Now()) {
		t.Error("token expiry in the past")
	}
	gotUID, err := a.ConsumeMagicLink(ctx, token, "127.0.0.1", "test-ua")
	if err != nil {
		t.Fatalf("consume: %v", err)
	}
	if gotUID != uid {
		t.Errorf("user_id = %q, want %q", gotUID, uid)
	}
	// Re-consume must fail
	if _, err := a.ConsumeMagicLink(ctx, token, "127.0.0.1", "test-ua"); !errors.Is(err, ErrMagicLinkInvalid) {
		t.Errorf("re-consume: err = %v, want ErrMagicLinkInvalid", err)
	}
}

func TestAuthd_LoginAndRefreshAndLogout(t *testing.T) {
	pool := setupTestDB(t)
	a := newTestAuthd(t, pool)
	s := a.store
	ctx := context.Background()

	// Provision: tenant + user with password
	if _, err := pool.Exec(ctx, `
		INSERT INTO auth.tenants (slug, display_name, contact_email)
		VALUES ('acme', 'Acme', 'a@a.test')
	`); err != nil {
		t.Fatalf("insert tenant: %v", err)
	}
	var tenantID string
	if err := pool.QueryRow(ctx, `SELECT id::text FROM auth.tenants WHERE slug='acme'`).Scan(&tenantID); err != nil {
		t.Fatalf("get tenant: %v", err)
	}
	uid, err := s.CreateUser(ctx, tenantID, "admin@acme.test", "tenant_admin")
	if err != nil {
		t.Fatalf("create user: %v", err)
	}
	hash, _ := a.HashPassword(ctx, "correct-password")
	if err := s.SetUserPassword(ctx, uid, hash, "active"); err != nil {
		t.Fatalf("set password: %v", err)
	}

	// 1) Login wrong password
	if _, err := a.Login(ctx, "admin@acme.test", "wrong", "127.0.0.1", "ua"); !errors.Is(err, ErrInvalidCredentials) {
		t.Errorf("login wrong: err = %v, want ErrInvalidCredentials", err)
	}

	// 2) Login right password
	res, err := a.Login(ctx, "admin@acme.test", "correct-password", "127.0.0.1", "ua")
	if err != nil {
		t.Fatalf("login ok: %v", err)
	}
	if res.AccessToken == "" || res.RefreshToken == "" {
		t.Fatal("empty tokens in login result")
	}
	if res.TenantID != tenantID {
		t.Errorf("tenantID = %q, want %q", res.TenantID, tenantID)
	}
	if res.Role != "tenant_admin" {
		t.Errorf("role = %q, want tenant_admin", res.Role)
	}

	// Verify the access token
	claims, err := a.VerifyAccessToken(res.AccessToken)
	if err != nil {
		t.Fatalf("verify jwt: %v", err)
	}
	if claims.UserID != uid || claims.TenantID != tenantID {
		t.Errorf("claims mismatch: %+v", claims)
	}

	// 3) Refresh
	res2, err := a.Refresh(ctx, res.RefreshToken, "127.0.0.1", "ua")
	if err != nil {
		t.Fatalf("refresh: %v", err)
	}
	if res2.RefreshToken == res.RefreshToken {
		t.Error("refresh returned the same token (no rotation)")
	}
	if res2.AccessToken == res.AccessToken {
		t.Error("refresh returned the same access token (no JTI rotation)")
	}

	// 4) Re-use the OLD refresh token — should kill the family
	_, err = a.Refresh(ctx, res.RefreshToken, "127.0.0.1", "ua")
	if !errors.Is(err, ErrTokenReuse) {
		t.Errorf("reuse old refresh: err = %v, want ErrTokenReuse", err)
	}

	// The rotated (new) refresh should now ALSO be revoked
	_, err = a.Refresh(ctx, res2.RefreshToken, "127.0.0.1", "ua")
	if !errors.Is(err, ErrTokenReuse) {
		t.Errorf("use new refresh after kill: err = %v, want ErrTokenReuse", err)
	}

	// 5) Logout flow on a fresh session
	res3, err := a.Login(ctx, "admin@acme.test", "correct-password", "127.0.0.1", "ua")
	if err != nil {
		t.Fatalf("re-login: %v", err)
	}
	if err := a.Logout(ctx, res3.RefreshToken, uid, "127.0.0.1", "ua"); err != nil {
		t.Errorf("logout: %v", err)
	}
	// Refresh after logout should fail with reuse (since it was rotated once
	// internally? no — logout is a direct revoke, not a rotation. The
	// presented token is now revoked. Subsequent use = reuse detection.)
	_, err = a.Refresh(ctx, res3.RefreshToken, "127.0.0.1", "ua")
	if err == nil {
		t.Error("refresh after logout: expected error, got nil")
	}
}

func TestAuthd_InviteAndSetPassword(t *testing.T) {
	pool := setupTestDB(t)
	a := newTestAuthd(t, pool)
	ctx := context.Background()

	if _, err := pool.Exec(ctx, `
		INSERT INTO auth.tenants (slug, display_name, contact_email)
		VALUES ('beta', 'Beta', 'b@b.test')
	`); err != nil {
		t.Fatalf("insert tenant: %v", err)
	}
	var tenantID string
	if err := pool.QueryRow(ctx, `SELECT id::text FROM auth.tenants WHERE slug='beta'`).Scan(&tenantID); err != nil {
		t.Fatalf("get tenant: %v", err)
	}
	inviter := "00000000-0000-0000-0000-000000000001"
	magic, uid, err := a.InviteUser(ctx, tenantID, "new@beta.test", "viewer", inviter, "127.0.0.1", "ua")
	if err != nil {
		t.Fatalf("invite: %v", err)
	}
	if magic == "" || uid == "" {
		t.Fatal("invite returned empty token or id")
	}

	// Consume the magic link, then set the password
	gotUID, err := a.ConsumeMagicLink(ctx, magic, "127.0.0.1", "ua")
	if err != nil {
		t.Fatalf("consume: %v", err)
	}
	if gotUID != uid {
		t.Errorf("consume uid = %q, want %q", gotUID, uid)
	}
	if err := a.SetPassword(ctx, uid, "new-password"); err != nil {
		t.Fatalf("set password: %v", err)
	}

	// Login with the new password
	if _, err := a.Login(ctx, "new@beta.test", "new-password", "127.0.0.1", "ua"); err != nil {
		t.Errorf("login after invite+setpassword: %v", err)
	}
}

func TestStore_AuditLog(t *testing.T) {
	pool := setupTestDB(t)
	s := NewStore(pool)
	ctx := context.Background()

	if err := s.WriteAudit(ctx, "test.event", "", "", "", "", "", map[string]any{"x": 1}); err != nil {
		t.Fatalf("write audit: %v", err)
	}
	var count int
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM auth.audit_log WHERE action='test.event'`).Scan(&count); err != nil {
		t.Fatalf("count: %v", err)
	}
	if count != 1 {
		t.Errorf("audit count = %d, want 1", count)
	}
}
