package handlers

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"testing"
	"time"

	webauthnlib "github.com/go-webauthn/webauthn/webauthn"
	"github.com/gofiber/fiber/v2"
	"github.com/pquerna/otp/totp"

	"wucher/internal/config"
	"wucher/internal/domain/auth"
	"wucher/internal/domain/contact"
	"wucher/internal/service"
	sharedconst "wucher/internal/shared/const"
	"wucher/internal/shared/pkg/uuidv7"
	"wucher/internal/transport/http/dto"
)

type memTokenStore struct {
	store   map[string][]byte
	lastKey string
}

func newMemTokenStore() *memTokenStore {
	return &memTokenStore{store: map[string][]byte{}}
}

func (m *memTokenStore) Set(_ context.Context, key string, value []byte, _ time.Duration) error {
	m.store[key] = value
	m.lastKey = key
	return nil
}

func (m *memTokenStore) SetIfNotExists(_ context.Context, key string, value []byte, _ time.Duration) (bool, error) {
	if _, ok := m.store[key]; ok {
		return false, nil
	}
	m.store[key] = value
	m.lastKey = key
	return true, nil
}

func (m *memTokenStore) Get(_ context.Context, key string) ([]byte, error) {
	v, ok := m.store[key]
	if !ok {
		return nil, nil
	}
	return v, nil
}

func (m *memTokenStore) Delete(_ context.Context, key string) error {
	delete(m.store, key)
	return nil
}

type memRepo struct {
	users                  map[string]*auth.User
	usersByMail            map[string]*auth.User
	usersByUsername        map[string]*auth.User
	identities             map[string]*auth.UserIdentity
	totp                   map[string]*auth.UserTOTP
	webauthnCredentials    map[string][]auth.UserWebAuthnCredential
	resetTokens            map[string]*auth.PasswordResetToken
	securityPINResetTokens map[string]*auth.SecurityPINResetToken
	emailLoginOTPs         map[string]*auth.UserEmailLoginOTP
	rolesByName            map[string]*auth.Role
	rolesByID              map[string]*auth.Role
	permsByRole            map[string][]auth.Permission
	permsErr               error

	consumeResetErr    error
	consumePINResetErr error
	deleteTOTPErr      error
	deleteWebAuthnErr  error
}

func newMemRepo() *memRepo {
	return &memRepo{
		users:                  map[string]*auth.User{},
		usersByMail:            map[string]*auth.User{},
		usersByUsername:        map[string]*auth.User{},
		identities:             map[string]*auth.UserIdentity{},
		totp:                   map[string]*auth.UserTOTP{},
		webauthnCredentials:    map[string][]auth.UserWebAuthnCredential{},
		resetTokens:            map[string]*auth.PasswordResetToken{},
		securityPINResetTokens: map[string]*auth.SecurityPINResetToken{},
		emailLoginOTPs:         map[string]*auth.UserEmailLoginOTP{},
		rolesByName:            map[string]*auth.Role{},
		rolesByID:              map[string]*auth.Role{},
		permsByRole:            map[string][]auth.Permission{},
	}
}

func (r *memRepo) CreateUser(_ context.Context, user *auth.User) error {
	if user != nil && !user.IsActive {
		user.IsActive = true
	}
	r.users[string(user.ID)] = user
	r.usersByMail[user.Email] = user
	if user.Username != nil {
		r.usersByUsername[*user.Username] = user
	}
	return nil
}
func (r *memRepo) GetUserByID(_ context.Context, id []byte) (*auth.User, error) {
	return r.users[string(id)], nil
}
func (r *memRepo) GetUserByEmail(_ context.Context, email string) (*auth.User, error) {
	return r.usersByMail[email], nil
}
func (r *memRepo) GetUserBySSOEmail(_ context.Context, ssoEmail string) (*auth.User, error) {
	return r.usersByMail[ssoEmail], nil
}
func (r *memRepo) GetUserByUsername(_ context.Context, username string) (*auth.User, error) {
	return r.usersByUsername[username], nil
}
func (r *memRepo) UpdateUserTimezone(_ context.Context, id []byte, timezoneName string) error {
	u := r.users[string(id)]
	if u == nil {
		return nil
	}
	u.Timezone = timezoneName
	return nil
}
func (r *memRepo) SetUserPassword(_ context.Context, id []byte, passwordHash, salt []byte) error {
	u := r.users[string(id)]
	if u == nil {
		return nil
	}
	u.PasswordHash = passwordHash
	u.SaltValue = salt
	return nil
}
func (r *memRepo) SetUserSecurityPIN(_ context.Context, id []byte, pinHash []byte, setAt time.Time) error {
	u := r.users[string(id)]
	if u == nil {
		return nil
	}
	u.SecurityPINHash = append([]byte(nil), pinHash...)
	ts := setAt
	u.SecurityPINSetAt = &ts
	return nil
}
func (r *memRepo) MarkEmailVerified(_ context.Context, id []byte) error {
	u := r.users[string(id)]
	now := time.Now()
	u.EmailVerifiedAt = &now
	return nil
}
func (r *memRepo) CreatePasswordResetToken(_ context.Context, token *auth.PasswordResetToken) error {
	if token == nil {
		return nil
	}
	cp := *token
	if len(cp.ID) == 0 {
		cp.ID = uuidv7.MustBytes()
	}
	r.resetTokens[string(cp.TokenHash)] = &cp
	return nil
}
func (r *memRepo) InvalidateActivePasswordResetTokensByUserID(_ context.Context, userID []byte, usedAt time.Time) error {
	for _, rec := range r.resetTokens {
		if string(rec.UserID) == string(userID) && rec.UsedAt == nil && rec.ExpiresAt.After(usedAt) {
			ts := usedAt
			rec.UsedAt = &ts
		}
	}
	return nil
}
func (r *memRepo) ConsumePasswordResetTokenAndSetPassword(_ context.Context, tokenHash, passwordHash, salt []byte, usedAt time.Time) ([]byte, bool, error) {
	if r.consumeResetErr != nil {
		return nil, false, r.consumeResetErr
	}
	rec := r.resetTokens[string(tokenHash)]
	if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(usedAt) {
		return nil, false, nil
	}
	u := r.users[string(rec.UserID)]
	if u == nil {
		return nil, false, nil
	}
	u.PasswordHash = passwordHash
	u.SaltValue = salt
	ts := usedAt
	rec.UsedAt = &ts
	for _, other := range r.resetTokens {
		if string(other.UserID) == string(rec.UserID) && string(other.ID) != string(rec.ID) && other.UsedAt == nil && other.ExpiresAt.After(usedAt) {
			otherTS := usedAt
			other.UsedAt = &otherTS
		}
	}
	return rec.UserID, true, nil
}
func (r *memRepo) CreateSecurityPINResetToken(_ context.Context, token *auth.SecurityPINResetToken) error {
	if token == nil {
		return nil
	}
	cp := *token
	if len(cp.ID) == 0 {
		cp.ID = uuidv7.MustBytes()
	}
	r.securityPINResetTokens[string(cp.TokenHash)] = &cp
	return nil
}
func (r *memRepo) InvalidateActiveSecurityPINResetTokensByUserID(_ context.Context, userID []byte, usedAt time.Time) error {
	for _, rec := range r.securityPINResetTokens {
		if string(rec.UserID) == string(userID) && rec.UsedAt == nil && rec.ExpiresAt.After(usedAt) {
			ts := usedAt
			rec.UsedAt = &ts
		}
	}
	return nil
}
func (r *memRepo) GetActiveSecurityPINResetTokenUser(_ context.Context, tokenHash []byte, now time.Time) (*auth.User, error) {
	rec := r.securityPINResetTokens[string(tokenHash)]
	if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(now) {
		return nil, nil
	}
	return r.users[string(rec.UserID)], nil
}
func (r *memRepo) ConsumeSecurityPINResetTokenAndSetPIN(_ context.Context, tokenHash, pinHash []byte, usedAt time.Time) ([]byte, bool, error) {
	if r.consumePINResetErr != nil {
		return nil, false, r.consumePINResetErr
	}
	rec := r.securityPINResetTokens[string(tokenHash)]
	if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(usedAt) {
		return nil, false, nil
	}
	u := r.users[string(rec.UserID)]
	if u == nil {
		return nil, false, nil
	}
	u.SecurityPINHash = append([]byte(nil), pinHash...)
	ts := usedAt
	u.SecurityPINSetAt = &ts
	rec.UsedAt = &ts
	return rec.UserID, true, nil
}
func (r *memRepo) UpsertIdentity(_ context.Context, identity *auth.UserIdentity) error {
	r.identities[identity.Provider+"|"+identity.ProviderSubject] = identity
	return nil
}
func (r *memRepo) CreateIdentity(_ context.Context, identity *auth.UserIdentity) error {
	r.identities[identity.Provider+"|"+identity.ProviderSubject] = identity
	return nil
}
func (r *memRepo) GetIdentityByProviderSubject(_ context.Context, provider, subject string) (*auth.UserIdentity, error) {
	return r.identities[provider+"|"+subject], nil
}
func (r *memRepo) GetIdentityByUserID(_ context.Context, userID []byte) (*auth.UserIdentity, error) {
	for _, identity := range r.identities {
		if bytes.Equal(identity.UserID, userID) {
			return identity, nil
		}
	}
	return nil, nil
}
func (r *memRepo) DeleteIdentityByUserIDProvider(_ context.Context, userID []byte, provider string) (bool, error) {
	for key, identity := range r.identities {
		if bytes.Equal(identity.UserID, userID) && identity.Provider == provider {
			delete(r.identities, key)
			return true, nil
		}
	}
	return false, nil
}
func (r *memRepo) GetUserTOTP(_ context.Context, userID []byte) (*auth.UserTOTP, error) {
	return r.totp[string(userID)], nil
}
func (r *memRepo) UpsertUserTOTP(_ context.Context, rec *auth.UserTOTP) error {
	r.totp[string(rec.UserID)] = rec
	return nil
}
func (r *memRepo) DisableUserTOTP(_ context.Context, userID []byte) error {
	if r.totp[string(userID)] != nil {
		r.totp[string(userID)].Enabled = false
	}
	return nil
}
func (r *memRepo) DeleteUserTOTPSetup(_ context.Context, userID []byte) error {
	if r.deleteTOTPErr != nil {
		return r.deleteTOTPErr
	}
	delete(r.totp, string(userID))
	return nil
}
func (r *memRepo) UpdateUserTOTPLastUsed(_ context.Context, userID []byte) error { return nil }
func (r *memRepo) GetActiveUserEmailLoginOTP(_ context.Context, userID []byte, now time.Time) (*auth.UserEmailLoginOTP, error) {
	rec := r.emailLoginOTPs[string(userID)]
	if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(now) {
		return nil, nil
	}
	return rec, nil
}
func (r *memRepo) CountUserEmailLoginOTPSince(_ context.Context, userID []byte, since time.Time) (int64, error) {
	rec := r.emailLoginOTPs[string(userID)]
	if rec == nil || rec.CreatedAt.Before(since) {
		return 0, nil
	}
	return 1, nil
}
func (r *memRepo) CreateUserEmailLoginOTP(_ context.Context, otp *auth.UserEmailLoginOTP) error {
	if otp == nil {
		return nil
	}
	cp := *otp
	if cp.CreatedAt.IsZero() {
		cp.CreatedAt = time.Now().UTC()
	}
	r.emailLoginOTPs[string(cp.UserID)] = &cp
	return nil
}
func (r *memRepo) InvalidateActiveUserEmailLoginOTPs(_ context.Context, userID []byte, usedAt time.Time) error {
	rec := r.emailLoginOTPs[string(userID)]
	if rec != nil && rec.UsedAt == nil && rec.ExpiresAt.After(usedAt) {
		ts := usedAt
		rec.UsedAt = &ts
	}
	return nil
}
func (r *memRepo) ConsumeUserEmailLoginOTP(_ context.Context, userID, otpHash []byte, now time.Time) (bool, int, error) {
	rec := r.emailLoginOTPs[string(userID)]
	if rec == nil || rec.UsedAt != nil || !rec.ExpiresAt.After(now) {
		return false, 0, nil
	}
	if bytes.Equal(rec.OTPHash, otpHash) {
		ts := now
		rec.UsedAt = &ts
		return true, max(0, rec.MaxAttempts-rec.AttemptCount), nil
	}
	rec.AttemptCount++
	left := max(0, rec.MaxAttempts-rec.AttemptCount)
	if rec.AttemptCount >= rec.MaxAttempts {
		ts := now
		rec.UsedAt = &ts
	}
	return false, left, nil
}
func (r *memRepo) ListUserWebAuthnCredentials(_ context.Context, userID []byte) ([]auth.UserWebAuthnCredential, error) {
	list := r.webauthnCredentials[string(userID)]
	out := make([]auth.UserWebAuthnCredential, len(list))
	copy(out, list)
	return out, nil
}
func (r *memRepo) CreateUserWebAuthnCredential(_ context.Context, credential *auth.UserWebAuthnCredential) error {
	if credential == nil {
		return nil
	}
	cp := *credential
	if len(cp.ID) == 0 {
		cp.ID = uuidv7.MustBytes()
	}
	key := string(cp.UserID)
	r.webauthnCredentials[key] = append(r.webauthnCredentials[key], cp)
	return nil
}
func (r *memRepo) UpdateUserWebAuthnCredential(_ context.Context, userID, credentialID, credentialJSON []byte, usedAt time.Time) error {
	key := string(userID)
	list := r.webauthnCredentials[key]
	for i := range list {
		if bytes.Equal(list[i].CredentialID, credentialID) {
			list[i].CredentialJSON = append([]byte(nil), credentialJSON...)
			ts := usedAt
			list[i].LastUsedAt = &ts
			r.webauthnCredentials[key] = list
			return nil
		}
	}
	return nil
}
func (r *memRepo) DeleteAllUserWebAuthnCredentials(_ context.Context, userID []byte) error {
	if r.deleteWebAuthnErr != nil {
		return r.deleteWebAuthnErr
	}
	delete(r.webauthnCredentials, string(userID))
	return nil
}

func (r *memRepo) CreateRole(_ context.Context, role *auth.Role) error { return nil }
func (r *memRepo) UpdateRole(_ context.Context, role *auth.Role) error { return nil }
func (r *memRepo) DeleteRole(_ context.Context, id []byte) error       { return nil }
func (r *memRepo) GetRoleByID(_ context.Context, id []byte) (*auth.Role, error) {
	return r.rolesByID[string(id)], nil
}
func (r *memRepo) GetRoleByName(_ context.Context, name string) (*auth.Role, error) {
	return r.rolesByName[name], nil
}
func (r *memRepo) ListRoles(_ context.Context, _ string, _ string, _ int, _ int) ([]auth.Role, int64, error) {
	return nil, 0, nil
}
func (r *memRepo) ListPermissions(_ context.Context, _ string, _ string, _ int, _ int) ([]auth.Permission, int64, error) {
	return nil, 0, nil
}
func (r *memRepo) ListPermissionsByRoleID(_ context.Context, roleID []byte) ([]auth.Permission, error) {
	return r.permsByRole[string(roleID)], r.permsErr
}
func (r *memRepo) HasRolePermission(_ context.Context, _ []byte, _ string) (bool, error) {
	return true, nil
}
func (r *memRepo) GetPermissionByID(_ context.Context, _ []byte) (*auth.Permission, error) {
	return nil, nil
}
func (r *memRepo) GetPermissionByKey(_ context.Context, _ string) (*auth.Permission, error) {
	return nil, nil
}
func (r *memRepo) UpdatePermission(_ context.Context, _ *auth.Permission) error { return nil }
func (r *memRepo) AssignPermission(_ context.Context, roleID, permissionID []byte) (*auth.RolePermission, error) {
	return &auth.RolePermission{RoleID: roleID, PermissionID: permissionID}, nil
}
func (r *memRepo) RemovePermission(_ context.Context, _ []byte, _ []byte) error { return nil }

const (
	testWebAuthnCreationResponseJSON = `{
	"id":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
	"rawId":"6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g",
	"type":"public-key",
	"authenticatorAttachment":"platform",
	"clientExtensionResults":{"appid":true},
	"response":{
		"attestationObject":"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQOsa7QYSUFukFOLTmgeK6x2ktirNMgwy_6vIwwtegxI2flS1X-JAkZL5dsadg-9bEz2J7PnsbB0B08txvsyUSvKlAQIDJiABIVggLKF5xS0_BntttUIrm2Z2tgZ4uQDwllbdIfrrBMABCNciWCDHwin8Zdkr56iSIh0MrB5qZiEzYLQpEOREhMUkY6q4Vw",
		"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJXOEd6RlU4cEdqaG9SYldyTERsYW1BZnFfeTRTMUNaRzFWdW9lUkxBUnJFIiwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdXRobi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ",
		"transports":["usb","nfc","fake"]
	}
}`
	testWebAuthnAssertionResponseTemplate = `{
	"id":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng",
	"rawId":"AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng",
	"clientExtensionResults":{"appID":"example.com"},
	"type":"public-key",
	"response":{
		"authenticatorData":"dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFXJJiGa3OAAI1vMYKZIsLJfHwVQMANwCOw-atj9C0vhWpfWU-whzNjeQS21Lpxfdk_G-omAtffWztpGoErlNOfuXWRqm9Uj9ANJck1p6lAQIDJiABIVggKAhfsdHcBIc0KPgAcRyAIK_-Vi-nCXHkRHPNaCMBZ-4iWCBxB8fGYQSBONi9uvq0gv95dGWlhJrBwCsj_a4LJQKVHQ",
		"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJFNFBUY0lIX0hmWDFwQzZTaWdrMVNDOU5BbGdlenROMDQzOXZpOHpfYzlrIiwibmV3X2tleXNfbWF5X2JlX2FkZGVkX2hlcmUiOiJkbyBub3QgY29tcGFyZSBjbGllbnREYXRhSlNPTiBhZ2FpbnN0IGEgdGVtcGxhdGUuIFNlZSBodHRwczovL2dvby5nbC95YWJQZXgiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9",
		"signature":"MEUCIBtIVOQxzFYdyWQyxaLR0tik1TnuPhGVhXVSNgFwLmN5AiEAnxXdCq0UeAVGWxOaFcjBZ_mEZoXqNboY5IkQDdlWZYc",
		"userHandle":"%s"
	}
}`
	testWebAuthnRegistrationChallenge = "W8GzFU8pGjhoRbWrLDlamAfq_y4S1CZG1VuoeRLARrE"
	testWebAuthnLoginChallenge        = "E4PTcIH_HfX1pC6Sigk1SC9NAlgeztN0439vi8z_c9k"
)

type testWebAuthnChallengeEnvelope struct {
	Ceremony string                  `json:"ceremony"`
	UserID   []byte                  `json:"user_id"`
	Session  webauthnlib.SessionData `json:"session"`
}

func newWebAuthnAuthService(repo *memRepo, store *memTokenStore, withJWT bool) *service.AuthService {
	cfg := config.AuthConfig{
		WebAuthnRPID:          "webauthn.io",
		WebAuthnRPDisplayName: "Wucher Test",
		WebAuthnRPOrigins:     []string{"https://webauthn.io"},
		WebAuthnChallengeTTL:  2 * time.Minute,
	}
	if withJWT {
		cfg.JWTAccessSecret = "access"
		cfg.JWTRefreshSecret = "refresh"
		cfg.JWTAccessTTL = time.Minute
		cfg.JWTRefreshTTL = time.Hour
	}
	return service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
}

func seedWebAuthnUser(repo *memRepo, email string, verified bool) []byte {
	userID := uuidv7.MustBytes()
	row := &auth.User{
		ID:       userID,
		Email:    email,
		RoleID:   uuidv7.MustBytes(),
		IsActive: true,
	}
	if verified {
		now := time.Now().UTC()
		row.EmailVerifiedAt = &now
	}
	repo.users[string(userID)] = row
	repo.usersByMail[email] = row
	return userID
}

func putWebAuthnChallenge(t *testing.T, store *memTokenStore, token, ceremony string, userID []byte, session webauthnlib.SessionData) {
	t.Helper()
	envelope := testWebAuthnChallengeEnvelope{
		Ceremony: ceremony,
		UserID:   append([]byte(nil), userID...),
		Session:  session,
	}
	raw, err := json.Marshal(envelope)
	if err != nil {
		t.Fatalf("marshal challenge envelope: %v", err)
	}
	store.store["auth:webauthn:challenge:"+token] = raw
}

func seedWebAuthnLoginCredential(t *testing.T, repo *memRepo, userID []byte) {
	t.Helper()
	credentialID, err := base64.RawURLEncoding.DecodeString("AI7D5q2P0LS-Fal9ZT7CHM2N5BLbUunF92T8b6iYC199bO2kagSuU05-5dZGqb1SP0A0lyTWng")
	if err != nil {
		t.Fatalf("decode credential id: %v", err)
	}
	publicKey, err := base64.RawURLEncoding.DecodeString("pQMmIAEhWCAoCF-x0dwEhzQo-ABxHIAgr_5WL6cJceREc81oIwFn7iJYIHEHx8ZhBIE42L26-rSC_3l0ZaWEmsHAKyP9rgslApUdAQI")
	if err != nil {
		t.Fatalf("decode public key: %v", err)
	}
	payload, err := json.Marshal(webauthnlib.Credential{
		ID:              credentialID,
		PublicKey:       publicKey,
		AttestationType: "none",
		Authenticator: webauthnlib.Authenticator{
			SignCount: 1553097240,
		},
	})
	if err != nil {
		t.Fatalf("marshal credential: %v", err)
	}
	repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
		UserID:         userID,
		CredentialID:   credentialID,
		CredentialJSON: payload,
	}}
}

func seedWebAuthnRegistrationExistingCredential(t *testing.T, repo *memRepo, userID []byte) {
	t.Helper()
	credentialID, err := base64.RawURLEncoding.DecodeString("6xrtBhJQW6QU4tOaB4rrHaS2Ks0yDDL_q8jDC16DEjZ-VLVf4kCRkvl2xp2D71sTPYns-exsHQHTy3G-zJRK8g")
	if err != nil {
		t.Fatalf("decode credential id: %v", err)
	}
	payload, err := json.Marshal(webauthnlib.Credential{ID: credentialID})
	if err != nil {
		t.Fatalf("marshal credential: %v", err)
	}
	repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
		UserID:         userID,
		CredentialID:   credentialID,
		CredentialJSON: payload,
	}}
}

func webAuthnAssertionPayloadForUser(userID []byte) []byte {
	handle := base64.RawURLEncoding.EncodeToString(userID)
	return []byte(fmt.Sprintf(testWebAuthnAssertionResponseTemplate, handle))
}

func TestAuthHandler_Login_TOTPChallenge(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:   "pepper",
		HashTime:         1,
		HashMemoryKB:     64 * 1024,
		HashThreads:      4,
		HashKeyLen:       32,
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
		TOTPChallengeTTL: time.Minute,
		TOTPIssuer:       "Wucher",
		DefaultRoleName:  "admin",
	}

	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})

	user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
	if err != nil {
		t.Fatalf("register failed: %v", err)
	}
	if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
		t.Fatalf("mark email verified: %v", err)
	}

	// enable TOTP
	secret, _, _, _ := svc.SetupTOTP(context.Background(), user.ID, user.Email)
	code, _ := totp.GenerateCode(secret, time.Now().UTC())
	_, _ = svc.ConfirmTOTP(context.Background(), user.ID, code)

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/login", h.Login)

	payload := []byte(`{"data":{"type":"auth_login","attributes":{"email":"user@example.com","password":"Password123!"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusAccepted {
		t.Fatalf("expected 202, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !bytes.Contains(body, []byte(`"totp_enabled":true`)) {
		t.Fatalf("expected totp_enabled true in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"webauthn_configured":false`)) {
		t.Fatalf("expected webauthn_configured false in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"linked_sso":false`)) {
		t.Fatalf("expected linked_sso false in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"pin_configured":false`)) {
		t.Fatalf("expected pin_configured false in response, got %s", string(body))
	}
}

func TestAuthHandler_Register_Disabled(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	cfg := config.AuthConfig{
		DisableRegister: true,
		DefaultRoleName: "admin",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/register", h.Register)

	payload := []byte(`{\"data\":{\"type\":\"auth_register\",\"attributes\":{\"email\":\"user@example.com\",\"username\":\"john.doe\",\"password\":\"Password123!\",\"first_name\":\"John\",\"last_name\":\"Doe\"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("expected 403, got %d", resp.StatusCode)
	}
}

func TestPermissionAttrs_WithPermissions(t *testing.T) {
	repo := newMemRepo()
	userID := uuidv7.MustBytes()
	roleID := uuidv7.MustBytes()
	repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID, RoleIDs: [][]byte{roleID}}
	repo.permsByRole[string(roleID)] = []auth.Permission{
		{ID: uuidv7.MustBytes(), Name: "Read User", Key: "user.read", Description: "Read users"},
	}

	svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
	app := fiber.New()
	var got []map[string]any
	app.Get("/t", func(c *fiber.Ctx) error {
		got = permissionAttrs(svc, c, userID)
		return c.SendStatus(http.StatusOK)
	})

	req, _ := http.NewRequest(http.MethodGet, "/t", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if len(got) != 1 || got[0]["key"] != "user.read" {
		t.Fatalf("unexpected permissions: %#v", got)
	}
}

func TestPermissionModuleGroups_WithPermissions(t *testing.T) {
	repo := newMemRepo()
	userID := uuidv7.MustBytes()
	roleID := uuidv7.MustBytes()
	repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID, RoleIDs: [][]byte{roleID}}
	repo.permsByRole[string(roleID)] = []auth.Permission{
		{ID: uuidv7.MustBytes(), Name: "Read User", Key: "user.read", Description: "Read users"},
		{ID: uuidv7.MustBytes(), Name: "Read Role", Key: "role.read", Description: "Read roles"},
	}

	svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
	app := fiber.New()
	var got []dto.PermissionGroupResource
	app.Get("/t", func(c *fiber.Ctx) error {
		got = permissionModuleGroups(svc, c, userID)
		return c.SendStatus(http.StatusOK)
	})

	req, _ := http.NewRequest(http.MethodGet, "/t", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if len(got) != 2 || got[0].Module != "user" || got[1].Module != "role" {
		t.Fatalf("unexpected grouped permissions: %#v", got)
	}
	if len(got[0].Permissions) != 1 || got[0].Permissions[0].Attributes.Key != "user.read" {
		t.Fatalf("unexpected user module permissions: %#v", got[0])
	}
}

func TestPermissionAttrs_OnError(t *testing.T) {
	repo := newMemRepo()
	repo.permsErr = errors.New("db error")
	userID := uuidv7.MustBytes()
	roleID := uuidv7.MustBytes()
	repo.users[string(userID)] = &auth.User{ID: userID, RoleID: roleID, RoleIDs: [][]byte{roleID}}

	svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
	app := fiber.New()
	var got []map[string]any
	app.Get("/t", func(c *fiber.Ctx) error {
		got = permissionAttrs(svc, c, userID)
		return c.SendStatus(http.StatusOK)
	})

	req, _ := http.NewRequest(http.MethodGet, "/t", nil)
	_, _ = app.Test(req)
	if len(got) != 0 {
		t.Fatalf("expected empty permissions on error, got %#v", got)
	}
}

func TestAuthHandler_Register_Success(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:   "pepper",
		HashTime:         1,
		HashMemoryKB:     64 * 1024,
		HashThreads:      4,
		HashKeyLen:       32,
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
		DefaultRoleName:  "admin",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/register", h.Register)

	payload := []byte(`{"data":{"type":"auth_register","attributes":{"email":"user@example.com","username":"john.doe","password":"Password123!","first_name":"John","last_name":"Doe"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("expected 201, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !bytes.Contains(body, []byte(`"username":"john.doe"`)) {
		t.Fatalf("expected response to include username, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"role_id":"`)) {
		t.Fatalf("expected response to include role_id, got %s", string(body))
	}
	if len(resp.Header.Values("Set-Cookie")) != 0 {
		t.Fatalf("expected register to not issue auth cookies")
	}
}

func TestAuthHandler_ForgotPassword_SameResponseExistingAndMissingEmail(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	cfg := config.AuthConfig{
		PasswordPepper:              "pepper",
		HashTime:                    1,
		HashMemoryKB:                64 * 1024,
		HashThreads:                 4,
		HashKeyLen:                  32,
		PasswordResetURL:            "http://localhost:3000/reset-password",
		PasswordResetTTL:            20 * time.Minute,
		ForgotPasswordEmailCooldown: 0,
		ForgotPasswordMinDuration:   0,
		ForgotPasswordIPRateMax:     10,
		ForgotPasswordIPRateWindow:  time.Minute,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
	if err := repo.CreateUser(context.Background(), &auth.User{
		ID:        uuidv7.MustBytes(),
		Email:     "exists@example.com",
		FirstName: "John",
		LastName:  "Doe",
		RoleID:    uuidv7.MustBytes(),
	}); err != nil {
		t.Fatalf("create user: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/forgot-password", h.ForgotPassword)

	existingPayload := []byte(`{"data":{"type":"auth_forgot_password","attributes":{"email":"exists@example.com"}}}`)
	missingPayload := []byte(`{"data":{"type":"auth_forgot_password","attributes":{"email":"missing@example.com"}}}`)

	req1, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/forgot-password", bytes.NewReader(existingPayload))
	req1.Header.Set("Content-Type", "application/json")
	resp1, err := app.Test(req1)
	if err != nil {
		t.Fatalf("existing app.Test: %v", err)
	}
	body1, _ := io.ReadAll(resp1.Body)

	req2, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/forgot-password", bytes.NewReader(missingPayload))
	req2.Header.Set("Content-Type", "application/json")
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("missing app.Test: %v", err)
	}
	body2, _ := io.ReadAll(resp2.Body)

	if resp1.StatusCode != http.StatusOK || resp2.StatusCode != http.StatusOK {
		t.Fatalf("expected both 200, got %d and %d", resp1.StatusCode, resp2.StatusCode)
	}
	if string(body1) != string(body2) {
		t.Fatalf("forgot-password responses should match, got existing=%s missing=%s", string(body1), string(body2))
	}
}

func TestAuthHandler_ForgotPassword_InvalidJSON(t *testing.T) {
	repo := newMemRepo()
	svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/forgot-password", h.ForgotPassword)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/forgot-password", bytes.NewReader([]byte(`{"data":`)))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_ForgotPassword_ValidationError(t *testing.T) {
	repo := newMemRepo()
	svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/forgot-password", h.ForgotPassword)

	payload := []byte(`{"data":{"type":"auth_forgot_password","attributes":{"email":"invalid-email"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/forgot-password", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_ResetPassword_InvalidTokenGenericMessage(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	cfg := config.AuthConfig{
		PasswordPepper:   "pepper",
		HashTime:         1,
		HashMemoryKB:     64 * 1024,
		HashThreads:      4,
		HashKeyLen:       32,
		PasswordResetTTL: 20 * time.Minute,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/reset-password", h.ResetPassword)

	payload := []byte(`{"data":{"type":"auth_reset_password","attributes":{"token":"missing-token","new_password":"Password123!Long","confirm_password":"Password123!Long"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !bytes.Contains(body, []byte("This reset link is invalid or expired.")) {
		t.Fatalf("expected generic invalid token message, got %s", string(body))
	}
}

func TestAuthHandler_ResetPassword_InvalidJSON(t *testing.T) {
	repo := newMemRepo()
	cfg := config.AuthConfig{
		PasswordPepper: "pepper",
		HashTime:       1,
		HashMemoryKB:   64 * 1024,
		HashThreads:    4,
		HashKeyLen:     32,
	}
	svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/reset-password", h.ResetPassword)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader([]byte(`{"data":`)))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_ResetPassword_ValidationError(t *testing.T) {
	repo := newMemRepo()
	cfg := config.AuthConfig{
		PasswordPepper: "pepper",
		HashTime:       1,
		HashMemoryKB:   64 * 1024,
		HashThreads:    4,
		HashKeyLen:     32,
	}
	svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/reset-password", h.ResetPassword)

	payload := []byte(`{"data":{"type":"auth_reset_password","attributes":{"token":"","new_password":"short","confirm_password":"short"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_ResetPassword_ConfirmMismatch(t *testing.T) {
	repo := newMemRepo()
	cfg := config.AuthConfig{
		PasswordPepper: "pepper",
		HashTime:       1,
		HashMemoryKB:   64 * 1024,
		HashThreads:    4,
		HashKeyLen:     32,
	}
	svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/reset-password", h.ResetPassword)

	payload := []byte(`{"data":{"type":"auth_reset_password","attributes":{"token":"token-x","new_password":"Password123!Long","confirm_password":"Different123!Long"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !bytes.Contains(body, []byte("confirm_password must match new_password")) {
		t.Fatalf("expected confirm mismatch message, got %s", string(body))
	}
}

func TestAuthHandler_ResetPassword_InternalError(t *testing.T) {
	repo := newMemRepo()
	repo.consumeResetErr = errors.New("db unavailable")
	cfg := config.AuthConfig{
		PasswordPepper: "pepper",
		HashTime:       1,
		HashMemoryKB:   64 * 1024,
		HashThreads:    4,
		HashKeyLen:     32,
	}
	svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/reset-password", h.ResetPassword)

	payload := []byte(`{"data":{"type":"auth_reset_password","attributes":{"token":"token-x","new_password":"Password123!Long","confirm_password":"Password123!Long"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusInternalServerError {
		t.Fatalf("expected 500, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_ResetPassword_Success(t *testing.T) {
	repo := newMemRepo()
	cfg := config.AuthConfig{
		PasswordPepper: "pepper",
		HashTime:       1,
		HashMemoryKB:   64 * 1024,
		HashThreads:    4,
		HashKeyLen:     32,
	}
	svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	user := &auth.User{
		ID:    uuidv7.MustBytes(),
		Email: "user@example.com",
	}
	if err := repo.CreateUser(context.Background(), user); err != nil {
		t.Fatalf("create user: %v", err)
	}
	rawToken := "valid-reset-token"
	tokenHash := sha256.Sum256([]byte(rawToken))
	repo.resetTokens[string(tokenHash[:])] = &auth.PasswordResetToken{
		ID:        uuidv7.MustBytes(),
		UserID:    user.ID,
		TokenHash: tokenHash[:],
		ExpiresAt: time.Now().UTC().Add(20 * time.Minute),
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/reset-password", h.ResetPassword)

	payload := []byte(`{"data":{"type":"auth_reset_password","attributes":{"token":"valid-reset-token","new_password":"Password123!Long","confirm_password":"Password123!Long"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/reset-password", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if len(user.PasswordHash) == 0 || len(user.SaltValue) == 0 {
		t.Fatalf("expected password hash and salt to be updated")
	}
}

func TestAuthHandler_VerifyEmail(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:  "pepper",
		HashTime:        1,
		HashMemoryKB:    64 * 1024,
		HashThreads:     4,
		HashKeyLen:      32,
		EmailVerifyTTL:  time.Hour,
		EmailVerifyURL:  "http://localhost/verify",
		DefaultRoleName: "admin",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
	user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)

	// token stored in mock
	key := store.lastKey[len("auth:email_verify:"):]

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/verify-email", h.VerifyEmail)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token="+key, nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if user.EmailVerifiedAt == nil {
		t.Fatalf("expected email verified")
	}
}

func TestAuthHandler_Login_Success(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:   "pepper",
		HashTime:         1,
		HashMemoryKB:     64 * 1024,
		HashThreads:      4,
		HashKeyLen:       32,
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
		DefaultRoleName:  "admin",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
	user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
	if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
		t.Fatalf("mark email verified: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/login", h.Login)

	payload := []byte(`{"data":{"type":"auth_login","attributes":{"email":"user@example.com","password":"Password123!"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusAccepted {
		t.Fatalf("expected 202, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !bytes.Contains(body, []byte(`"requires_email_otp":true`)) {
		t.Fatalf("expected requires_email_otp true in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"requires_totp_setup":true`)) {
		t.Fatalf("expected requires_totp_setup true in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"resend_cooldown_seconds"`)) {
		t.Fatalf("expected resend_cooldown_seconds in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"max_resend_per_hour"`)) {
		t.Fatalf("expected max_resend_per_hour in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"challenge_token"`)) {
		t.Fatalf("expected challenge_token in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"webauthn_configured":false`)) {
		t.Fatalf("expected webauthn_configured false in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"linked_sso":false`)) {
		t.Fatalf("expected linked_sso false in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"pin_configured":false`)) {
		t.Fatalf("expected pin_configured false in response, got %s", string(body))
	}

	_ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{
		ID:              uuidv7.MustBytes(),
		UserID:          user.ID,
		Provider:        "microsoft",
		ProviderSubject: "sub",
		Email:           user.Email,
	})
	resp2, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp2.StatusCode != http.StatusAccepted {
		t.Fatalf("expected 202, got %d", resp2.StatusCode)
	}
	body2, _ := io.ReadAll(resp2.Body)
	if !bytes.Contains(body2, []byte(`"linked_sso":true`)) {
		t.Fatalf("expected linked_sso true in response, got %s", string(body2))
	}
}

func TestAuthHandler_Refresh_And_Logout(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:   "pepper",
		HashTime:         1,
		HashMemoryKB:     64 * 1024,
		HashThreads:      4,
		HashKeyLen:       32,
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
		DefaultRoleName:  "admin",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
	user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
	if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
		t.Fatalf("mark email verified: %v", err)
	}
	pair, _ := svc.IssueTokens(context.Background(), user)

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/refresh", h.Refresh)
	app.Post("/api/v1/auth/logout", h.Logout)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/refresh", nil)
	req.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pair.RefreshToken})
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	req2, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
	req2.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pair.RefreshToken})
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp2.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp2.StatusCode)
	}
	if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, service.ErrInvalidToken) {
		t.Fatalf("expected refresh token invalid after logout, got %v", err)
	}
	if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, service.ErrInvalidToken) {
		t.Fatalf("expected access token invalid after logout, got %v", err)
	}
	sessions, err := svc.ListUserSessions(context.Background(), user.ID, "")
	if err != nil {
		t.Fatalf("list sessions after logout: %v", err)
	}
	if len(sessions) != 0 {
		t.Fatalf("expected session removed after logout, got %#v", sessions)
	}
}

func TestAuthHandler_MicrosoftFrontChannelLogout(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:   "pepper",
		HashTime:         1,
		HashMemoryKB:     64 * 1024,
		HashThreads:      4,
		HashKeyLen:       32,
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
		DefaultRoleName:  "admin",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
	user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
	if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
		t.Fatalf("mark email verified: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/front-channel-logout", h.MicrosoftFrontChannelLogout)

	// A request carrying a foreign issuer must NOT revoke the session.
	pairForeign, _ := svc.IssueTokens(context.Background(), user)
	reqForeign, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/front-channel-logout?iss=https://evil.example.com", nil)
	reqForeign.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: pairForeign.AccessToken})
	reqForeign.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pairForeign.RefreshToken})
	respForeign, err := app.Test(reqForeign)
	if err != nil {
		t.Fatalf("app.Test (foreign iss): %v", err)
	}
	if respForeign.StatusCode != http.StatusOK {
		t.Fatalf("expected 200 for foreign iss, got %d", respForeign.StatusCode)
	}
	if _, err := svc.RefreshTokens(context.Background(), pairForeign.RefreshToken); err != nil {
		t.Fatalf("expected session to survive foreign issuer logout, got %v", err)
	}

	// A legitimate front-channel logout revokes the caller's session and tokens.
	pair, _ := svc.IssueTokens(context.Background(), user)
	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/front-channel-logout?iss=https://login.microsoftonline.com/tenant/v2.0&sid=abc", nil)
	req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: pair.AccessToken})
	req.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pair.RefreshToken})
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "no-store") {
		t.Fatalf("expected no-store cache header, got %q", cc)
	}
	if got := resp.Header.Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors") {
		t.Fatalf("expected frame-ancestors CSP, got %q", got)
	}
	if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, service.ErrInvalidToken) {
		t.Fatalf("expected refresh token invalid after front-channel logout, got %v", err)
	}
	if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, service.ErrInvalidToken) {
		t.Fatalf("expected access token invalid after front-channel logout, got %v", err)
	}
	sessions, err := svc.ListUserSessions(context.Background(), user.ID, "")
	if err != nil {
		t.Fatalf("list sessions after logout: %v", err)
	}
	if len(sessions) != 0 {
		t.Fatalf("expected sessions revoked after front-channel logout, got %#v", sessions)
	}
}

func TestAuthHandler_MicrosoftFrontChannelLogout_UsesSidWithoutCookies(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:   "pepper",
		HashTime:         1,
		HashMemoryKB:     64 * 1024,
		HashThreads:      4,
		HashKeyLen:       32,
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
		DefaultRoleName:  "admin",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
	user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
	if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
		t.Fatalf("mark email verified: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/front-channel-logout", h.MicrosoftFrontChannelLogout)

	const microsoftSID = "ms-session-abc"
	pair, err := svc.IssueTokensWithClientForProviderAndMicrosoftSID(context.Background(), user, "", "", "microsoft", microsoftSID)
	if err != nil {
		t.Fatalf("issue microsoft tokens: %v", err)
	}

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/front-channel-logout?iss=https://login.microsoftonline.com/tenant/v2.0&sid="+microsoftSID, nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if _, err := svc.RefreshTokens(context.Background(), pair.RefreshToken); !errors.Is(err, service.ErrInvalidToken) {
		t.Fatalf("expected refresh token invalid after sid logout, got %v", err)
	}
	if _, err := svc.ParseAccessToken(context.Background(), pair.AccessToken); !errors.Is(err, service.ErrInvalidToken) {
		t.Fatalf("expected access token invalid after sid logout, got %v", err)
	}
}

func TestAuthHandler_SetAuthCookiesForMicrosoftUsesNoneSecure(t *testing.T) {
	svc := service.NewAuthService(newMemRepo(), newMemRepo(), newMemTokenStore(), nil, service.AuthServiceDependencies{
		SSO: nil,
		Protector: nil,
		Config: config.AuthConfig{
			JWTAccessSecret:   "access",
			JWTRefreshSecret:  "refresh",
			JWTAccessTTL:      time.Minute,
			JWTRefreshTTL:     time.Hour,
			JWTCookieSecure:   true,
			JWTCookieSameSite: "Lax",
		},
	})

	app := fiber.New()
	app.Get("/cookie", func(c *fiber.Ctx) error {
		setAuthCookiesForProvider(c, svc, service.TokenPair{
			AccessToken:  "a",
			RefreshToken: "r",
			RefreshTTL:   time.Hour,
		}, "microsoft")
		return c.SendStatus(http.StatusOK)
	})

	req, _ := http.NewRequest(http.MethodGet, "/cookie", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	cookies := resp.Header.Values("Set-Cookie")
	if len(cookies) == 0 {
		t.Fatalf("expected Set-Cookie headers")
	}
	for _, raw := range cookies {
		if !strings.Contains(raw, "SameSite=None") {
			t.Fatalf("expected SameSite=None for microsoft cookie, got %q", raw)
		}
		if !strings.Contains(strings.ToLower(raw), "secure") {
			t.Fatalf("expected Secure for microsoft cookie, got %q", raw)
		}
	}
}

func TestAuthHandler_SetAuthCookiesForMicrosoftFallsBackWhenInsecure(t *testing.T) {
	svc := service.NewAuthService(newMemRepo(), newMemRepo(), newMemTokenStore(), nil, service.AuthServiceDependencies{
		SSO: nil,
		Protector: nil,
		Config: config.AuthConfig{
			JWTAccessSecret:   "access",
			JWTRefreshSecret:  "refresh",
			JWTAccessTTL:      time.Minute,
			JWTRefreshTTL:     time.Hour,
			JWTCookieSecure:   false,
			JWTCookieSameSite: "Lax",
		},
	})

	app := fiber.New()
	app.Get("/cookie", func(c *fiber.Ctx) error {
		setAuthCookiesForProvider(c, svc, service.TokenPair{
			AccessToken:  "a",
			RefreshToken: "r",
			RefreshTTL:   time.Hour,
		}, "microsoft")
		return c.SendStatus(http.StatusOK)
	})

	req, _ := http.NewRequest(http.MethodGet, "/cookie", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	cookies := resp.Header.Values("Set-Cookie")
	if len(cookies) == 0 {
		t.Fatalf("expected Set-Cookie headers")
	}
	for _, raw := range cookies {
		if strings.Contains(raw, "SameSite=None") {
			t.Fatalf("expected fallback same-site for insecure microsoft cookie, got %q", raw)
		}
		if strings.Contains(strings.ToLower(raw), "secure") {
			t.Fatalf("expected insecure microsoft cookie fallback, got %q", raw)
		}
	}
}


func TestAuthHandler_Logout_DoesNotRedirectToSSO(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:        "pepper",
		HashTime:              1,
		HashMemoryKB:          64 * 1024,
		HashThreads:           4,
		HashKeyLen:            32,
		JWTAccessSecret:       "access",
		JWTRefreshSecret:      "refresh",
		JWTAccessTTL:          time.Minute,
		JWTRefreshTTL:         time.Hour,
		DefaultRoleName:       "admin",
		SSOSuccessRedirectURL: "http://localhost/logout-done",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{
		SSO:       &serviceMockSSO{},
		Protector: nil,
		Config:    cfg,
	})
	user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
	_ = repo.MarkEmailVerified(context.Background(), user.ID)
	pair, _ := svc.IssueTokens(context.Background(), user)

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/logout", h.Logout)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
	req.Header.Set("Accept", "text/html")
	req.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pair.RefreshToken})
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_Logout_DoesNotReturnSSOLogoutURL(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:        "pepper",
		HashTime:              1,
		HashMemoryKB:          64 * 1024,
		HashThreads:           4,
		HashKeyLen:            32,
		JWTAccessSecret:       "access",
		JWTRefreshSecret:      "refresh",
		JWTAccessTTL:          time.Minute,
		JWTRefreshTTL:         time.Hour,
		DefaultRoleName:       "admin",
		SSOSuccessRedirectURL: "http://localhost/logout-done",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{
		SSO:       &serviceMockSSO{},
		Protector: nil,
		Config:    cfg,
	})
	user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
	_ = repo.MarkEmailVerified(context.Background(), user.ID)
	pair, _ := svc.IssueTokens(context.Background(), user)

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/logout", h.Logout)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
	req.Header.Set("X-Requested-With", "XMLHttpRequest")
	req.AddCookie(&http.Cookie{Name: svc.RefreshCookieName(), Value: pair.RefreshToken})
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if bytes.Contains(body, []byte(`sso_logout_url`)) {
		t.Fatalf("did not expect sso logout url in body, got %s", string(body))
	}
}

func TestAuthHandler_Me(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByID[string(roleID)] = &auth.Role{ID: roleID, Name: "admin"}

	cfg := config.AuthConfig{
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
	ssoEmail := "u.sso@e.com"
	user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com", SSOEmail: &ssoEmail, RoleID: roleID}
	if err := repo.CreateUser(context.Background(), user); err != nil {
		t.Fatalf("create user: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/me", func(c *fiber.Ctx) error {
		c.Locals("user_id", user.ID)
		return h.Me(c)
	})

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !bytes.Contains(body, []byte(`"totp_enabled":false`)) {
		t.Fatalf("expected totp_enabled false in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"webauthn_configured":false`)) {
		t.Fatalf("expected webauthn_configured false in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"pin_configured":false`)) {
		t.Fatalf("expected pin_configured false in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"pin_blocked":false`)) {
		t.Fatalf("expected pin_blocked false in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"email_sso":"u.sso@e.com"`)) {
		t.Fatalf("expected email_sso in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"microsoft_scope":""`)) {
		t.Fatalf("expected microsoft_scope in response, got %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"dul_bases":[]`)) {
		t.Fatalf("expected dul_bases empty array in response, got %s", string(body))
	}
}

func TestAuthHandler_Me_PilotDULBases(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByID[string(roleID)] = &auth.Role{ID: roleID, Name: "pilot"}

	cfg := config.AuthConfig{
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
	user := &auth.User{ID: uuidv7.MustBytes(), Email: "pilot@e.com", RoleID: roleID}
	if err := repo.CreateUser(context.Background(), user); err != nil {
		t.Fatalf("create user: %v", err)
	}

	baseID := uuidv7.MustBytes()
	raw := strings.ToLower(hex.EncodeToString(baseID)) + "|" +
		strings.ToLower(hex.EncodeToString([]byte("Halim Base"))) + "|dul|" +
		strings.ToLower(hex.EncodeToString([]byte("hems")))
	contactSvc := &stubContactService{
		getItem: &contact.ContactListItem{
			UserID:       user.ID,
			RoleCode:     "pilot",
			BaseRolesRaw: &raw,
		},
	}

	h := NewAuthHandler(svc).WithContactService(contactSvc)
	app := fiber.New()
	app.Get("/api/v1/auth/me", func(c *fiber.Ctx) error {
		c.Locals("user_id", user.ID)
		return h.Me(c)
	})

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !bytes.Contains(body, []byte(`"dul_bases":[`)) ||
		!bytes.Contains(body, []byte(`"name":"Halim Base"`)) ||
		!bytes.Contains(body, []byte(`"category":"hems"`)) {
		t.Fatalf("expected pilot dul_bases payload, got %s", string(body))
	}
}

func TestAuthHandler_Me_WithSessionAuthContext(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	roleID := uuidv7.MustBytes()
	repo.rolesByID[string(roleID)] = &auth.Role{ID: roleID, Name: "admin"}

	cfg := config.AuthConfig{
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
	user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com", RoleID: roleID, IsActive: true}
	if err := repo.CreateUser(context.Background(), user); err != nil {
		t.Fatalf("create user: %v", err)
	}

	t.Run("email session", func(t *testing.T) {
		pair, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0", "127.0.0.1")
		if err != nil {
			t.Fatalf("issue tokens: %v", err)
		}
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Get("/api/v1/auth/me", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.Me(c)
		})
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
		req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: pair.AccessToken})
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		body, _ := io.ReadAll(resp.Body)
		if !bytes.Contains(body, []byte(`"login_method":"email"`)) {
			t.Fatalf("expected login_method email in response, got %s", string(body))
		}
		if !bytes.Contains(body, []byte(`"auth_provider":"email"`)) {
			t.Fatalf("expected auth_provider email in response, got %s", string(body))
		}
	})

	t.Run("microsoft session", func(t *testing.T) {
		pair, err := svc.IssueTokensWithClientForProvider(context.Background(), user, "Mozilla/5.0", "127.0.0.1", "microsoft")
		if err != nil {
			t.Fatalf("issue provider tokens: %v", err)
		}
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Get("/api/v1/auth/me", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.Me(c)
		})
		req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
		req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: pair.AccessToken})
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		body, _ := io.ReadAll(resp.Body)
		if !bytes.Contains(body, []byte(`"login_method":"sso"`)) {
			t.Fatalf("expected login_method sso in response, got %s", string(body))
		}
		if !bytes.Contains(body, []byte(`"auth_provider":"microsoft"`)) {
			t.Fatalf("expected auth_provider microsoft in response, got %s", string(body))
		}
	})
}

func TestAuthHandler_UpdateTimezone(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByID[string(roleID)] = &auth.Role{ID: roleID, Name: "admin"}

	cfg := config.AuthConfig{
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
	user := &auth.User{ID: uuidv7.MustBytes(), Email: "u@e.com", RoleID: roleID, Timezone: "UTC"}
	if err := repo.CreateUser(context.Background(), user); err != nil {
		t.Fatalf("create user: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Patch("/api/v1/auth/me/timezone", func(c *fiber.Ctx) error {
		c.Locals("user_id", user.ID)
		return h.UpdateTimezone(c)
	})

	t.Run("success", func(t *testing.T) {
		payload := []byte(`{"data":{"type":"auth_timezone_update","attributes":{"timezone":"Asia/Jakarta"}}}`)
		req, _ := http.NewRequest(http.MethodPatch, "/api/v1/auth/me/timezone", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
		if got := repo.users[string(user.ID)].Timezone; got != "Asia/Jakarta" {
			t.Fatalf("expected timezone updated, got %q", got)
		}
	})

	t.Run("invalid timezone", func(t *testing.T) {
		payload := []byte(`{"data":{"type":"auth_timezone_update","attributes":{"timezone":"UTC+7"}}}`)
		req, _ := http.NewRequest(http.MethodPatch, "/api/v1/auth/me/timezone", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, _ := app.Test(req)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("empty timezone", func(t *testing.T) {
		payload := []byte(`{"data":{"type":"auth_timezone_update","attributes":{"timezone":"   "}}}`)
		req, _ := http.NewRequest(http.MethodPatch, "/api/v1/auth/me/timezone", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, _ := app.Test(req)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid json", func(t *testing.T) {
		req, _ := http.NewRequest(http.MethodPatch, "/api/v1/auth/me/timezone", bytes.NewReader([]byte(`{\"data\":`)))
		req.Header.Set("Content-Type", "application/json")
		resp, _ := app.Test(req)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("unauthorized missing auth context", func(t *testing.T) {
		appNoAuth := fiber.New()
		appNoAuth.Patch("/api/v1/auth/me/timezone", h.UpdateTimezone)
		payload := []byte(`{"data":{"type":"auth_timezone_update","attributes":{"timezone":"Asia/Jakarta"}}}`)
		req, _ := http.NewRequest(http.MethodPatch, "/api/v1/auth/me/timezone", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, _ := appNoAuth.Test(req)
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_TOTPSetup_Confirm_Disable(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	cfg := config.AuthConfig{
		TOTPIssuer:       "Wucher",
		TOTPChallengeTTL: time.Minute,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	userID := uuidv7.MustBytes()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", append([]byte(nil), userID...))
		return c.Next()
	})
	app.Post("/api/v1/auth/totp/setup", h.TOTPSetup)
	app.Post("/api/v1/auth/totp/confirm", h.TOTPConfirm)
	app.Post("/api/v1/auth/totp/disable", h.TOTPDisable)
	if err := repo.CreateUser(context.Background(), &auth.User{
		ID:       userID,
		Email:    "user@example.com",
		RoleID:   uuidv7.MustBytes(),
		IsActive: true,
	}); err != nil {
		t.Fatalf("create user: %v", err)
	}

	payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_setup","attributes":{"user_id":"%s","email":"user@example.com"}}}`, func() string { s, _ := uuidv7.BytesToString(userID); return s }()))
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/setup", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	// confirm
	secretRec, _ := repo.GetUserTOTP(context.Background(), userID)
	secret, _ := protector.Decrypt(secretRec.SecretEncrypted)
	code, _ := totp.GenerateCode(string(secret), time.Now().UTC())

	payload2 := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_confirm","attributes":{"user_id":"%s","code":"%s"}}}`, func() string { s, _ := uuidv7.BytesToString(userID); return s }(), code))
	req2, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/confirm", bytes.NewReader(payload2))
	req2.Header.Set("Content-Type", "application/json")
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp2.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp2.StatusCode)
	}

	// disable
	payload3 := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_disable","attributes":{"user_id":"%s"}}}`, func() string { s, _ := uuidv7.BytesToString(userID); return s }()))
	req3, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/disable", bytes.NewReader(payload3))
	req3.Header.Set("Content-Type", "application/json")
	resp3, err := app.Test(req3)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp3.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp3.StatusCode)
	}
}

func TestAuthHandler_TOTPResetSetup_WithPassword(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:  "pepper",
		HashTime:        1,
		HashMemoryKB:    64 * 1024,
		HashThreads:     4,
		HashKeyLen:      32,
		DefaultRoleName: "admin",
		TOTPIssuer:      "Wucher",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
	user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
	if err != nil {
		t.Fatalf("register failed: %v", err)
	}
	if _, _, _, err := svc.SetupTOTP(context.Background(), user.ID, user.Email); err != nil {
		t.Fatalf("setup totp failed: %v", err)
	}
	if repo.totp[string(user.ID)] == nil {
		t.Fatalf("expected totp setup to exist")
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error {
		c.Locals("user_id", user.ID)
		return h.TOTPResetSetup(c)
	})

	payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if repo.totp[string(user.ID)] != nil {
		t.Fatalf("expected totp setup to be removed")
	}
}

func TestAuthHandler_TOTPResetSetup_Branches(t *testing.T) {
	newFixture := func(t *testing.T) (*memRepo, *service.AuthService, *auth.User) {
		t.Helper()

		repo := newMemRepo()
		store := newMemTokenStore()

		key := make([]byte, 32)
		for i := range key {
			key[i] = byte(i + 1)
		}
		protector, err := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
		if err != nil {
			t.Fatalf("protector: %v", err)
		}

		roleID := uuidv7.MustBytes()
		repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
		repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

		cfg := config.AuthConfig{
			PasswordPepper:            "pepper",
			HashTime:                  1,
			HashMemoryKB:              64 * 1024,
			HashThreads:               4,
			HashKeyLen:                32,
			DefaultRoleName:           "admin",
			TOTPIssuer:                "Wucher",
			SecurityPINMaxAttempts:    3,
			SecurityPINLockDuration:   time.Minute,
			SecurityPINActionTokenTTL: time.Minute,
		}
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
		user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
		if err != nil {
			t.Fatalf("register failed: %v", err)
		}
		if _, _, _, err := svc.SetupTOTP(context.Background(), user.ID, user.Email); err != nil {
			t.Fatalf("setup totp failed: %v", err)
		}
		return repo, svc, user
	}

	t.Run("missing auth context", func(t *testing.T) {
		_, svc, _ := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/totp/reset-setup", h.TOTPResetSetup)

		payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid json", func(t *testing.T) {
		_, svc, user := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.TOTPResetSetup(c)
		})

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader([]byte(`{"data":`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("validation error", func(t *testing.T) {
		_, svc, user := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.TOTPResetSetup(c)
		})

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader([]byte(`{}`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("password required", func(t *testing.T) {
		_, svc, user := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.TOTPResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"password"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("wrong password", func(t *testing.T) {
		_, svc, user := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.TOTPResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"password","password":"WrongPassword!"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("user not found", func(t *testing.T) {
		_, svc, _ := newFixture(t)
		h := NewAuthHandler(svc)
		missingUserID := uuidv7.MustBytes()
		app := fiber.New()
		app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", missingUserID)
			return h.TOTPResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("pin invalid token", func(t *testing.T) {
		_, svc, user := newFixture(t)
		if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil {
			t.Fatalf("set pin: %v", err)
		}

		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.TOTPResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"pin"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set(pinVerificationHeader, "bad-token")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("pin success", func(t *testing.T) {
		repo, svc, user := newFixture(t)
		if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil {
			t.Fatalf("set pin: %v", err)
		}
		actionToken, err := svc.VerifySecurityPINForAction(context.Background(), user.ID, "123456", sharedconst.PinActionTOTPResetSetup)
		if err != nil {
			t.Fatalf("verify pin: %v", err)
		}

		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.TOTPResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"pin"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set(pinVerificationHeader, actionToken)
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
		if repo.totp[string(user.ID)] != nil {
			t.Fatalf("expected totp setup removed after pin reset")
		}
	})

	t.Run("pin delete setup error", func(t *testing.T) {
		repo, svc, user := newFixture(t)
		repo.deleteTOTPErr = errors.New("delete failed")
		if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil {
			t.Fatalf("set pin: %v", err)
		}
		actionToken, err := svc.VerifySecurityPINForAction(context.Background(), user.ID, "123456", sharedconst.PinActionTOTPResetSetup)
		if err != nil {
			t.Fatalf("verify pin: %v", err)
		}

		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/totp/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.TOTPResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_totp_reset_setup","attributes":{"method":"pin"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set(pinVerificationHeader, actionToken)
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_WebAuthnResetSetup_WithPIN(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:  "pepper",
		HashTime:        1,
		HashMemoryKB:    64 * 1024,
		HashThreads:     4,
		HashKeyLen:      32,
		DefaultRoleName: "admin",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
	user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
	if err != nil {
		t.Fatalf("register failed: %v", err)
	}
	if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil {
		t.Fatalf("set pin failed: %v", err)
	}
	actionToken, err := svc.VerifySecurityPINForAction(context.Background(), user.ID, "123456", sharedconst.PinActionWebAuthnResetSetup)
	if err != nil {
		t.Fatalf("pin verify for action failed: %v", err)
	}
	seedWebAuthnLoginCredential(t, repo, user.ID)
	if len(repo.webauthnCredentials[string(user.ID)]) == 0 {
		t.Fatalf("expected webauthn credentials to exist")
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error {
		c.Locals("user_id", user.ID)
		return h.WebAuthnResetSetup(c)
	})

	payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"pin"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set(pinVerificationHeader, actionToken)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if len(repo.webauthnCredentials[string(user.ID)]) != 0 {
		t.Fatalf("expected webauthn credentials to be removed")
	}
}

func TestAuthHandler_WebAuthnResetSetup_Branches(t *testing.T) {
	newFixture := func(t *testing.T) (*memRepo, *service.AuthService, *auth.User) {
		t.Helper()

		repo := newMemRepo()
		store := newMemTokenStore()

		key := make([]byte, 32)
		for i := range key {
			key[i] = byte(i + 1)
		}
		protector, err := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
		if err != nil {
			t.Fatalf("protector: %v", err)
		}

		roleID := uuidv7.MustBytes()
		repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
		repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

		cfg := config.AuthConfig{
			PasswordPepper:            "pepper",
			HashTime:                  1,
			HashMemoryKB:              64 * 1024,
			HashThreads:               4,
			HashKeyLen:                32,
			DefaultRoleName:           "admin",
			SecurityPINMaxAttempts:    3,
			SecurityPINLockDuration:   time.Minute,
			SecurityPINActionTokenTTL: time.Minute,
		}
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
		user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
		if err != nil {
			t.Fatalf("register failed: %v", err)
		}
		seedWebAuthnLoginCredential(t, repo, user.ID)
		return repo, svc, user
	}

	t.Run("missing auth context", func(t *testing.T) {
		_, svc, _ := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/reset-setup", h.WebAuthnResetSetup)

		payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid json", func(t *testing.T) {
		_, svc, user := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.WebAuthnResetSetup(c)
		})

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader([]byte(`{"data":`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("validation error", func(t *testing.T) {
		_, svc, user := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.WebAuthnResetSetup(c)
		})

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader([]byte(`{}`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("password required", func(t *testing.T) {
		_, svc, user := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.WebAuthnResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"password"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("wrong password", func(t *testing.T) {
		_, svc, user := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.WebAuthnResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"password","password":"WrongPassword!"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("user not found", func(t *testing.T) {
		_, svc, _ := newFixture(t)
		h := NewAuthHandler(svc)
		missingUserID := uuidv7.MustBytes()
		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", missingUserID)
			return h.WebAuthnResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("pin invalid token", func(t *testing.T) {
		_, svc, user := newFixture(t)
		if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil {
			t.Fatalf("set pin: %v", err)
		}

		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.WebAuthnResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"pin"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set(pinVerificationHeader, "bad-token")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("pin delete setup error", func(t *testing.T) {
		repo, svc, user := newFixture(t)
		repo.deleteWebAuthnErr = errors.New("delete failed")
		if err := svc.SetSecurityPIN(context.Background(), user.ID, "123456"); err != nil {
			t.Fatalf("set pin: %v", err)
		}
		actionToken, err := svc.VerifySecurityPINForAction(context.Background(), user.ID, "123456", sharedconst.PinActionWebAuthnResetSetup)
		if err != nil {
			t.Fatalf("verify pin: %v", err)
		}

		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.WebAuthnResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"pin"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set(pinVerificationHeader, actionToken)
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("password success", func(t *testing.T) {
		repo, svc, user := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/reset-setup", func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return h.WebAuthnResetSetup(c)
		})

		payload := []byte(`{"data":{"type":"auth_webauthn_reset_setup","attributes":{"method":"password","password":"Password123!"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/reset-setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
		if len(repo.webauthnCredentials[string(user.ID)]) != 0 {
			t.Fatalf("expected webauthn credentials removed")
		}
	})
}

func TestAuthHandler_MicrosoftLogin_Callback(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"}

	cfg := config.AuthConfig{
		SSOStateTTL:           time.Minute,
		JWTAccessSecret:       "access",
		JWTRefreshSecret:      "refresh",
		JWTAccessTTL:          time.Minute,
		JWTRefreshTTL:         time.Hour,
		SSOSuccessRedirectURL: "http://localhost/cb",
	}
	sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
	userID := uuidv7.MustBytes()
	if err := repo.CreateUser(context.Background(), &auth.User{
		ID:       userID,
		Email:    "user@example.com",
		RoleID:   roleID,
		Timezone: "UTC",
	}); err != nil {
		t.Fatalf("create user: %v", err)
	}
	_ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{
		ID:              uuidv7.MustBytes(),
		UserID:          userID,
		Provider:        "microsoft",
		ProviderSubject: "sub",
		Email:           "user@example.com",
	})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin)
	app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !bytes.Contains(body, []byte(`"type":"auth_microsoft_url"`)) {
		t.Fatalf("expected JSON:API type auth_microsoft_url, got body: %s", string(body))
	}
	if !bytes.Contains(body, []byte(`"url":"http://example.com?`)) || !bytes.Contains(body, []byte(`state=`)) {
		t.Fatalf("expected oauth redirect url in response body, got body: %s", string(body))
	}

	// Extract state from token store
	state := store.lastKey[len("auth:sso_state:"):]
	req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil)
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp2.StatusCode != http.StatusFound {
		t.Fatalf("expected 302, got %d", resp2.StatusCode)
	}
}

func TestAuthHandler_Exchange(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	roleID := uuidv7.MustBytes()
	repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"}

	cfg := config.AuthConfig{
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{Config: cfg})

	userID := uuidv7.MustBytes()
	if err := repo.CreateUser(context.Background(), &auth.User{
		ID:       userID,
		Email:    "user@example.com",
		RoleID:   roleID,
		Timezone: "UTC",
	}); err != nil {
		t.Fatalf("create user: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/exchange", h.Exchange)

	t.Run("success", func(t *testing.T) {
		exp := time.Now().UTC().Add(5 * time.Minute).Unix()
		payload := fmt.Sprintf(`{"data":{"type":"auth_exchange","attributes":{"access_token":"at","id_token":"it","id_token_claims":{"sub":"sub-123","preferred_username":"user@example.com","name":"User Name","exp":%d,"tid":"tenant-1","oid":"obj-1"}}}}`, exp)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/exchange", strings.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			body, _ := io.ReadAll(resp.Body)
			t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(body))
		}
	})

	t.Run("expired id token claim", func(t *testing.T) {
		exp := time.Now().UTC().Add(-5 * time.Minute).Unix()
		payload := fmt.Sprintf(`{"data":{"type":"auth_exchange","attributes":{"access_token":"at","id_token":"it","id_token_claims":{"sub":"sub-123","preferred_username":"user@example.com","exp":%d}}}}`, exp)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/exchange", strings.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_MicrosoftLogin_CallbackXHR_NoRedirect(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"}

	cfg := config.AuthConfig{
		SSOStateTTL:           time.Minute,
		JWTAccessSecret:       "access",
		JWTRefreshSecret:      "refresh",
		JWTAccessTTL:          time.Minute,
		JWTRefreshTTL:         time.Hour,
		SSOSuccessRedirectURL: "http://localhost:3000/challenge/msentra",
	}
	sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})
	userID := uuidv7.MustBytes()
	if err := repo.CreateUser(context.Background(), &auth.User{
		ID:       userID,
		Email:    "user@example.com",
		RoleID:   roleID,
		Timezone: "UTC",
	}); err != nil {
		t.Fatalf("create user: %v", err)
	}
	_ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{
		ID:              uuidv7.MustBytes(),
		UserID:          userID,
		Provider:        "microsoft",
		ProviderSubject: "sub",
		Email:           "user@example.com",
	})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin)
	app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	state := store.lastKey[len("auth:sso_state:"):]
	req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil)
	req2.Header.Set("Accept", "application/json")
	req2.Header.Set("Sec-Fetch-Mode", "cors")
	req2.Header.Set("Sec-Fetch-Dest", "empty")
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp2.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp2.StatusCode)
	}
	body, _ := io.ReadAll(resp2.Body)
	if !bytes.Contains(body, []byte(`"type":"auth_identity"`)) {
		t.Fatalf("expected auth_identity response, got body: %s", string(body))
	}
}

func TestAuthHandler_MicrosoftLogin_UsesInteractivePromptByDefault(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	sso := &serviceMockSSO{authURL: "http://example.com?state=%s&prompt=select_account"}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{
		SSO:       sso,
		Protector: nil,
		Config:    config.AuthConfig{SSOStateTTL: time.Minute},
	})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	// /microsoft/login is now an interactive sign-in; silent prompt=none belongs to
	// /microsoft/login-check only.
	if !bytes.Contains(body, []byte(`prompt=select_account`)) {
		t.Fatalf("expected interactive prompt=select_account in response body, got: %s", string(body))
	}
	if bytes.Contains(body, []byte(`prompt=none`)) {
		t.Fatalf("expected interactive login, but response used silent prompt=none: %s", string(body))
	}
}

func TestAuthHandler_MicrosoftCallback_LoginCheck_NoMicrosoftSession(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane"}}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{
		SSO:       sso,
		Protector: nil,
		Config:    config.AuthConfig{SSOStateTTL: time.Minute},
	})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback)

	// The silent login_check flow is seeded by the session-check entrypoint (no longer by
	// /microsoft/login, which is now an interactive sign-in).
	if _, err := svc.BeginMicrosoftSessionCheck(context.Background()); err != nil {
		t.Fatalf("begin session check: %v", err)
	}

	state := store.lastKey[len("auth:sso_state:"):]
	req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?state="+state+"&error=login_required", nil)
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("app.Test callback: %v", err)
	}
	if resp2.StatusCode != http.StatusFound {
		t.Fatalf("expected 302, got %d", resp2.StatusCode)
	}
	location := resp2.Header.Get("Location")
	if !strings.Contains(location, "http://example.com?") || !strings.Contains(location, "state=") {
		t.Fatalf("expected redirect to interactive microsoft auth url, got: %s", location)
	}
	if !strings.Contains(location, "prompt=select_account") {
		t.Fatalf("expected interactive fallback with prompt=select_account, got: %s", location)
	}
	if _, ok := store.store["auth:sso_state:"+state]; ok {
		t.Fatalf("expected state to be cleared for failed login_check flow")
	}
}

func TestAuthHandler_MicrosoftCallback_LoginCheckStrict_NoMicrosoftSession(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane"}}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{
		SSO:       sso,
		Protector: nil,
		Config:    config.AuthConfig{SSOStateTTL: time.Minute},
	})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/login-check", h.MicrosoftLoginCheck)
	app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login-check", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	state := store.lastKey[len("auth:sso_state:"):]
	req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?state="+state+"&error=login_required", nil)
	req2.Header.Set("Accept", "application/json")
	req2.Header.Set("Sec-Fetch-Mode", "cors")
	req2.Header.Set("Sec-Fetch-Dest", "empty")
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("app.Test callback: %v", err)
	}
	if resp2.StatusCode != http.StatusUnauthorized {
		t.Fatalf("expected 401, got %d", resp2.StatusCode)
	}
}

func TestAuthHandler_MicrosoftCallback_LoginCheckStrict_ActiveSession(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane"}}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{
		SSO:       sso,
		Protector: nil,
		Config:    config.AuthConfig{SSOStateTTL: time.Minute},
	})

	roleID := uuidv7.MustBytes()
	repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"}
	userID := uuidv7.MustBytes()
	if err := repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: roleID, Timezone: "UTC"}); err != nil {
		t.Fatalf("create user: %v", err)
	}
	_ = repo.CreateIdentity(context.Background(), &auth.UserIdentity{
		ID:              uuidv7.MustBytes(),
		UserID:          userID,
		Provider:        "microsoft",
		ProviderSubject: "sub",
		Email:           "user@example.com",
	})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/login-check", h.MicrosoftLoginCheck)
	app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login-check", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	state := store.lastKey[len("auth:sso_state:"):]
	req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil)
	req2.Header.Set("Accept", "application/json")
	req2.Header.Set("Sec-Fetch-Mode", "cors")
	req2.Header.Set("Sec-Fetch-Dest", "empty")
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("app.Test callback: %v", err)
	}
	if resp2.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp2.StatusCode)
	}
	body, _ := io.ReadAll(resp2.Body)
	if !bytes.Contains(body, []byte(`"type":"auth_microsoft_session"`)) {
		t.Fatalf("expected auth_microsoft_session response, got body: %s", string(body))
	}
}

func TestAuthHandler_MicrosoftReauth_Callback(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"}

	cfg := config.AuthConfig{
		SSOStateTTL:           time.Minute,
		SSOSuccessRedirectURL: "http://localhost/cb",
	}
	sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})

	identity := &auth.UserIdentity{
		ID:              uuidv7.MustBytes(),
		UserID:          uuidv7.MustBytes(),
		Provider:        "microsoft",
		ProviderSubject: "sub",
		Email:           "user@example.com",
	}
	if err := repo.CreateUser(context.Background(), &auth.User{
		ID:       identity.UserID,
		Email:    "user@example.com",
		RoleID:   roleID,
		Timezone: "UTC",
	}); err != nil {
		t.Fatalf("create user: %v", err)
	}
	_ = repo.CreateIdentity(context.Background(), identity)

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", identity.UserID)
		return c.Next()
	})
	app.Get("/api/v1/auth/microsoft/reauth", h.MicrosoftReauth)
	app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/reauth?purpose=pin_reset_request", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	state := store.lastKey[len("auth:sso_state:"):]
	req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil)
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("callback app.Test: %v", err)
	}
	if resp2.StatusCode != http.StatusFound {
		t.Fatalf("expected 302, got %d", resp2.StatusCode)
	}
	location, err := url.Parse(resp2.Header.Get("Location"))
	if err != nil {
		t.Fatalf("parse location: %v", err)
	}
	if location.Query().Get("sso_reauth") != "1" {
		t.Fatalf("expected sso_reauth marker in redirect")
	}
	if location.Query().Get("purpose") != "pin_reset_request" {
		t.Fatalf("unexpected purpose in redirect: %s", location.Query().Get("purpose"))
	}
	if location.Query().Get("reauth_token") == "" {
		t.Fatalf("expected reauth_token in redirect")
	}
}

func TestAuthHandler_MicrosoftLink_Callback(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"}

	cfg := config.AuthConfig{
		SSOStateTTL:           time.Minute,
		SSOSuccessRedirectURL: "http://localhost/cb",
	}
	sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub-link", Email: "user@example.com", Name: "Jane Doe"}}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})

	userID := uuidv7.MustBytes()
	if err := repo.CreateUser(context.Background(), &auth.User{
		ID:       userID,
		Email:    "user@example.com",
		RoleID:   roleID,
		Timezone: "UTC",
	}); err != nil {
		t.Fatalf("create user: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", userID)
		return c.Next()
	})
	app.Get("/api/v1/auth/microsoft/link", h.MicrosoftLink)
	app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/link", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	state := store.lastKey[len("auth:sso_state:"):]
	req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil)
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("callback app.Test: %v", err)
	}
	if resp2.StatusCode != http.StatusFound {
		t.Fatalf("expected 302, got %d", resp2.StatusCode)
	}
	location, err := url.Parse(resp2.Header.Get("Location"))
	if err != nil {
		t.Fatalf("parse location: %v", err)
	}
	if location.Query().Get("sso_link") != "1" {
		t.Fatalf("expected sso_link marker in redirect")
	}
	if location.Query().Get("provider") != "microsoft" {
		t.Fatalf("unexpected provider in redirect: %s", location.Query().Get("provider"))
	}
}

type serviceMockSSO struct {
	claims  service.MicrosoftClaims
	authURL string
}

func (m *serviceMockSSO) AuthCodeURL(state string) string {
	if strings.TrimSpace(m.authURL) != "" {
		return fmt.Sprintf(m.authURL, state)
	}
	return "http://example.com?state=" + state
}
func (m *serviceMockSSO) LogoutURL(postLogoutRedirectURL string) string {
	if strings.TrimSpace(postLogoutRedirectURL) == "" {
		return "http://example.com/logout"
	}
	return "http://example.com/logout?post_logout_redirect_uri=" + url.QueryEscape(postLogoutRedirectURL)
}
func (m *serviceMockSSO) ExchangeCode(ctx context.Context, code string) (service.MicrosoftClaims, error) {
	return m.claims, nil
}

func TestAuthHandler_Register_InvalidJSON(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/register", h.Register)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader([]byte("{bad")))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_Register_RoleNotFound(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	cfg := config.AuthConfig{
		PasswordPepper:  "pepper",
		HashTime:        1,
		HashMemoryKB:    64 * 1024,
		HashThreads:     4,
		HashKeyLen:      32,
		DefaultRoleName: "admin",
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/register", h.Register)

	payload := []byte(`{"data":{"type":"auth_register","attributes":{"email":"user@example.com","username":"john.doe","password":"Password123!","first_name":"John","last_name":"Doe"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_VerifyEmail_MissingToken(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/verify-email", h.VerifyEmail)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/verify-email", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_Login_InvalidJSON(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/login", h.Login)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte("{bad")))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_MicrosoftLogin_NotConfigured(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_MicrosoftCallback_Invalid(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_MicrosoftCallback_InvalidState(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{SSOStateTTL: time.Minute}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state=missing", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_MicrosoftCallback_SSONotLinked(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{SSOStateTTL: time.Minute}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin)
	app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil)
	_, _ = app.Test(req)
	state := store.lastKey[len("auth:sso_state:"):]

	req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil)
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp2.StatusCode != http.StatusUnauthorized {
		t.Fatalf("expected 401, got %d", resp2.StatusCode)
	}
	body, _ := io.ReadAll(resp2.Body)
	if !bytes.Contains(body, []byte(`"title":"SSO not linked"`)) {
		t.Fatalf("expected sso not linked response, got %s", string(body))
	}
}

func TestAuthHandler_MicrosoftCallback_SSONotLinked_BrowserRedirectsToFrontendError(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	cfg := config.AuthConfig{
		SSOStateTTL:           time.Minute,
		SSOSuccessRedirectURL: "http://localhost:3000/en/challenge/msentra",
	}
	sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Get("/api/v1/auth/microsoft/login", h.MicrosoftLogin)
	app.Get("/api/v1/auth/microsoft/callback", h.MicrosoftCallback)

	req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/login", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test login: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	state := store.lastKey[len("auth:sso_state:"):]
	req2, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/callback?code=code&state="+state, nil)
	req2.Header.Set("Accept", "text/html")
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("app.Test callback: %v", err)
	}
	if resp2.StatusCode != http.StatusFound {
		t.Fatalf("expected 302, got %d", resp2.StatusCode)
	}
	location := resp2.Header.Get("Location")
	if !strings.Contains(location, "http://localhost:3000/en/challenge/msentra?") {
		t.Fatalf("expected frontend redirect, got %q", location)
	}
	if !strings.Contains(location, "sso=error") || !strings.Contains(location, "reason=sso_not_linked") {
		t.Fatalf("expected error query params in redirect, got %q", location)
	}
}

func TestAuthHandler_SSOLinkAndUnlink(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	roleID := uuidv7.MustBytes()
	repo.rolesByName["user"] = &auth.Role{ID: roleID, Name: "user"}

	userID := uuidv7.MustBytes()
	if err := repo.CreateUser(context.Background(), &auth.User{
		ID:       userID,
		Email:    "user@example.com",
		RoleID:   roleID,
		Timezone: "UTC",
	}); err != nil {
		t.Fatalf("create user: %v", err)
	}

	sso := &serviceMockSSO{claims: service.MicrosoftClaims{Subject: "sub", Email: "user@example.com", Name: "Jane Doe"}}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: sso, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", userID)
		return c.Next()
	})
	app.Post("/api/v1/auth/sso/link", h.SSOLink)
	app.Delete("/api/v1/auth/sso/unlink", h.SSOUnlink)

	linkPayload := []byte(`{"data":{"type":"auth_sso_link","attributes":{"provider":"microsoft","code":"code"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/sso/link", bytes.NewReader(linkPayload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	unlinkPayload := []byte(`{"data":{"type":"auth_sso_unlink","attributes":{"provider":"microsoft"}}}`)
	req2, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sso/unlink", bytes.NewReader(unlinkPayload))
	req2.Header.Set("Content-Type", "application/json")
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp2.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp2.StatusCode)
	}
}

func TestAuthHandler_TOTPSetup_InvalidUserID(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	userID := uuidv7.MustBytes()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", append([]byte(nil), userID...))
		return c.Next()
	})
	app.Post("/api/v1/auth/totp/setup", h.TOTPSetup)

	payload := []byte(`{"data":{"type":"auth_totp_setup","attributes":{"user_id":"bad","email":"user@example.com"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/setup", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_TOTPSetup_InvalidJSON(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	userID := uuidv7.MustBytes()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", append([]byte(nil), userID...))
		return c.Next()
	})
	app.Post("/api/v1/auth/totp/setup", h.TOTPSetup)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/setup", bytes.NewReader([]byte("{bad")))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_Invite_InvalidJSON(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/invite", h.Invite)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader([]byte("{bad")))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_Invite_InvalidRoleID(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/invite", h.Invite)

	payload := []byte(`{"data":{"type":"auth_invite","attributes":{"email":"user@example.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"bad-uuid"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_Invite_InvalidRoleIDs(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/invite", h.Invite)

	roleID, _ := uuidv7.BytesToString(uuidv7.MustBytes())
	payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_invite","attributes":{"email":"user@example.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"%s","role_ids":["bad-uuid"]}}}`, roleID))
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_Invite_ServiceError(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	roleID := uuidv7.MustBytes()
	if err := repo.CreateUser(context.Background(), &auth.User{
		ID:        uuidv7.MustBytes(),
		Email:     "user@example.com",
		FirstName: "Jane",
		LastName:  "Doe",
		RoleID:    roleID,
	}); err != nil {
		t.Fatalf("create user: %v", err)
	}
	roleIDStr, _ := uuidv7.BytesToString(roleID)

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/invite", h.Invite)

	payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_invite","attributes":{"email":"user@example.com","username":"john.doe","first_name":"John","last_name":"Doe","role_id":"%s"}}}`, roleIDStr))
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_Invite_Success(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	cfg := config.AuthConfig{
		SetPasswordURL: "http://localhost/set-password",
		InviteTTL:      time.Hour,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	roleID := uuidv7.MustBytes()
	roleIDStr, _ := uuidv7.BytesToString(roleID)

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/invite", h.Invite)

	payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_invite","attributes":{"email":"new-user@example.com","username":"new.user","first_name":"John","last_name":"Doe","mobile_phone":"  +6281234  ","role_id":"%s"}}}`, roleIDStr))
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("expected 201, got %d", resp.StatusCode)
	}
	if store.lastKey == "" {
		t.Fatalf("expected invite token to be stored")
	}
	body, _ := io.ReadAll(resp.Body)
	if !bytes.Contains(body, []byte(`"username":"new.user"`)) {
		t.Fatalf("expected response to include username, got %s", string(body))
	}
}

func TestAuthHandler_Invite_SuccessWithRoleIDs(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	cfg := config.AuthConfig{
		SetPasswordURL: "http://localhost/set-password",
		InviteTTL:      time.Hour,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	roleID := uuidv7.MustBytes()
	extraRoleID := uuidv7.MustBytes()
	roleIDStr, _ := uuidv7.BytesToString(roleID)
	extraRoleIDStr, _ := uuidv7.BytesToString(extraRoleID)

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/invite", h.Invite)

	payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_invite","attributes":{"email":"multi-role@example.com","username":"multi.role","first_name":"John","last_name":"Doe","role_id":"%s","role_ids":["%s","%s"]}}}`, roleIDStr, roleIDStr, extraRoleIDStr))
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("expected 201, got %d", resp.StatusCode)
	}
	found := false
	for _, created := range repo.users {
		if created.Email != "multi-role@example.com" {
			continue
		}
		found = true
		if len(created.RoleIDs) != 2 {
			t.Fatalf("expected 2 role ids stored, got %d", len(created.RoleIDs))
		}
	}
	if !found {
		t.Fatalf("expected invited user stored in repo")
	}
}

func TestAuthHandler_SetPassword_InvalidJSON(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/set-password", h.SetPassword)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/set-password", bytes.NewReader([]byte("{bad")))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_SetPassword_ValidationError(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/set-password", h.SetPassword)

	payload := []byte(`{"data":{"type":"auth_set_password","attributes":{"token":"abc","password":"123"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/set-password", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_SetPassword_InvalidToken(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	cfg := config.AuthConfig{
		PasswordPepper: "pepper",
		HashTime:       1,
		HashMemoryKB:   64 * 1024,
		HashThreads:    4,
		HashKeyLen:     32,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/set-password", h.SetPassword)

	payload := []byte(`{"data":{"type":"auth_set_password","attributes":{"token":"missing-token","password":"Password123!"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/set-password", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_SetPassword_Success(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	cfg := config.AuthConfig{
		PasswordPepper: "pepper",
		HashTime:       1,
		HashMemoryKB:   64 * 1024,
		HashThreads:    4,
		HashKeyLen:     32,
		SetPasswordURL: "http://localhost/set-password",
		InviteTTL:      time.Hour,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	roleID := uuidv7.MustBytes()
	user, err := svc.InviteUser(context.Background(), "invitee@example.com", "invitee", "John", "Doe", "", roleID)
	if err != nil {
		t.Fatalf("invite user: %v", err)
	}
	inviteToken := store.lastKey[len("auth:invite:"):]

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/set-password", h.SetPassword)

	payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_set_password","attributes":{"token":"%s","password":"Password123!"}}}`, inviteToken))
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/set-password", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	savedUser, err := repo.GetUserByID(context.Background(), user.ID)
	if err != nil {
		t.Fatalf("get user: %v", err)
	}
	if savedUser == nil {
		t.Fatalf("expected user to exist")
	}
	if len(savedUser.PasswordHash) == 0 || len(savedUser.SaltValue) == 0 {
		t.Fatalf("expected password hash and salt to be set")
	}
	if savedUser.EmailVerifiedAt == nil {
		t.Fatalf("expected email verified at to be set")
	}
}

func TestAuthHandler_ResendSetPasswordInvite(t *testing.T) {
	t.Run("validation error invalid user_id", func(t *testing.T) {
		h := NewAuthHandler(service.NewAuthService(newMemRepo(), nil, newMemTokenStore(), nil, service.AuthServiceDependencies{
			Config: config.AuthConfig{
				SetPasswordURL:            "http://localhost/set-password",
				ForgotPasswordMinDuration: 0,
			},
		}))
		app := fiber.New()
		app.Post("/api/v1/auth/invite/resend-set-password", h.ResendSetPasswordInvite)

		payload := []byte(`{"data":{"type":"auth_resend_set_password","attributes":{"user_id":"bad"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite/resend-set-password", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, _ := app.Test(req)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("generic success and enqueue when user has no password", func(t *testing.T) {
		repo := newMemRepo()
		tokens := newMemTokenStore()
		cfg := config.AuthConfig{
			SetPasswordURL:              "http://localhost/set-password",
			InviteTTL:                   time.Hour,
			ForgotPasswordMinDuration:   0,
			ForgotPasswordEmailCooldown: time.Minute,
		}
		svc := service.NewAuthService(repo, nil, tokens, nil, service.AuthServiceDependencies{Config: cfg})
		h := NewAuthHandler(svc)

		userID := uuidv7.MustBytes()
		repo.users[string(userID)] = &auth.User{
			ID:        userID,
			Email:     "invitee@example.com",
			FirstName: "Invitee",
			LastName:  "User",
			IsActive:  true,
		}

		app := fiber.New()
		app.Post("/api/v1/auth/invite/resend-set-password", h.ResendSetPasswordInvite)

		userIDStr, _ := uuidv7.BytesToString(userID)
		payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_resend_set_password","attributes":{"user_id":"%s"}}}`, userIDStr))
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/invite/resend-set-password", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, _ := app.Test(req)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
		if tokens.lastKey == "" || !strings.HasPrefix(tokens.lastKey, "auth:invite:resend:cooldown:") {
			t.Fatalf("expected resend cooldown key, got %q", tokens.lastKey)
		}
	})
}

func TestAuthHandler_TOTPConfirm_InvalidCode(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	cfg := config.AuthConfig{TOTPIssuer: "Wucher"}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})

	h := NewAuthHandler(svc)
	app := fiber.New()
	userID := uuidv7.MustBytes()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", append([]byte(nil), userID...))
		return c.Next()
	})
	app.Post("/api/v1/auth/totp/confirm", h.TOTPConfirm)
	_, _, _, _ = svc.SetupTOTP(context.Background(), userID, "user@example.com")
	payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_confirm","attributes":{"user_id":"%s","code":"000000"}}}`, func() string { s, _ := uuidv7.BytesToString(userID); return s }()))
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/confirm", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("expected 401, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_TOTPConfirm_InvalidUserID(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	userID := uuidv7.MustBytes()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", append([]byte(nil), userID...))
		return c.Next()
	})
	app.Post("/api/v1/auth/totp/confirm", h.TOTPConfirm)

	payload := []byte(`{"data":{"type":"auth_totp_confirm","attributes":{"user_id":"bad","code":"123456"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/confirm", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_TOTPConfirm_InvalidJSON(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	userID := uuidv7.MustBytes()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", append([]byte(nil), userID...))
		return c.Next()
	})
	app.Post("/api/v1/auth/totp/confirm", h.TOTPConfirm)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/confirm", bytes.NewReader([]byte("{bad")))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_TOTPVerify_InvalidChallenge(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/totp/verify", h.TOTPVerify)

	payload := []byte(`{"data":{"type":"auth_totp_verify","attributes":{"challenge_token":"bad","code":"123456"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("expected 401, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(body), `"code":"TOKEN_INVALID"`) {
		t.Fatalf("expected TOKEN_INVALID code, got body=%s", string(body))
	}
}

func TestAuthHandler_TOTPVerify_InvalidJSON(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/totp/verify", h.TOTPVerify)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader([]byte("{bad")))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_TOTPVerify_TokenIssueError(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	cfg := config.AuthConfig{
		TOTPIssuer:       "Wucher",
		TOTPChallengeTTL: time.Minute,
		// No JWT secrets to force token issue error
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})

	// prepare user + totp
	userID := uuidv7.MustBytes()
	if err := repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "u@e.com", RoleID: uuidv7.MustBytes()}); err != nil {
		t.Fatalf("create user: %v", err)
	}
	secret, _, _, _ := svc.SetupTOTP(context.Background(), userID, "u@e.com")
	code, _ := totp.GenerateCode(secret, time.Now().UTC())
	_, _ = svc.ConfirmTOTP(context.Background(), userID, code)
	challenge, _ := svc.CreateTOTPChallenge(context.Background(), userID)

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/totp/verify", h.TOTPVerify)

	payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_verify","attributes":{"challenge_token":"%s","code":"%s"}}}`, challenge, code))
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_TOTPDisable_InvalidUserID(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	userID := uuidv7.MustBytes()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", append([]byte(nil), userID...))
		return c.Next()
	})
	app.Post("/api/v1/auth/totp/disable", h.TOTPDisable)

	payload := []byte(`{"data":{"type":"auth_totp_disable","attributes":{"user_id":"bad"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/disable", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_TOTPDisable_InvalidJSON(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	userID := uuidv7.MustBytes()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", append([]byte(nil), userID...))
		return c.Next()
	})
	app.Post("/api/v1/auth/totp/disable", h.TOTPDisable)

	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/disable", bytes.NewReader([]byte("{bad")))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_ParseSameSite_All(t *testing.T) {
	if parseSameSite("Strict") != "Strict" {
		t.Fatalf("expected strict")
	}
	if parseSameSite("None") != "None" {
		t.Fatalf("expected none")
	}
	if parseSameSite("Lax") != "Lax" {
		t.Fatalf("expected lax")
	}
}

func TestAuthHandler_TOTPVerify_SetsCookies(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:   "pepper",
		HashTime:         1,
		HashMemoryKB:     64 * 1024,
		HashThreads:      4,
		HashKeyLen:       32,
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
		TOTPChallengeTTL: time.Minute,
		TOTPIssuer:       "Wucher",
		DefaultRoleName:  "admin",
	}

	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
	user, _ := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", roleID)
	if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
		t.Fatalf("mark email verified: %v", err)
	}

	secret, _, _, _ := svc.SetupTOTP(context.Background(), user.ID, user.Email)
	code, _ := totp.GenerateCode(secret, time.Now().UTC())
	_, _ = svc.ConfirmTOTP(context.Background(), user.ID, code)

	challenge, _ := svc.CreateTOTPChallenge(context.Background(), user.ID)

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/totp/verify", h.TOTPVerify)

	payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_verify","attributes":{"challenge_token":"%s","code":"%s"}}}`, challenge, code))
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_TOTPVerify_WrongThenCorrectSameChallenge(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	roleID := uuidv7.MustBytes()
	repo.rolesByName["admin"] = &auth.Role{ID: roleID, Name: "admin"}
	repo.rolesByID[string(roleID)] = repo.rolesByName["admin"]

	cfg := config.AuthConfig{
		PasswordPepper:   "pepper",
		HashTime:         1,
		HashMemoryKB:     64 * 1024,
		HashThreads:      4,
		HashKeyLen:       32,
		JWTAccessSecret:  "access",
		JWTRefreshSecret: "refresh",
		JWTAccessTTL:     time.Minute,
		JWTRefreshTTL:    time.Hour,
		TOTPChallengeTTL: time.Minute,
		TOTPIssuer:       "Wucher",
		DefaultRoleName:  "admin",
	}

	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: cfg})
	user, _ := svc.RegisterWithEmail(context.Background(), "user2@example.com", "john.doe.2", "John", "Doe", "", "Password123!", roleID)
	if err := repo.MarkEmailVerified(context.Background(), user.ID); err != nil {
		t.Fatalf("mark email verified: %v", err)
	}

	secret, _, _, _ := svc.SetupTOTP(context.Background(), user.ID, user.Email)
	code, _ := totp.GenerateCode(secret, time.Now().UTC())
	_, _ = svc.ConfirmTOTP(context.Background(), user.ID, code)
	challenge, _ := svc.CreateTOTPChallenge(context.Background(), user.ID)

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/totp/verify", h.TOTPVerify)

	wrongPayload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_verify","attributes":{"challenge_token":"%s","code":"000000"}}}`, challenge))
	wrongReq, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader(wrongPayload))
	wrongReq.Header.Set("Content-Type", "application/json")
	wrongResp, err := app.Test(wrongReq)
	if err != nil {
		t.Fatalf("app.Test wrong attempt: %v", err)
	}
	if wrongResp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("expected 401 for wrong code, got %d", wrongResp.StatusCode)
	}
	wrongBody, _ := io.ReadAll(wrongResp.Body)
	if !strings.Contains(string(wrongBody), `"code":"USER_UNAUTHORIZED"`) {
		t.Fatalf("expected USER_UNAUTHORIZED code on wrong totp, got body=%s", string(wrongBody))
	}

	okPayload := []byte(fmt.Sprintf(`{"data":{"type":"auth_totp_verify","attributes":{"challenge_token":"%s","code":"%s"}}}`, challenge, code))
	okReq, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/totp/verify", bytes.NewReader(okPayload))
	okReq.Header.Set("Content-Type", "application/json")
	okResp, err := app.Test(okReq)
	if err != nil {
		t.Fatalf("app.Test correct attempt: %v", err)
	}
	if okResp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200 after retry with same challenge, got %d", okResp.StatusCode)
	}
}

func TestAuthHandler_SetupSecurityPIN(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	userID := uuidv7.MustBytes()
	if err := repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}); err != nil {
		t.Fatalf("create user: %v", err)
	}

	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}})
	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", userID)
		return c.Next()
	})
	app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN)

	payload := []byte(`{"data":{"type":"auth_pin_setup","attributes":{"pin":"123456","confirm_pin":"123456"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if len(repo.users[string(userID)].SecurityPINHash) == 0 {
		t.Fatalf("expected encrypted PIN to be stored")
	}
}

func TestAuthHandler_VerifySecurityPIN(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	userID := uuidv7.MustBytes()
	if err := repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}); err != nil {
		t.Fatalf("create user: %v", err)
	}

	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{
		SecurityPINActionTokenTTL: time.Minute,
	}})
	if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
		t.Fatalf("set pin: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", userID)
		return c.Next()
	})
	app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN)

	payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"123456","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !bytes.Contains(body, []byte("action_token")) {
		t.Fatalf("expected action_token in response body, got %s", string(body))
	}
}

func TestAuthHandler_ChangeSecurityPIN_InvalidCurrent(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	userID := uuidv7.MustBytes()
	if err := repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()}); err != nil {
		t.Fatalf("create user: %v", err)
	}

	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}})
	if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
		t.Fatalf("set pin: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", userID)
		return c.Next()
	})
	app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN)

	payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"000000","new_pin":"654321","confirm_pin":"654321"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("expected 401, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_ForgotSecurityPIN_GenericResponse(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	cfg := config.AuthConfig{
		SecurityPINResetURL:       "https://app.example.com/reset-pin",
		SecurityPINResetTTL:       20 * time.Minute,
		ForgotSecurityPINCooldown: time.Minute,
	}
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})

	existing := &auth.User{ID: uuidv7.MustBytes(), Email: "exists@example.com", RoleID: uuidv7.MustBytes()}
	if err := repo.CreateUser(context.Background(), existing); err != nil {
		t.Fatalf("create user: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/pin/forgot", h.ForgotSecurityPIN)

	existingPayload := []byte(`{"data":{"type":"auth_forgot_pin","attributes":{"email":"exists@example.com"}}}`)
	missingPayload := []byte(`{"data":{"type":"auth_forgot_pin","attributes":{"email":"missing@example.com"}}}`)

	req1, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/forgot", bytes.NewReader(existingPayload))
	req1.Header.Set("Content-Type", "application/json")
	resp1, err := app.Test(req1)
	if err != nil {
		t.Fatalf("first app.Test: %v", err)
	}
	req2, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/forgot", bytes.NewReader(missingPayload))
	req2.Header.Set("Content-Type", "application/json")
	resp2, err := app.Test(req2)
	if err != nil {
		t.Fatalf("second app.Test: %v", err)
	}
	if resp1.StatusCode != http.StatusOK || resp2.StatusCode != http.StatusOK {
		t.Fatalf("expected both responses 200, got %d and %d", resp1.StatusCode, resp2.StatusCode)
	}
	body1, _ := io.ReadAll(resp1.Body)
	body2, _ := io.ReadAll(resp2.Body)
	if !bytes.Equal(body1, body2) {
		t.Fatalf("forgot-pin responses should match, got existing=%s missing=%s", string(body1), string(body2))
	}
}

func TestAuthHandler_RequestSecurityPINReset(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
		SecurityPINResetURL:       "https://app.example.com/reset-pin",
		SecurityPINResetTTL:       20 * time.Minute,
		ForgotSecurityPINCooldown: time.Minute,
		SecurityPINLockDuration:   0,
		PasswordPepper:            "pepper",
		HashTime:                  1,
		HashMemoryKB:              64 * 1024,
		HashThreads:               4,
		HashKeyLen:                32,
	}})
	h := NewAuthHandler(svc)

	user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", uuidv7.MustBytes())
	if err != nil {
		t.Fatalf("register user: %v", err)
	}
	userID := user.ID
	payload := []byte(`{"data":{"type":"auth_pin_request_reset","attributes":{"password":"Password123!"}}}`)

	t.Run("missing auth context", func(t *testing.T) {
		app := fiber.New()
		app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset)

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("not blocked", func(t *testing.T) {
		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset)

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusConflict {
			t.Fatalf("expected 409, got %d", resp.StatusCode)
		}
		body, _ := io.ReadAll(resp.Body)
		if !bytes.Contains(body, []byte(`"title":"PIN not blocked"`)) {
			t.Fatalf("expected PIN not blocked title, got %s", string(body))
		}
		if !bytes.Contains(body, []byte(`"detail":"security PIN is not blocked"`)) {
			t.Fatalf("expected security PIN is not blocked detail, got %s", string(body))
		}
	})

	t.Run("blocked and reset requested", func(t *testing.T) {
		userIDStr, _ := uuidv7.BytesToString(userID)
		store.store["auth:security_pin:lock:"+userIDStr] = []byte("1")

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset)

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid password", func(t *testing.T) {
		userIDStr, _ := uuidv7.BytesToString(userID)
		store.store["auth:security_pin:lock:"+userIDStr] = []byte("1")
		delete(store.store, "auth:security_pin:cooldown:user@example.com")

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset)

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader([]byte(`{"data":{"type":"auth_pin_request_reset","attributes":{"password":"wrong-password"}}}`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_ResetSecurityPIN(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{
		PasswordPepper: "pepper",
		HashTime:       1,
		HashMemoryKB:   64 * 1024,
		HashThreads:    4,
		HashKeyLen:     32,
	}})
	user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", uuidv7.MustBytes())
	if err != nil {
		t.Fatalf("register user: %v", err)
	}

	rawToken := "pin-reset-token"
	tokenHash := sha256.Sum256([]byte(rawToken))
	repo.securityPINResetTokens[string(tokenHash[:])] = &auth.SecurityPINResetToken{
		ID:        uuidv7.MustBytes(),
		UserID:    user.ID,
		TokenHash: tokenHash[:],
		ExpiresAt: time.Now().Add(10 * time.Minute),
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN)

	payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"pin-reset-token","password":"Password123!","new_pin":"654321","confirm_pin":"654321"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}

	enc := repo.users[string(user.ID)].SecurityPINHash
	if len(enc) == 0 {
		t.Fatalf("expected hashed pin record on user")
	}
	if _, err := svc.VerifySecurityPINForAction(context.Background(), user.ID, "654321", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"); err != nil {
		t.Fatalf("expected updated pin to verify, got %v", err)
	}
}

func TestAuthHandler_SetupSecurityPIN_Branches(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
	h := NewAuthHandler(svc)

	t.Run("missing auth context", func(t *testing.T) {
		app := fiber.New()
		app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_setup","attributes":{"pin":"123456","confirm_pin":"123456"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("confirm mismatch", func(t *testing.T) {
		userID := uuidv7.MustBytes()
		_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_setup","attributes":{"pin":"123456","confirm_pin":"123455"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_VerifySecurityPIN_Branches(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()

	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	userID := uuidv7.MustBytes()
	_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})

	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{
		SecurityPINMaxAttempts:    1,
		SecurityPINLockDuration:   time.Minute,
		SecurityPINActionTokenTTL: time.Minute,
	}})
	h := NewAuthHandler(svc)

	t.Run("missing auth context", func(t *testing.T) {
		app := fiber.New()
		app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"123456","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("pin not set", func(t *testing.T) {
		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"123456","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusConflict {
			t.Fatalf("expected 409, got %d", resp.StatusCode)
		}
	})

	t.Run("locked response", func(t *testing.T) {
		if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
			t.Fatalf("set pin: %v", err)
		}
		_, _ = svc.VerifySecurityPINForAction(context.Background(), userID, "000000", "a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74")

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"123456","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusLocked {
			t.Fatalf("expected 423, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_Sessions_Branches(t *testing.T) {
	t.Run("missing auth context", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
			JWTAccessSecret:  "access",
			JWTRefreshSecret: "refresh",
			JWTAccessTTL:     time.Minute,
			JWTRefreshTTL:    time.Hour,
		}})
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Get("/api/v1/auth/sessions", h.Sessions)

		req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/sessions", nil)
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("service error on invalid user id", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
			JWTAccessSecret:  "access",
			JWTRefreshSecret: "refresh",
			JWTAccessTTL:     time.Minute,
			JWTRefreshTTL:    time.Hour,
		}})
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", []byte("short"))
			return c.Next()
		})
		app.Get("/api/v1/auth/sessions", h.Sessions)

		req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/sessions", nil)
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("success returns only current active session", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
			JWTAccessSecret:  "access",
			JWTRefreshSecret: "refresh",
			JWTAccessTTL:     time.Minute,
			JWTRefreshTTL:    time.Hour,
		}})
		user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com", RoleID: uuidv7.MustBytes()}
		if err := repo.CreateUser(context.Background(), user); err != nil {
			t.Fatalf("create user: %v", err)
		}
		first, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Macintosh)", "127.0.0.1")
		if err != nil {
			t.Fatalf("issue first token: %v", err)
		}
		second, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Windows)", "127.0.0.2")
		if err != nil {
			t.Fatalf("issue second token: %v", err)
		}
		if _, err := svc.ParseAccessToken(context.Background(), first.AccessToken); !errors.Is(err, service.ErrInvalidToken) {
			t.Fatalf("expected first token invalid after second login, got %v", err)
		}

		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return c.Next()
		})
		app.Get("/api/v1/auth/sessions", h.Sessions)

		req, _ := http.NewRequest(http.MethodGet, "/api/v1/auth/sessions", nil)
		req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: second.AccessToken})
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}

		body, err := io.ReadAll(resp.Body)
		if err != nil {
			t.Fatalf("read body: %v", err)
		}

		var payload map[string]any
		if err := json.Unmarshal(body, &payload); err != nil {
			t.Fatalf("unmarshal body: %v", err)
		}
		data := payload["data"].(map[string]any)
		attrs := data["attributes"].(map[string]any)
		sessions := attrs["sessions"].([]any)
		if len(sessions) != 1 {
			t.Fatalf("expected only one active session, got %d", len(sessions))
		}
		session := sessions[0].(map[string]any)
		if session["id"] != second.SessionID || session["active_now"] != true {
			t.Fatalf("expected current session flagged active_now, got %#v", session)
		}
	})
}

func TestAuthHandler_DeleteSession_Branches(t *testing.T) {
	newFixture := func(t *testing.T) (*memRepo, *service.AuthService, *auth.User, service.TokenPair, service.TokenPair) {
		t.Helper()
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
			JWTAccessSecret:  "access",
			JWTRefreshSecret: "refresh",
			JWTAccessTTL:     time.Minute,
			JWTRefreshTTL:    time.Hour,
		}})
		user := &auth.User{ID: uuidv7.MustBytes(), Email: "user@example.com", RoleID: uuidv7.MustBytes()}
		if err := repo.CreateUser(context.Background(), user); err != nil {
			t.Fatalf("create user: %v", err)
		}
		first, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Macintosh)", "127.0.0.1")
		if err != nil {
			t.Fatalf("issue first token: %v", err)
		}
		second, err := svc.IssueTokensWithClient(context.Background(), user, "Mozilla/5.0 (Windows)", "127.0.0.2")
		if err != nil {
			t.Fatalf("issue second token: %v", err)
		}
		return repo, svc, user, first, second
	}

	t.Run("missing auth context", func(t *testing.T) {
		_, svc, _, _, _ := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Delete("/api/v1/auth/sessions/:session_id", h.DeleteSession)

		req, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/"+mustUUIDStringForcesPresent(t, uuidv7.MustBytes()), nil)
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid session id", func(t *testing.T) {
		_, svc, user, _, _ := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return c.Next()
		})
		app.Delete("/api/v1/auth/sessions/:session_id", h.DeleteSession)

		req, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/not-uuid", nil)
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("session not found", func(t *testing.T) {
		_, svc, user, _, _ := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return c.Next()
		})
		app.Delete("/api/v1/auth/sessions/:session_id", h.DeleteSession)

		req, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/"+mustUUIDStringForcesPresent(t, uuidv7.MustBytes()), nil)
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", resp.StatusCode)
		}
	})

	t.Run("session belongs to another user", func(t *testing.T) {
		_, svc, _, _, second := newFixture(t)
		otherUser := &auth.User{ID: uuidv7.MustBytes(), Email: "other@example.com", RoleID: uuidv7.MustBytes()}
		repo := newMemRepo()
		_ = repo
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", otherUser.ID)
			return c.Next()
		})
		app.Delete("/api/v1/auth/sessions/:session_id", h.DeleteSession)

		req, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/"+second.SessionID, nil)
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("success deletes one session", func(t *testing.T) {
		_, svc, user, first, second := newFixture(t)
		h := NewAuthHandler(svc)
		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", user.ID)
			return c.Next()
		})
		app.Delete("/api/v1/auth/sessions/:session_id", h.DeleteSession)

		req, _ := http.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/"+second.SessionID, nil)
		req.AddCookie(&http.Cookie{Name: svc.AccessCookieName(), Value: first.AccessToken})
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
		if _, err := svc.RefreshTokens(context.Background(), second.RefreshToken); !errors.Is(err, service.ErrInvalidToken) {
			t.Fatalf("expected revoked session refresh token invalid, got %v", err)
		}
	})
}

func TestAuthHandler_ChangeSecurityPIN_Branches(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
	h := NewAuthHandler(svc)

	t.Run("missing auth context", func(t *testing.T) {
		app := fiber.New()
		app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"123456","new_pin":"654321","confirm_pin":"654321"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("confirm mismatch", func(t *testing.T) {
		userID := uuidv7.MustBytes()
		_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"123456","new_pin":"654321","confirm_pin":"654322"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_ForgotAndResetSecurityPIN_Branches(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
	h := NewAuthHandler(svc)

	t.Run("forgot invalid json", func(t *testing.T) {
		app := fiber.New()
		app.Post("/api/v1/auth/pin/forgot", h.ForgotSecurityPIN)

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/forgot", bytes.NewReader([]byte("{bad")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("reset invalid json", func(t *testing.T) {
		app := fiber.New()
		app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN)

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader([]byte("{bad")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("reset invalid link", func(t *testing.T) {
		key := make([]byte, 32)
		for i := range key {
			key[i] = byte(i + 1)
		}
		protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
		svc2 := service.NewAuthService(newMemRepo(), newMemRepo(), newMemTokenStore(), nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}})
		h2 := NewAuthHandler(svc2)

		app := fiber.New()
		app.Post("/api/v1/auth/pin/reset", h2.ResetSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"missing","password":"Password123!","new_pin":"123456","confirm_pin":"123456"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("reset invalid password", func(t *testing.T) {
		key := make([]byte, 32)
		for i := range key {
			key[i] = byte(i + 1)
		}
		protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

		repo2 := newMemRepo()
		svc2 := service.NewAuthService(repo2, repo2, newMemTokenStore(), nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{
			PasswordPepper: "pepper",
			HashTime:       1,
			HashMemoryKB:   64 * 1024,
			HashThreads:    4,
			HashKeyLen:     32,
		}})
		user, err := svc2.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", uuidv7.MustBytes())
		if err != nil {
			t.Fatalf("register user: %v", err)
		}
		hash := sha256.Sum256([]byte("token-invalid-password"))
		repo2.securityPINResetTokens[string(hash[:])] = &auth.SecurityPINResetToken{
			ID:        uuidv7.MustBytes(),
			UserID:    user.ID,
			TokenHash: hash[:],
			ExpiresAt: time.Now().Add(time.Minute),
		}
		h2 := NewAuthHandler(svc2)

		app := fiber.New()
		app.Post("/api/v1/auth/pin/reset", h2.ResetSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"token-invalid-password","password":"WrongPassword","new_pin":"123456","confirm_pin":"123456"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("reset service error returns 500", func(t *testing.T) {
		key := make([]byte, 32)
		for i := range key {
			key[i] = byte(i + 1)
		}
		protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

		repo2 := newMemRepo()
		svc2 := service.NewAuthService(repo2, repo2, newMemTokenStore(), nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{
			PasswordPepper: "pepper",
			HashTime:       1,
			HashMemoryKB:   64 * 1024,
			HashThreads:    4,
			HashKeyLen:     32,
		}})
		user, err := svc2.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", uuidv7.MustBytes())
		if err != nil {
			t.Fatalf("register user: %v", err)
		}
		repo2.consumePINResetErr = errors.New("db fail")
		hash := sha256.Sum256([]byte("token-err"))
		repo2.securityPINResetTokens[string(hash[:])] = &auth.SecurityPINResetToken{
			ID:        uuidv7.MustBytes(),
			UserID:    user.ID,
			TokenHash: hash[:],
			ExpiresAt: time.Now().Add(time.Minute),
		}
		h2 := NewAuthHandler(svc2)

		app := fiber.New()
		app.Post("/api/v1/auth/pin/reset", h2.ResetSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"token-err","password":"Password123!","new_pin":"123456","confirm_pin":"123456"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusInternalServerError {
			t.Fatalf("expected 500, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_SetupSecurityPIN_AlreadySet(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	userID := uuidv7.MustBytes()
	_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}})
	if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
		t.Fatalf("set initial pin: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", userID)
		return c.Next()
	})
	app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN)

	payload := []byte(`{"data":{"type":"auth_pin_setup","attributes":{"pin":"123456","confirm_pin":"123456"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusConflict {
		t.Fatalf("expected 409, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_VerifySecurityPIN_InvalidPIN(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	userID := uuidv7.MustBytes()
	_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{
		SecurityPINMaxAttempts:    5,
		SecurityPINLockDuration:   time.Minute,
		SecurityPINActionTokenTTL: time.Minute,
	}})
	if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
		t.Fatalf("set pin: %v", err)
	}

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", userID)
		return c.Next()
	})
	app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN)

	payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"000000","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("expected 401, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_ChangeSecurityPIN_NotSet(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	userID := uuidv7.MustBytes()
	_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}})

	h := NewAuthHandler(svc)
	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", userID)
		return c.Next()
	})
	app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN)

	payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"123456","new_pin":"654321","confirm_pin":"654321"}}}`)
	req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	if resp.StatusCode != http.StatusConflict {
		t.Fatalf("expected 409, got %d", resp.StatusCode)
	}
}

func TestAuthHandler_ResetSecurityPIN_ValidationBranches(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}})
	h := NewAuthHandler(svc)

	t.Run("confirm mismatch", func(t *testing.T) {
		app := fiber.New()
		app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"x","password":"Password123!","new_pin":"123456","confirm_pin":"123455"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("missing token for password method", func(t *testing.T) {
		app := fiber.New()
		app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"password":"Password123!","new_pin":"123456","confirm_pin":"123456"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("missing password", func(t *testing.T) {
		app := fiber.New()
		app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"x","password":"","new_pin":"123456","confirm_pin":"123456"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid pin format", func(t *testing.T) {
		app := fiber.New()
		app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_reset_pin","attributes":{"token":"x","password":"Password123!","new_pin":"12345a","confirm_pin":"12345a"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("microsoft does not require token", func(t *testing.T) {
		userID := uuidv7.MustBytes()
		userIDStr, err := uuidv7.BytesToString(userID)
		if err != nil {
			t.Fatalf("user id to string: %v", err)
		}
		token := "reauth-direct"
		store.store["auth:microsoft_reauth:"+token] = []byte(userIDStr + "|pin_reset_request")
		_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})

		app := fiber.New()
		app.Post("/api/v1/auth/pin/reset", h.ResetSecurityPIN)

		payload := []byte(fmt.Sprintf(`{"data":{"type":"auth_reset_pin","attributes":{"method":"microsoft","reauth_token":"%s","new_pin":"123456","confirm_pin":"123456"}}}`, token))
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/reset", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_SetupSecurityPIN_ErrorBranches(t *testing.T) {
	repo := newMemRepo()
	store := newMemTokenStore()
	svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
	h := NewAuthHandler(svc)

	t.Run("invalid json", func(t *testing.T) {
		app := fiber.New()
		userID := uuidv7.MustBytes()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN)

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader([]byte("{bad")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("service default branch", func(t *testing.T) {
		userID := uuidv7.MustBytes()
		_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/setup", h.SetupSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_setup","attributes":{"pin":"123456","confirm_pin":"123456"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/setup", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_ChangeSecurityPIN_SuccessAndBranches(t *testing.T) {
	key := make([]byte, 32)
	for i := range key {
		key[i] = byte(i + 1)
	}
	protector, _ := service.NewAESGCMProtectorFromBase64(base64.StdEncoding.EncodeToString(key))

	t.Run("invalid json", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}})
		h := NewAuthHandler(svc)

		userID := uuidv7.MustBytes()
		_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})
		_ = svc.SetSecurityPIN(context.Background(), userID, "123456")

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN)

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader([]byte("{bad")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("locked branch", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{
			SecurityPINMaxAttempts:  1,
			SecurityPINLockDuration: time.Minute,
		}})
		h := NewAuthHandler(svc)

		userID := uuidv7.MustBytes()
		_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})
		_ = svc.SetSecurityPIN(context.Background(), userID, "123456")
		_, _ = svc.VerifySecurityPINForAction(context.Background(), userID, "000000", "428fdf48-3f99-46ea-bc47-8fb2e312be08")

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"123456","new_pin":"654321","confirm_pin":"654321"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusLocked {
			t.Fatalf("expected 423, got %d", resp.StatusCode)
		}
	})

	t.Run("success", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: protector, Config: config.AuthConfig{}})
		h := NewAuthHandler(svc)

		userID := uuidv7.MustBytes()
		_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})
		_ = svc.SetSecurityPIN(context.Background(), userID, "123456")

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/change", h.ChangeSecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_change","attributes":{"current_pin":"123456","new_pin":"654321","confirm_pin":"654321"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/change", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_VerifySecurityPIN_ErrorBranches(t *testing.T) {
	t.Run("invalid json", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
		h := NewAuthHandler(svc)
		userID := uuidv7.MustBytes()
		_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN)

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader([]byte("{bad")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("validation error", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
		h := NewAuthHandler(svc)
		userID := uuidv7.MustBytes()
		_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"","action":""}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("service default branch returns 400", func(t *testing.T) {
		repo := newMemRepo()
		userID := uuidv7.MustBytes()
		_ = repo.CreateUser(context.Background(), &auth.User{ID: userID, Email: "user@example.com", RoleID: uuidv7.MustBytes()})
		svc := service.NewAuthService(repo, repo, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
			SecurityPINActionTokenTTL: time.Minute,
		}})
		if err := svc.SetSecurityPIN(context.Background(), userID, "123456"); err != nil {
			t.Fatalf("set pin: %v", err)
		}
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/verify", h.VerifySecurityPIN)

		payload := []byte(`{"data":{"type":"auth_pin_verify","attributes":{"pin":"123456","action":"a546de2f-f1e6-4c2c-ab03-9b4bf39f4d74"}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/verify", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_RequestSecurityPINReset_ErrorBranches(t *testing.T) {
	t.Run("rate limited after successful request", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
			SecurityPINResetURL:       "https://app.example.com/reset-pin",
			SecurityPINResetTTL:       20 * time.Minute,
			ForgotSecurityPINCooldown: time.Minute,
			PasswordPepper:            "pepper",
			HashTime:                  1,
			HashMemoryKB:              64 * 1024,
			HashThreads:               4,
			HashKeyLen:                32,
		}})
		h := NewAuthHandler(svc)

		user, err := svc.RegisterWithEmail(context.Background(), "user@example.com", "john.doe", "John", "Doe", "", "Password123!", uuidv7.MustBytes())
		if err != nil {
			t.Fatalf("register user: %v", err)
		}
		userID := user.ID
		userIDStr, _ := uuidv7.BytesToString(userID)
		store.store["auth:security_pin:lock:"+userIDStr] = []byte("1")
		payload := []byte(`{"data":{"type":"auth_pin_request_reset","attributes":{"password":"Password123!"}}}`)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", userID)
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset)

		req1, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader(payload))
		req1.Header.Set("Content-Type", "application/json")
		resp1, err := app.Test(req1)
		if err != nil {
			t.Fatalf("first app.Test: %v", err)
		}
		if resp1.StatusCode != http.StatusOK {
			t.Fatalf("expected first request 200, got %d", resp1.StatusCode)
		}

		req2, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader(payload))
		req2.Header.Set("Content-Type", "application/json")
		resp2, err := app.Test(req2)
		if err != nil {
			t.Fatalf("second app.Test: %v", err)
		}
		if resp2.StatusCode != http.StatusTooManyRequests {
			t.Fatalf("expected second request 429, got %d", resp2.StatusCode)
		}
	})

	t.Run("default branch returns 500", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error {
			c.Locals("user_id", []byte("bad"))
			return c.Next()
		})
		app.Post("/api/v1/auth/pin/request-reset", h.RequestSecurityPINReset)

		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/pin/request-reset", bytes.NewReader([]byte(`{"data":{"type":"auth_pin_request_reset","attributes":{"password":"Password123!"}}}`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusInternalServerError {
			t.Fatalf("expected 500, got %d", resp.StatusCode)
		}
	})
}

func TestAuthHandler_WebAuthnRegisterBegin(t *testing.T) {
	validPayload := []byte(`{"data":{"type":"auth_webauthn_register_begin"}}`)

	t.Run("missing auth context", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader(validPayload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid json", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader([]byte(`{"data":`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("validation error", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader([]byte(`{"data":{"type":"wrong"}}`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("not configured", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader(validPayload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("service default branch", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", []byte("short")); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader(validPayload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("success", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/begin", h.WebAuthnRegisterBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", bytes.NewReader(validPayload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
		body, _ := io.ReadAll(resp.Body)
		if !bytes.Contains(body, []byte(`"challenge_token"`)) || !bytes.Contains(body, []byte(`"public_key"`)) {
			t.Fatalf("unexpected response body: %s", string(body))
		}
	})
}

func TestAuthHandler_WebAuthnRegisterFinish(t *testing.T) {
	validPayload := func(token string, credential []byte, name string) []byte {
		return []byte(fmt.Sprintf(`{"data":{"type":"auth_webauthn_register_finish","attributes":{"challenge_token":"%s","credential":%s,"name":"%s"}}}`, token, string(credential), name))
	}

	t.Run("missing auth context", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok", []byte(`{}`), "")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid json", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader([]byte(`{"data":`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("validation error", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish)
		payload := []byte(`{"data":{"type":"auth_webauthn_register_finish","attributes":{"challenge_token":"","credential":null}}}`)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(payload))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("not configured", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok", []byte(`{}`), "")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid challenge", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("missing", []byte(testWebAuthnCreationResponseJSON), "")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("payload invalid", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		putWebAuthnChallenge(t, store, "tok-payload", "registration", userID, webauthnlib.SessionData{
			Challenge:      testWebAuthnRegistrationChallenge,
			RelyingPartyID: "webauthn.io",
			UserID:         userID,
			Expires:        time.Now().UTC().Add(time.Minute),
			CredParams:     webauthnlib.CredentialParametersDefault(),
		})

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok-payload", []byte(`{}`), "")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("verification failed", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		putWebAuthnChallenge(t, store, "tok-verify", "registration", userID, webauthnlib.SessionData{
			Challenge:      "invalid-challenge",
			RelyingPartyID: "webauthn.io",
			UserID:         userID,
			Expires:        time.Now().UTC().Add(time.Minute),
			CredParams:     webauthnlib.CredentialParametersDefault(),
		})

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok-verify", []byte(testWebAuthnCreationResponseJSON), "")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("credential exists", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		seedWebAuthnRegistrationExistingCredential(t, repo, userID)
		putWebAuthnChallenge(t, store, "tok-exists", "registration", userID, webauthnlib.SessionData{
			Challenge:      testWebAuthnRegistrationChallenge,
			RelyingPartyID: "webauthn.io",
			UserID:         userID,
			Expires:        time.Now().UTC().Add(time.Minute),
			CredParams:     webauthnlib.CredentialParametersDefault(),
		})

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok-exists", []byte(testWebAuthnCreationResponseJSON), "")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusConflict {
			t.Fatalf("expected 409, got %d", resp.StatusCode)
		}
	})

	t.Run("service default branch", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", []byte("short")); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok", []byte(testWebAuthnCreationResponseJSON), "")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("success", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		putWebAuthnChallenge(t, store, "tok-success", "registration", userID, webauthnlib.SessionData{
			Challenge:      testWebAuthnRegistrationChallenge,
			RelyingPartyID: "webauthn.io",
			UserID:         userID,
			Expires:        time.Now().UTC().Add(time.Minute),
			CredParams:     webauthnlib.CredentialParametersDefault(),
		})

		app := fiber.New()
		app.Use(func(c *fiber.Ctx) error { c.Locals("user_id", userID); return c.Next() })
		app.Post("/api/v1/auth/webauthn/register/finish", h.WebAuthnRegisterFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/finish", bytes.NewReader(validPayload("tok-success", []byte(testWebAuthnCreationResponseJSON), "Device A")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
		body, _ := io.ReadAll(resp.Body)
		if !bytes.Contains(body, []byte(`"registered":true`)) {
			t.Fatalf("unexpected response body: %s", string(body))
		}
	})
}

func TestAuthHandler_WebAuthnLoginBegin(t *testing.T) {
	validPayload := func(email string) []byte {
		return []byte(fmt.Sprintf(`{"data":{"type":"auth_webauthn_login_begin","attributes":{"email":"%s"}}}`, email))
	}

	t.Run("invalid json", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader([]byte(`{"data":`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("validation error", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader([]byte(`{"data":{"type":"auth_webauthn_login_begin","attributes":{"email":"invalid"}}}`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid credentials", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("missing@example.com")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("email not verified", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		seedWebAuthnUser(repo, "user@example.com", false)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("user@example.com")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("credential unavailable", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		seedWebAuthnUser(repo, "user@example.com", true)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("user@example.com")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusConflict {
			t.Fatalf("expected 409, got %d", resp.StatusCode)
		}
	})

	t.Run("not configured", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
		h := NewAuthHandler(svc)
		seedWebAuthnUser(repo, "user@example.com", true)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("user@example.com")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("service default branch", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
			UserID:         userID,
			CredentialID:   []byte{1},
			CredentialJSON: []byte("{bad"),
		}}

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("user@example.com")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("success", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		seedWebAuthnLoginCredential(t, repo, userID)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/begin", h.WebAuthnLoginBegin)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/begin", bytes.NewReader(validPayload("user@example.com")))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
		body, _ := io.ReadAll(resp.Body)
		if !bytes.Contains(body, []byte(`"challenge_token"`)) || !bytes.Contains(body, []byte(`"public_key"`)) {
			t.Fatalf("unexpected response body: %s", string(body))
		}
	})
}

func TestAuthHandler_WebAuthnLoginFinish(t *testing.T) {
	validPayload := func(token string, credential []byte) []byte {
		return []byte(fmt.Sprintf(`{"data":{"type":"auth_webauthn_login_finish","attributes":{"challenge_token":"%s","credential":%s}}}`, token, string(credential)))
	}

	t.Run("invalid json", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, true)
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader([]byte(`{"data":`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("validation error", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, true)
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader([]byte(`{"data":{"type":"auth_webauthn_login_finish","attributes":{"challenge_token":"","credential":null}}}`)))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("not configured", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := service.NewAuthService(repo, repo, store, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{
			JWTAccessSecret:  "access",
			JWTRefreshSecret: "refresh",
			JWTAccessTTL:     time.Minute,
			JWTRefreshTTL:    time.Hour,
		}})
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok", webAuthnAssertionPayloadForUser(uuidv7.MustBytes()))))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid challenge", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, true)
		h := NewAuthHandler(svc)

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("missing", webAuthnAssertionPayloadForUser(uuidv7.MustBytes()))))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("payload invalid", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, true)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		seedWebAuthnLoginCredential(t, repo, userID)
		putWebAuthnChallenge(t, store, "tok-payload", "login", userID, webauthnlib.SessionData{
			Challenge:      testWebAuthnLoginChallenge,
			RelyingPartyID: "webauthn.io",
			UserID:         userID,
			Expires:        time.Now().UTC().Add(time.Minute),
		})

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-payload", []byte(`{}`))))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid credentials", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, true)
		h := NewAuthHandler(svc)
		userID := uuidv7.MustBytes()
		putWebAuthnChallenge(t, store, "tok-invalid-credentials", "login", userID, webauthnlib.SessionData{
			Challenge:      testWebAuthnLoginChallenge,
			RelyingPartyID: "webauthn.io",
			UserID:         userID,
			Expires:        time.Now().UTC().Add(time.Minute),
		})

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-invalid-credentials", webAuthnAssertionPayloadForUser(userID))))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("credential unavailable", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, true)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		putWebAuthnChallenge(t, store, "tok-no-cred", "login", userID, webauthnlib.SessionData{
			Challenge:      testWebAuthnLoginChallenge,
			RelyingPartyID: "webauthn.io",
			UserID:         userID,
			Expires:        time.Now().UTC().Add(time.Minute),
		})

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-no-cred", webAuthnAssertionPayloadForUser(userID))))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusConflict {
			t.Fatalf("expected 409, got %d", resp.StatusCode)
		}
	})

	t.Run("verification failed", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, true)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		seedWebAuthnLoginCredential(t, repo, userID)
		putWebAuthnChallenge(t, store, "tok-verify", "login", userID, webauthnlib.SessionData{
			Challenge:      "wrong-challenge",
			RelyingPartyID: "webauthn.io",
			UserID:         userID,
			Expires:        time.Now().UTC().Add(time.Minute),
		})

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-verify", webAuthnAssertionPayloadForUser(userID))))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("service default branch", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, true)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		repo.webauthnCredentials[string(userID)] = []auth.UserWebAuthnCredential{{
			UserID:         userID,
			CredentialID:   []byte{1},
			CredentialJSON: []byte("{bad"),
		}}
		putWebAuthnChallenge(t, store, "tok-default", "login", userID, webauthnlib.SessionData{
			Challenge:      testWebAuthnLoginChallenge,
			RelyingPartyID: "webauthn.io",
			UserID:         userID,
			Expires:        time.Now().UTC().Add(time.Minute),
		})

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-default", webAuthnAssertionPayloadForUser(userID))))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("token issue failed", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, false)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		seedWebAuthnLoginCredential(t, repo, userID)
		putWebAuthnChallenge(t, store, "tok-token-fail", "login", userID, webauthnlib.SessionData{
			Challenge:      testWebAuthnLoginChallenge,
			RelyingPartyID: "webauthn.io",
			UserID:         userID,
			Expires:        time.Now().UTC().Add(time.Minute),
		})

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-token-fail", webAuthnAssertionPayloadForUser(userID))))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("success", func(t *testing.T) {
		repo := newMemRepo()
		store := newMemTokenStore()
		svc := newWebAuthnAuthService(repo, store, true)
		h := NewAuthHandler(svc)
		userID := seedWebAuthnUser(repo, "user@example.com", true)
		seedWebAuthnLoginCredential(t, repo, userID)
		putWebAuthnChallenge(t, store, "tok-success", "login", userID, webauthnlib.SessionData{
			Challenge:      testWebAuthnLoginChallenge,
			RelyingPartyID: "webauthn.io",
			UserID:         userID,
			Expires:        time.Now().UTC().Add(time.Minute),
		})

		app := fiber.New()
		app.Post("/api/v1/auth/webauthn/login/finish", h.WebAuthnLoginFinish)
		req, _ := http.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/login/finish", bytes.NewReader(validPayload("tok-success", webAuthnAssertionPayloadForUser(userID))))
		req.Header.Set("Content-Type", "application/json")
		resp, err := app.Test(req)
		if err != nil {
			t.Fatalf("app.Test: %v", err)
		}
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
		body, _ := io.ReadAll(resp.Body)
		if !bytes.Contains(body, []byte(`"access_expires_in"`)) {
			t.Fatalf("unexpected response body: %s", string(body))
		}
		if len(resp.Header.Values("Set-Cookie")) == 0 {
			t.Fatalf("expected auth cookies to be set")
		}
	})
}
