package authd

import (
	"context"
	"testing"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"
)

// These tests cover the pure-Go logic in authd.go that does NOT
// require Postgres: bcrypt password hashing/verification, JWT
// signing/verification, magic-link token generation/hashing, and
// the SecureEqual helper. The DB-backed paths are tested in
// store_test.go (build tag 'postgres').

func TestHashPassword_And_Verify(t *testing.T) {
	cfg := DefaultConfig()
	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
	a, err := New(nil, cfg) // nil pool ok for password tests
	if err != nil {
		t.Fatalf("new authd: %v", err)
	}
	hash, err := a.HashPassword(context.Background(), "hunter2-correct-horse")
	if err != nil {
		t.Fatalf("hash: %v", err)
	}
	if hash == "" {
		t.Fatal("hash is empty")
	}
	if err := a.VerifyPassword(hash, "hunter2-correct-horse"); err != nil {
		t.Errorf("verify correct: %v", err)
	}
	if err := a.VerifyPassword(hash, "wrong"); err == nil {
		t.Error("verify wrong: expected error, got nil")
	}
}

func TestHashPassword_BcryptCost(t *testing.T) {
	cfg := DefaultConfig()
	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
	cfg.BcryptCost = bcrypt.MinCost // speed up test
	a, _ := New(nil, cfg)
	start := time.Now()
	_, err := a.HashPassword(context.Background(), "x")
	if err != nil {
		t.Fatal(err)
	}
	if elapsed := time.Since(start); elapsed > 2*time.Second {
		t.Errorf("bcrypt MinCost should be <2s, got %s", elapsed)
	}
}

func TestNew_RejectsShortSecret(t *testing.T) {
	cfg := DefaultConfig()
	cfg.JWTSecret = []byte("too-short")
	if _, err := New(nil, cfg); err == nil {
		t.Error("expected error for short JWT secret, got nil")
	}
}

func TestNew_AppliesDefaults(t *testing.T) {
	cfg := Config{
		JWTSecret: []byte("this-is-a-test-secret-with-32-bytes-min"),
	}
	a, err := New(nil, cfg)
	if err != nil {
		t.Fatalf("new: %v", err)
	}
	if a.cfg.AccessTokenTTL != 15*time.Minute {
		t.Errorf("AccessTokenTTL default = %s, want 15m", a.cfg.AccessTokenTTL)
	}
	if a.cfg.RefreshTokenTTL != 7*24*time.Hour {
		t.Errorf("RefreshTokenTTL default = %s, want 7d", a.cfg.RefreshTokenTTL)
	}
	if a.cfg.MagicLinkTTL != 24*time.Hour {
		t.Errorf("MagicLinkTTL default = %s, want 24h", a.cfg.MagicLinkTTL)
	}
	if a.cfg.BcryptCost != 12 {
		t.Errorf("BcryptCost default = %d, want 12", a.cfg.BcryptCost)
	}
}

func TestIssueMagicLink_ReturnsHexToken(t *testing.T) {
	cfg := DefaultConfig()
	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
	a, _ := New(nil, cfg)

	// IssueMagicLink requires a real DB (InsertMagicLink). We can
	// only test the token shape via the random source by
	// calling the parts we can. Skip if pool is nil.
	// To avoid coupling, we just check that the method exists and
	// the error path is what we expect (DB unavailable).
	_, _, err := a.IssueMagicLink(context.Background(), "00000000-0000-0000-0000-000000000000", "invite")
	if err == nil {
		t.Error("expected DB error, got nil (pool was nil)")
	}
}

func TestConsumeMagicLink_BadInput(t *testing.T) {
	cfg := DefaultConfig()
	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
	a, _ := New(nil, cfg)
	// Wrong length
	_, err := a.ConsumeMagicLink(context.Background(), "tooshort", "1.1.1.1", "ua")
	if err != ErrMagicLinkInvalid {
		t.Errorf("short token: err = %v, want ErrMagicLinkInvalid", err)
	}
	// Bad hex
	_, err = a.ConsumeMagicLink(context.Background(), "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "1.1.1.1", "ua")
	if err != ErrMagicLinkInvalid {
		t.Errorf("bad hex: err = %v, want ErrMagicLinkInvalid", err)
	}
}

func TestVerifyAccessToken_RoundTrip(t *testing.T) {
	cfg := DefaultConfig()
	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
	a, _ := New(nil, cfg)

	user := &User{ID: "u-1", TenantID: "t-1", Role: "tenant_admin", Status: "active"}
	accessJWT, jti, exp, err := a.mintAccessToken(user)
	if err != nil {
		t.Fatalf("mint: %v", err)
	}
	if accessJWT == "" {
		t.Fatal("empty token")
	}
	if jti == "" {
		t.Fatal("empty jti")
	}
	if !exp.After(time.Now()) {
		t.Fatal("token already expired")
	}

	claims, err := a.VerifyAccessToken(accessJWT)
	if err != nil {
		t.Fatalf("verify: %v", err)
	}
	if claims.UserID != "u-1" {
		t.Errorf("UserID = %q, want u-1", claims.UserID)
	}
	if claims.TenantID != "t-1" {
		t.Errorf("TenantID = %q, want t-1", claims.TenantID)
	}
	if claims.Role != "tenant_admin" {
		t.Errorf("Role = %q, want tenant_admin", claims.Role)
	}
	if claims.TokenType != "access" {
		t.Errorf("TokenType = %q, want access", claims.TokenType)
	}
}

func TestVerifyAccessToken_RejectsBadSecret(t *testing.T) {
	cfg := DefaultConfig()
	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
	a, _ := New(nil, cfg)
	user := &User{ID: "u-1", Status: "active"}
	accessJWT, _, _, _ := a.mintAccessToken(user)

	// Same secret, different bytes
	other := DefaultConfig()
	other.JWTSecret = []byte("different-secret-with-32-bytes-min!!")
	b, _ := New(nil, other)
	if _, err := b.VerifyAccessToken(accessJWT); err == nil {
		t.Error("expected error from different-secret verifier, got nil")
	}
}

func TestVerifyAccessToken_RejectsExpired(t *testing.T) {
	cfg := DefaultConfig()
	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
	cfg.AccessTokenTTL = -1 * time.Minute // already expired
	a, _ := New(nil, cfg)

	user := &User{ID: "u-1", Status: "active"}
	accessJWT, _, _, _ := a.mintAccessToken(user)

	_, err := a.VerifyAccessToken(accessJWT)
	if err == nil {
		t.Error("expected expired-token error, got nil")
	}
}

func TestVerifyAccessToken_RejectsAlgConfusion(t *testing.T) {
	// Classic alg=none / RS256-confused-as-HS256 attack. The verifier
	// must reject any non-HMAC alg. We craft a token signed with the
	// "none" alg and confirm it's rejected.
	cfg := DefaultConfig()
	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
	a, _ := New(nil, cfg)

	claims := AccessClaims{
		UserID:    "u-1",
		Role:      "tenant_admin",
		TokenType: "access",
		RegisteredClaims: jwt.RegisteredClaims{
			Issuer:    "broad-announce",
			Subject:   "u-1",
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
		},
	}
	t1 := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
	noneToken, err := t1.SignedString(jwt.UnsafeAllowNoneSignatureType)
	if err != nil {
		t.Fatalf("sign with none: %v", err)
	}
	if _, err := a.VerifyAccessToken(noneToken); err == nil {
		t.Error("expected verifier to reject 'none' alg, got nil error")
	}
}

func TestSecureEqual(t *testing.T) {
	if !SecureEqual([]byte("abc"), []byte("abc")) {
		t.Error("equal slices: want true")
	}
	if SecureEqual([]byte("abc"), []byte("abd")) {
		t.Error("differing slices: want false")
	}
	if SecureEqual([]byte("abc"), []byte("abcd")) {
		t.Error("different lengths: want false")
	}
}

func TestNewJTI_Unique(t *testing.T) {
	seen := make(map[string]struct{}, 1000)
	for i := 0; i < 1000; i++ {
		id := newJTI()
		if _, dup := seen[id]; dup {
			t.Fatalf("duplicate JTI on iter %d: %s", i, id)
		}
		seen[id] = struct{}{}
	}
}

func TestNewJTI_HexShape(t *testing.T) {
	id := newJTI()
	if len(id) != 32 {
		t.Errorf("JTI length = %d, want 32 hex chars (16 bytes)", len(id))
	}
	for _, c := range id {
		if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
			t.Fatalf("non-hex char in JTI: %q", c)
		}
	}
}
