package handler

import (
	"baron-sso-backend/internal/domain"
	"baron-sso-backend/internal/logger"
	"baron-sso-backend/internal/repository"
	"baron-sso-backend/internal/service"
	"baron-sso-backend/internal/utils"
	"bytes"
	"context"
	crand "crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"maps"
	"math"
	"math/rand"
	"net"
	"net/http"
	"net/url"
	"os"
	"slices"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/go-jose/go-jose/v4"
	josejwt "github.com/go-jose/go-jose/v4/jwt"
	"github.com/gofiber/fiber/v2"
	"github.com/lib/pq"
)

const (
	// Redis Key Prefixes
	prefixSession            = "enchanted_session:"
	prefixToken              = "enchanted_token:"
	prefixLoginCode          = "login_code_flow:"
	prefixLoginCodePending   = "login_code_pending:"
	prefixLoginCodeSmsTarget = "login_code_sms_target:"
	prefixLoginCodeSmsLookup = "login_code_sms_lookup:"
	prefixLoginCodeShort     = "login_code_short:"
	prefixLoginCodeValue     = "login_code_value:"
	prefixLoginIDRaw         = "login_id_raw:"
	prefixLoginMethod        = "login_method:"
	prefixLoginFlow          = "login_flow:"
	prefixLoginStrategy      = "login_strategy:"
	prefixLoginApproverMeta  = "login_approver_meta:"
	prefixLoginCodeSmsOnly   = "login_code_sms_only:"
	prefixLoginCodeQrPending = "login_code_qr_pending:"
	prefixLoginCodeQr        = "login_code_qr:"
	prefixHeadlessLinkState  = "headless_link_state:"
	prefixPollMeta           = "poll_meta:"
	prefixQrRef              = "qr_ref:"
	prefixQrMeta             = "qr_meta:"
	prefixQrApproverSession  = "qr_approver_session:"
	prefixQrPending          = "qr_pending:"
	prefixSignupEmail        = "signup:email:"
	prefixSignupPhone        = "signup:phone:"

	// Session Statuses
	statusPending = "pending"
	statusSuccess = "success"

	// Login Flows
	loginFlowCode = "code"
	loginFlowLink = "link"

	// Durations
	defaultExpiration      = 5 * time.Minute
	signupStateExpiration  = 10 * time.Minute
	signupBlockDuration    = 10 * time.Minute
	maxSignupFailures      = 5
	emailCodeTTL           = 5 * time.Minute
	smsCodeTTL             = 3 * time.Minute
	prefixPwdResetToken    = "pwdreset_token:"
	prefixPwdResetUsed     = "pwdreset_used:"
	pwdResetExpiration     = 15 * time.Minute
	pwdResetUsedExpiration = 2 * time.Minute
	minPollInterval        = 2 * time.Second
	loginCodeExpiration    = 10 * time.Minute
	linkResendCooldown     = 60 * time.Second
	prefixDrySend          = "dry_send:"
	headlessJWKSFetchTTL   = 5 * time.Second
)

type AuthHandler struct {
	SmsService         domain.SmsService
	EmailService       domain.EmailService
	RedisService       domain.RedisRepository
	HeadlessJWKS       *service.HeadlessJWKSCacheService
	KratosAdmin        service.KratosAdminService
	IdpProvider        domain.IdentityProvider
	AuditRepo          domain.AuditRepository
	OathkeeperRepo     domain.OathkeeperLogRepository
	Hydra              *service.HydraAdminService
	BackchannelLogout  *service.BackchannelLogoutService
	TenantService      service.TenantService
	KetoService        service.KetoService
	KetoOutboxRepo     repository.KetoOutboxRepository
	UserRepo           repository.UserRepository
	UserProjectionRepo repository.UserProjectionRepository
	ConsentRepo        repository.ClientConsentRepository
	RPUserMetadataRepo repository.RPUserMetadataRepository
	RPUsageSink        domain.RPUsageEventSink
}

type signupState struct {
	Code      string `json:"code"`
	Verified  bool   `json:"verified"`
	FailCount int    `json:"fail_count"`
	ExpiresAt int64  `json:"expires_at"` // Unix timestamp
}

type headlessLinkState struct {
	ClientID       string `json:"clientId"`
	ClientName     string `json:"clientName,omitempty"`
	LoginChallenge string `json:"loginChallenge"`
	LoginID        string `json:"loginId"`
	RedirectTo     string `json:"redirectTo,omitempty"`
}

type headlessClientAssertionClaims struct {
	Issuer    string               `json:"iss"`
	Subject   string               `json:"sub"`
	Audience  headlessAssertionAud `json:"aud"`
	ExpiresAt int64                `json:"exp"`
	IssuedAt  int64                `json:"iat,omitempty"`
	NotBefore int64                `json:"nbf,omitempty"`
	ID        string               `json:"jti,omitempty"`
}

type headlessAssertionAud []string

type headlessLoginFailure struct {
	status      int
	code        string
	safeMessage string
	logMessage  string
	debugFields map[string]any
}

func (e *headlessLoginFailure) Error() string {
	if e == nil {
		return ""
	}
	if e.code != "" {
		return e.code
	}
	return e.safeMessage
}

func newHeadlessLoginFailure(status int, code, safeMessage, logMessage string, debugFields map[string]any) *headlessLoginFailure {
	return &headlessLoginFailure{
		status:      status,
		code:        code,
		safeMessage: safeMessage,
		logMessage:  logMessage,
		debugFields: debugFields,
	}
}

func (a *headlessAssertionAud) UnmarshalJSON(data []byte) error {
	var single string
	if err := json.Unmarshal(data, &single); err == nil {
		*a = []string{single}
		return nil
	}

	var list []string
	if err := json.Unmarshal(data, &list); err != nil {
		return err
	}
	*a = list
	return nil
}

// GenerateSecureToken - Helper to generate secure random strings
func GenerateSecureToken(length int) string {
	b := make([]byte, length)
	if _, err := crand.Read(b); err != nil {
		return ""
	}
	return hex.EncodeToString(b)
}

func GenerateSecureAlnumToken(length int) string {
	const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
	if length <= 0 {
		return ""
	}
	buf := make([]byte, length)
	if _, err := crand.Read(buf); err != nil {
		return ""
	}
	for i := range buf {
		buf[i] = charset[int(buf[i])%len(charset)]
	}
	return string(buf)
}

func GenerateUserCode() string {
	const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"
	return fmt.Sprintf("%c%c-%03d",
		letters[rand.Intn(len(letters))],
		letters[rand.Intn(len(letters))],
		rand.Intn(1000),
	)
}

func checkPollInterval(redis domain.RedisRepository, key string, interval time.Duration) (bool, int) {
	now := time.Now().UnixMilli()
	val, err := redis.Get(key)
	if err == nil && val != "" {
		if last, parseErr := strconv.ParseInt(val, 10, 64); parseErr == nil {
			if now-last < interval.Milliseconds() {
				_ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration)
				return true, int(interval.Seconds()) + 1
			}
		}
	}
	_ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration)
	return false, int(interval.Seconds())
}

func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository, kratos service.KratosAdminService) *AuthHandler {
	backchannelLogout, err := service.NewBackchannelLogoutService()
	if err != nil {
		slog.Warn("failed to initialize backchannel logout service", "error", err)
	}
	return &AuthHandler{
		SmsService:        service.NewSmsService(),
		EmailService:      service.NewEmailService(),
		RedisService:      redisService,
		HeadlessJWKS:      service.NewHeadlessJWKSCacheService(redisService, nil),
		KratosAdmin:       kratos,
		IdpProvider:       idpProvider,
		AuditRepo:         auditRepo,
		OathkeeperRepo:    oathkeeperRepo,
		Hydra:             service.NewHydraAdminService(),
		BackchannelLogout: backchannelLogout,
		TenantService:     tenantService,
		KetoService:       ketoService,
		KetoOutboxRepo:    ketoOutboxRepo,
		UserRepo:          userRepo,
		ConsentRepo:       consentRepo,
	}
}

func (h *AuthHandler) emitRPUsageAuthorizationGranted(c *fiber.Ctx, consentRequest *domain.HydraConsentRequest, profile *domain.UserProfileResponse, sessionID string, autoAccepted bool, correlationID string) error {
	if consentRequest == nil {
		return nil
	}
	return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationGranted, consentRequest.Subject, consentRequest.Client, consentRequest.RequestedScope, profile, sessionID, "hydra_consent", correlationID, domain.JSONMap{
		"auto_accepted": autoAccepted,
		"scopes":        consentRequest.RequestedScope,
	})
}

func (h *AuthHandler) emitRPUsageAuthorizationRevoked(c *fiber.Ctx, subject string, clientID string, profile *domain.UserProfileResponse, sessionID string) error {
	return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationRevoked, subject, domain.HydraClient{ClientID: clientID}, nil, profile, sessionID, "hydra_consent", clientID, domain.JSONMap{})
}

func (h *AuthHandler) emitRPUsageEvent(c *fiber.Ctx, eventType string, subject string, client domain.HydraClient, scopes []string, profile *domain.UserProfileResponse, sessionID string, source string, correlationID string, payload domain.JSONMap) error {
	if h.RPUsageSink == nil {
		return nil
	}
	clientID := strings.TrimSpace(client.ClientID)
	if clientID == "" || strings.TrimSpace(subject) == "" {
		return nil
	}
	tenantID, tenantType := rpUsageTenantFromProfile(profile)
	event := domain.RPUsageEvent{
		EventType:     eventType,
		Subject:       strings.TrimSpace(subject),
		TenantID:      tenantID,
		TenantType:    tenantType,
		ClientID:      clientID,
		ClientName:    strings.TrimSpace(client.ClientName),
		SessionID:     strings.TrimSpace(sessionID),
		Scopes:        pq.StringArray(scopes),
		Source:        source,
		CorrelationID: strings.TrimSpace(correlationID),
		Payload:       payload,
		OccurredAt:    time.Now(),
	}
	if event.Payload == nil {
		event.Payload = domain.JSONMap{}
	}
	if event.ClientName != "" {
		event.Payload["client_name"] = event.ClientName
	}
	if tenantID != "" {
		event.Payload["tenant_id"] = tenantID
	}
	if tenantType != "" {
		event.Payload["tenant_type"] = tenantType
	}
	if c != nil {
		event.Payload["ip_address"] = c.IP()
		event.Payload["user_agent"] = string(c.Request().Header.UserAgent())
	}
	ctx := context.Background()
	if c != nil && c.UserContext() != nil {
		ctx = c.UserContext()
	}
	return h.RPUsageSink.EmitRPUsageEvent(ctx, event)
}

func rpUsageTenantFromProfile(profile *domain.UserProfileResponse) (string, string) {
	if profile == nil {
		return "", ""
	}
	tenantID := ""
	if profile.SessionTenantID != nil {
		tenantID = strings.TrimSpace(*profile.SessionTenantID)
	}
	if tenantID == "" && profile.TenantID != nil {
		tenantID = strings.TrimSpace(*profile.TenantID)
	}
	tenantType := ""
	if profile.Tenant != nil {
		switch strings.ToUpper(strings.TrimSpace(profile.Tenant.Type)) {
		case domain.TenantTypeCompany, domain.TenantTypeOrganization:
			tenantType = strings.ToUpper(strings.TrimSpace(profile.Tenant.Type))
			if tenantID == "" {
				tenantID = strings.TrimSpace(profile.Tenant.ID)
			}
		case domain.TenantTypeUserGroup, domain.TenantTypePersonal:
			return "", ""
		}
	}
	return tenantID, tenantType
}

// --- Signup Flow Handlers ---

// CheckEmail - 이메일 사용 가능 여부를 확인합니다.
func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error {
	var req domain.CheckEmailRequest
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request")
	}

	// Email Format Validation
	if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid email format")
	}

	if h.IdpProvider == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
	}

	exists, err := h.IdpProvider.UserExists(req.Email)
	if err != nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
	}
	if exists {
		return c.JSON(fiber.Map{"available": false, "message": "Email already registered"})
	}
	return c.JSON(fiber.Map{"available": true})
}

// CheckLoginID - 로그인 ID 사용 가능 여부를 확인합니다.
func (h *AuthHandler) CheckLoginID(c *fiber.Ctx) error {
	var req domain.CheckLoginIDRequest
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request")
	}

	if h.IdpProvider == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
	}

	// Basic validation via our ValidateLoginID helper (without email/phone since we just check format & collision with reserved words)
	if err := domain.ValidateLoginID(req.LoginID, []string{}, ""); err != nil {
		return c.JSON(fiber.Map{"available": false, "message": err.Error()})
	}

	// We don't prepend companyCode to Kratos lookup if traits.id is unique globally
	// Assuming Kratos traits.id handles unique constraints per tenant or globally based on schema
	exists, err := h.IdpProvider.UserExists(req.LoginID)
	if err != nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
	}

	if !exists && h.UserRepo != nil {
		// [New] Check local DB for custom login IDs (Plan A)
		taken, err := h.UserRepo.IsLoginIDTaken(c.Context(), req.LoginID)
		if err != nil {
			slog.Error("Failed to check login ID in local DB", "error", err)
		} else if taken {
			exists = true
		}
	}

	if exists {
		return c.JSON(fiber.Map{"available": false, "message": "ID already registered"})
	}
	return c.JSON(fiber.Map{"available": true})
}

// SendSignupEmailCode - Sends verification code to email
func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error {
	var req domain.SendSignupCodeRequest
	if err := c.BodyParser(&req); err != nil || req.Target == "" {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid email")
	}
	req.Type = "email" // Enforce type

	key := prefixSignupEmail + req.Target

	// 1. Check existing state (Rate Limit / Block)
	state, _ := h.getSignupState(key)
	if state != nil && state.FailCount > maxSignupFailures {
		// Check if block expired
		// Simple block implementation: if FailCount > 5, user is blocked until TTL expires
		// Since we refresh TTL on each update, we rely on Redis TTL.
		return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts. Try again later.")
	}

	// 2. Generate Code
	rand.Seed(time.Now().UnixNano())
	code := fmt.Sprintf("%06d", rand.Intn(1000000))

	// 3. Update State
	newState := &signupState{
		Code:      code,
		Verified:  false,
		FailCount: 0, // Reset fail count on new code generation? Or keep it?
		// Requirement says "Auth fail > 5 -> block". New code usually resets or continues?
		// Usually getting a new code doesn't reset verify failure count if we want strict blocking.
		// But for simplicity let's say "fail count" applies to verification attempts.
		// If we are issuing a new code, it's a new attempt cycle usually.
		// However, spamming "send code" is also an attack.
		// Let's keep FailCount if exists, or 0.
		ExpiresAt: time.Now().Add(emailCodeTTL).Unix(),
	}
	if state != nil {
		newState.FailCount = state.FailCount
	}

	h.saveSignupState(key, newState, signupStateExpiration)

	// 4. Send Email
	if h.EmailService == nil {
		return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured")
	}

	subject := "[Baron 로그인] 회원가입 인증코드"
	body := fmt.Sprintf(`
		<div style="padding: 20px; font-family: sans-serif;">
			<h2>이메일 인증</h2>
			<p>아래 인증코드를 입력하여 회원가입을 진행해 주세요.</p>
			<h1 style="color: #1A1F2C; letter-spacing: 5px;">%s</h1>
			<p>이 코드는 5분간 유효합니다.</p>
		</div>
	`, code)

	go h.EmailService.SendEmail(req.Target, subject, body)

	return c.JSON(fiber.Map{"message": "Verification code sent"})
}

// SendSignupSmsCode - Sends verification code to phone
func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error {
	var req domain.SendSignupCodeRequest
	if err := c.BodyParser(&req); err != nil || req.Target == "" {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid phone number")
	}
	req.Type = "phone"

	// Sanitize phone
	phone := strings.ReplaceAll(req.Target, "-", "")
	key := prefixSignupPhone + phone

	// 1. Check existing state
	state, _ := h.getSignupState(key)
	if state != nil && state.FailCount > maxSignupFailures {
		return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts. Try again later.")
	}

	// 2. Generate Code
	rand.Seed(time.Now().UnixNano())
	code := fmt.Sprintf("%06d", rand.Intn(1000000))

	// 3. Save State
	newState := &signupState{
		Code:      code,
		Verified:  false,
		FailCount: 0,
		ExpiresAt: time.Now().Add(smsCodeTTL).Unix(),
	}
	if state != nil {
		newState.FailCount = state.FailCount
	}
	h.saveSignupState(key, newState, signupStateExpiration)

	// 4. Send SMS
	content := fmt.Sprintf("[Baron 로그인] 인증번호 [%s]를 입력해주세요.", code)
	go h.SmsService.SendSms(phone, content)

	return c.JSON(fiber.Map{"message": "Verification code sent"})
}

var affiliateSlugs = map[string]bool{
	"hanmac":   true,
	"saman":    true,
	"ptc":      true,
	"jangheon": true,
	"baron":    true,
	"halla":    true,
}

func (h *AuthHandler) isAffiliateTenant(ctx context.Context, domainName string) (bool, *domain.Tenant) {
	if h.TenantService == nil {
		return false, nil
	}
	tenant, err := h.TenantService.GetTenantByDomain(ctx, domainName)
	if err != nil || tenant == nil {
		return false, nil
	}
	// [Strict] Check if the slug belongs to the predefined family company slugs
	return affiliateSlugs[strings.ToLower(tenant.Slug)], tenant
}

// VerifySignupCode - Verifies the code for email or phone
func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
	var req domain.VerifySignupCodeRequest
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request")
	}

	var key string
	if req.Type == "email" {
		key = prefixSignupEmail + req.Target
	} else if req.Type == "phone" {
		phone := strings.ReplaceAll(req.Target, "-", "")
		key = prefixSignupPhone + phone
	} else {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid type")
	}

	state, err := h.getSignupState(key)
	if err != nil || state == nil {
		return errorJSON(c, fiber.StatusBadRequest, "Verification session expired or not found")
	}

	// Check Verified
	if state.Verified {
		return c.JSON(fiber.Map{"success": true, "message": "Already verified"})
	}

	// Check Attempts
	if state.FailCount > maxSignupFailures {
		return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts")
	}

	// Check Code match (Allow magic code 000000 in non-production environments)
	isMagicCodeAllowed := service.IsDryRunAllowed() && req.Code == "000000"
	if state.Code != req.Code && !isMagicCodeAllowed {
		state.FailCount++
		h.saveSignupState(key, state, signupStateExpiration)
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
			"error":     "Invalid code",
			"code":      "invalid_code",
			"failCount": state.FailCount,
		})
	}

	// Check Expiry (Logic time vs stored time)
	if time.Now().Unix() > state.ExpiresAt {
		return errorJSON(c, fiber.StatusBadRequest, "Code expired")
	}

	// Success
	state.Verified = true
	h.saveSignupState(key, state, signupStateExpiration)

	// [New] Check if this is a family affiliate domain to let frontend lock the choice
	isAffiliate := false
	parts := strings.Split(req.Target, "@")
	if req.Type == "email" && len(parts) == 2 {
		isAffiliate, _ = h.isAffiliateTenant(c.Context(), parts[1])
	}

	return c.JSON(fiber.Map{
		"success":     true,
		"isAffiliate": isAffiliate,
	})
}

// Signup - Finalize registration
// GetActiveTenants - List active tenants ONLY if the email is verified in Redis
func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
	if h.TenantService == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "Tenant service unavailable")
	}

	email := c.Query("email")
	if email == "" {
		// No email provided, return empty list (Security policy)
		return c.JSON([]any{})
	}

	// 1. Verify Verification Status in Redis
	emailKey := prefixSignupEmail + email
	state, _ := h.getSignupState(emailKey)
	if state == nil || !state.Verified {
		slog.Warn("[GetActiveTenants] Unverified access attempt", "email", email)
		return errorJSON(c, fiber.StatusForbidden, "Email verification is required before selecting an organization.")
	}

	// 2. Extract domain from verified email
	parts := strings.Split(email, "@")
	if len(parts) != 2 {
		return c.JSON([]any{})
	}
	domainName := parts[1]

	// [Policy] Verify if the email belongs to any family affiliate domain
	isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
	if !isInternal {
		// If not an affiliate email, do not show any tenants
		return c.JSON([]any{})
	}

	// 3. List and Filter Tenants
	tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "", "")
	if err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants")
	}

	type tenantResp struct {
		ID      string   `json:"id"`
		Name    string   `json:"name"`
		Slug    string   `json:"slug"`
		Type    string   `json:"type"`
		Domains []string `json:"domains"`
	}

	var results []tenantResp
	for _, t := range tenants {
		// [Strict] Only allow choosing defined family company slugs
		if t.Status != domain.TenantStatusActive || !affiliateSlugs[strings.ToLower(t.Slug)] {
			continue
		}

		var domains []string
		for _, d := range t.Domains {
			domains = append(domains, d.Domain)
		}
		results = append(results, tenantResp{
			ID:      t.ID,
			Name:    t.Name,
			Slug:    t.Slug,
			Type:    t.Type,
			Domains: domains,
		})
	}

	return c.JSON(results)
}

func (h *AuthHandler) Signup(c *fiber.Ctx) error {
	var req domain.SignupRequest
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
	}

	// 1. Validate Fields (Simple validation)
	if req.Email == "" || req.Password == "" || req.Name == "" || req.Phone == "" {
		return errorJSON(c, fiber.StatusBadRequest, "Missing required fields")
	}
	if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid email format")
	}
	if !req.TermsAccepted {
		return errorJSON(c, fiber.StatusBadRequest, "Terms must be accepted")
	}

	// 비밀번호 정책 검증
	policy := h.resolvePasswordPolicy()
	if err := validatePasswordWithPolicy(policy, req.Password); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, err.Error())
	}

	// 2. Verify Auth Status (Redis)
	emailKey := prefixSignupEmail + req.Email
	phoneKey := prefixSignupPhone + strings.ReplaceAll(req.Phone, "-", "")

	emailState, _ := h.getSignupState(emailKey)
	phoneState, _ := h.getSignupState(phoneKey)

	if emailState == nil || !emailState.Verified {
		return errorJSON(c, fiber.StatusUnauthorized, "Email not verified")
	}
	if phoneState == nil || !phoneState.Verified {
		return errorJSON(c, fiber.StatusUnauthorized, "Phone not verified")
	}

	if h.IdpProvider == nil {
		return errorJSON(c, fiber.StatusInternalServerError, "Identity provider unavailable")
	}

	if strings.TrimSpace(req.CompanyCode) != "" {
		return errorJSON(c, fiber.StatusBadRequest, "companyCode is deprecated; use tenantSlug")
	}

	// 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다.
	tenantSlug := strings.TrimSpace(req.TenantSlug)
	var tenantID *string

	parts := strings.Split(req.Email, "@")
	if len(parts) != 2 {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid email format")
	}
	domainName := parts[1]

	// Check if this domain belongs to a predefined family affiliate
	isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)

	// [Strict Policy] Force AffiliationType based on predefined family slugs (User cannot choose)
	if isInternal {
		req.AffiliationType = "AFFILIATE"
		slog.Info("[Signup] Forcing AffiliationType to AFFILIATE", "email", req.Email)
	} else {
		req.AffiliationType = "GENERAL"
		slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email)
	}

	if tenantSlug != "" {
		// [Security] Cross-check: If domain is NOT internal, they cannot provide a tenantSlug
		if !isInternal {
			slog.Warn("[Signup] Security violation: non-internal email providing tenantSlug", "email", req.Email)
			return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.")
		}

		if !affiliateSlugs[strings.ToLower(tenantSlug)] {
			return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.")
		}

		tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
		if err == nil && tenant != nil {
			if tenant.Status == domain.TenantStatusActive {
				slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
				tenantSlug = tenant.Slug
				tenantID = &tenant.ID
			} else {
				return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.")
			}
		} else {
			slog.Warn("[Signup] Attempted to join non-existent organization", "slug", tenantSlug, "email", req.Email)
			return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.")
		}
	} else {
		// If it's a family affiliate domain, they MUST select one of the family companies
		if isInternal {
			return errorJSON(c, fiber.StatusBadRequest, "Please select your organization.")
		}
	}

	if tenantID == nil && req.AffiliationType == "AFFILIATE" {
		return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.")
	}
	if tenantID == nil && req.AffiliationType == "GENERAL" {
		tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, req.Email)
		if err != nil {
			return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
		}
		tenantSlug = tenant.Slug
		tenantID = &tenant.ID
	}

	// Normalize Phone (E.164 형태로 보관)
	normalizedPhone := domain.NormalizePhoneNumber(req.Phone)

	slog.Info("[Signup] Phone normalization", "raw", req.Phone, "normalized", normalizedPhone)

	// IDP에 전달할 BrokerUser 스키마 구성
	attributes := map[string]any{
		"department":      req.Department,
		"affiliationType": req.AffiliationType,
		"grade":           "",
		"role":            domain.RoleUser,
	}
	if tenantID != nil {
		attributes["tenant_id"] = *tenantID
	}

	// Sync all custom login IDs based on tenant schemas
	loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")

	// Validate all collected LoginIDs
	if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
		for _, lid := range collectedIDs {
			if err := domain.ValidateLoginID(lid, []string{req.Email}, normalizedPhone); err != nil {
				return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error())
			}
		}
	}

	brokerUser := &domain.BrokerUser{
		Email:       req.Email,
		Name:        req.Name,
		PhoneNumber: normalizedPhone,
		Attributes:  attributes,
	}

	providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password)
	if err != nil {
		if errors.Is(err, domain.ErrNotSupported) {
			return errorJSON(c, fiber.StatusNotImplemented, "Signup method not supported")
		}
		slog.Error("[Signup] Failed to create user via IDP", "provider", h.IdpProvider.Name(), "error", err)
		if strings.Contains(err.Error(), "already exists") {
			return errorJSON(c, fiber.StatusConflict, "User or login identifier already exists")
		}
		// Include the actual error message in the response for debugging
		return errorJSON(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to create user: %v", err))
	}

	// 4. Cleanup Redis
	h.RedisService.Delete(emailKey)
	h.RedisService.Delete(phoneKey)

	slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID)

	// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
	// 로컬 DB 저장이 실패하더라도 회원가입 프로세스는 성공으로 간주합니다.
	localUser := &domain.User{
		ID:              providerID,
		Email:           req.Email,
		Name:            req.Name,
		Phone:           normalizedPhone,
		Role:            "user",
		AffiliationType: req.AffiliationType,
		Department:      req.Department,
		Status:          "active",
		CreatedAt:       time.Now(),
		UpdatedAt:       time.Now(),
	}

	if tenantID != nil {
		localUser.TenantID = tenantID
	}

	// Merge metadata
	localUser.Metadata = make(domain.JSONMap)
	maps.Copy(localUser.Metadata, req.Metadata)

	if h.UserRepo != nil {
		go func(u *domain.User, ids []domain.UserLoginID) {
			ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
			defer cancel()

			if err := h.UserRepo.Update(ctx, u); err != nil {
				slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
				markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
			} else {
				slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)

				// Update User Login IDs
				for i := range ids {
					ids[i].UserID = u.ID
				}
				if err := h.UserRepo.UpdateUserLoginIDs(ctx, u.ID, ids); err != nil {
					slog.Error("[Signup] Failed to update user login IDs", "userID", u.ID, "error", err)
					markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
				}

				// [Keto] Sync user-tenant relationship via Outbox
				if h.KetoOutboxRepo != nil && u.TenantID != nil {
					_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
						Namespace: "Tenant",
						Object:    *u.TenantID,
						Relation:  "members",
						Subject:   "User:" + u.ID,
						Action:    domain.KetoOutboxActionCreate,
					})
				}
			}
		}(localUser, loginIDRecords)
	}

	return c.JSON(fiber.Map{
		"success":  true,
		"message":  "User registered successfully",
		"provider": h.IdpProvider.Name(),
		"subject":  providerID,
	})
}

// --- Helpers ---

func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string {
	authHeader := c.Get("Authorization")
	if authHeader == "" {
		return ""
	}
	parts := strings.Split(authHeader, " ")
	if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
		return ""
	}
	return parts[1]
}

func firstForwardedValue(raw string) string {
	for part := range strings.SplitSeq(raw, ",") {
		value := strings.TrimSpace(part)
		if value != "" {
			return value
		}
	}
	return ""
}

func forwardedDirective(raw, key string) string {
	for group := range strings.SplitSeq(raw, ",") {
		for directive := range strings.SplitSeq(group, ";") {
			pair := strings.SplitN(strings.TrimSpace(directive), "=", 2)
			if len(pair) != 2 {
				continue
			}
			if !strings.EqualFold(strings.TrimSpace(pair[0]), key) {
				continue
			}
			return strings.Trim(strings.TrimSpace(pair[1]), "\"")
		}
	}
	return ""
}

func normalizedAbsoluteBaseURL(raw string) string {
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return ""
	}

	parsed, err := url.Parse(trimmed)
	if err != nil || parsed.Scheme == "" || parsed.Host == "" {
		return ""
	}

	parsed.RawQuery = ""
	parsed.Fragment = ""
	return strings.TrimRight(parsed.String(), "/")
}

func forwardedRequestHost(c *fiber.Ctx) string {
	if c == nil {
		return ""
	}
	if host := firstForwardedValue(c.Get("X-Forwarded-Host")); host != "" {
		return host
	}
	if host := forwardedDirective(c.Get("Forwarded"), "host"); host != "" {
		return host
	}
	return ""
}

func forwardedRequestProto(c *fiber.Ctx) string {
	if c == nil {
		return ""
	}
	if proto := firstForwardedValue(c.Get("X-Forwarded-Proto")); proto != "" {
		return strings.ToLower(proto)
	}
	if proto := forwardedDirective(c.Get("Forwarded"), "proto"); proto != "" {
		return strings.ToLower(proto)
	}
	return ""
}

func resolvePublicRequestBaseURL(c *fiber.Ctx, configuredBaseURL string) string {
	if base := normalizedAbsoluteBaseURL(configuredBaseURL); base != "" {
		return base
	}
	if c == nil {
		return ""
	}

	host := forwardedRequestHost(c)
	proto := forwardedRequestProto(c)
	if host != "" && proto != "" {
		return fmt.Sprintf("%s://%s", proto, host)
	}

	base := strings.TrimRight(strings.TrimSpace(c.BaseURL()), "/")
	if base != "" {
		return base
	}

	host = strings.TrimSpace(c.Get("Host"))
	if host == "" {
		host = strings.TrimSpace(c.Hostname())
	}
	proto = strings.ToLower(strings.TrimSpace(c.Protocol()))
	if host == "" || proto == "" {
		return ""
	}
	return fmt.Sprintf("%s://%s", proto, host)
}

func (h *AuthHandler) resolveUserfrontURL(c *fiber.Ctx) string {
	envURL := os.Getenv("USERFRONT_URL")
	if envURL == "" {
		envURL = "http://sso.hmac.kr"
	}

	baseURL := resolvePublicRequestBaseURL(c, "")
	host := strings.TrimSpace(forwardedRequestHost(c))
	if host == "" {
		host = strings.TrimSpace(c.Hostname())
	}

	if host == "" || (host == "localhost" && os.Getenv("APP_ENV") != "dev") {
		return strings.TrimRight(envURL, "/")
	}

	if baseURL == "" {
		return strings.TrimRight(envURL, "/")
	}

	envParsed, envErr := url.Parse(strings.TrimRight(envURL, "/"))
	baseParsed, baseErr := url.Parse(strings.TrimRight(baseURL, "/"))
	if envErr == nil && baseErr == nil &&
		strings.EqualFold(envParsed.Hostname(), baseParsed.Hostname()) &&
		envParsed.Scheme == "https" && baseParsed.Scheme == "http" {
		return strings.TrimRight(envURL, "/")
	}
	if os.Getenv("APP_ENV") == "dev" &&
		envErr == nil && baseErr == nil &&
		strings.EqualFold(envParsed.Hostname(), baseParsed.Hostname()) &&
		(envParsed.Hostname() == "localhost" || envParsed.Hostname() == "127.0.0.1") &&
		envParsed.Port() != "" && baseParsed.Port() == "" {
		return strings.TrimRight(envURL, "/")
	}

	return baseURL
}

func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error {
	tenantID, _ := c.Locals("tenant_id").(string)
	if tenantID == "" {
		return c.JSON(fiber.Map{
			"isCentral": true,
		})
	}

	tenant, err := h.TenantService.GetTenant(c.Context(), tenantID)
	if err != nil {
		return errorJSON(c, fiber.StatusNotFound, "Tenant not found")
	}

	res := fiber.Map{
		"isCentral":   false,
		"id":          tenant.ID,
		"name":        tenant.Name,
		"slug":        tenant.Slug,
		"description": tenant.Description,
		"type":        tenant.Type,
	}

	if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
		res["loginIdField"] = loginIdField
		// Find label in userSchema
		if schema, ok := tenant.Config["userSchema"].([]any); ok {
			for _, field := range schema {
				if f, ok := field.(map[string]any); ok {
					if f["key"] == loginIdField {
						res["loginIdLabel"] = f["label"]
						break
					}
				}
			}
		}
	}

	return c.JSON(res)
}

// normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다.
func normalizePhoneForLoginID(phone string) string {
	return domain.NormalizePhoneNumber(phone)
}

func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string) map[string]any {
	claims := map[string]any{}
	if traits == nil {
		return claims
	}
	if tenantID == "" {
		tenantID = representativeTenantIDFromTraits(traits)
	}
	includeTenantDetails := tenantClaimScopeRequested(scopes)

	scopeSet := map[string]struct{}{}
	for _, scope := range scopes {
		scope = strings.TrimSpace(scope)
		if scope == "" {
			continue
		}
		scopeSet[scope] = struct{}{}
	}

	getString := func(key string) string {
		raw, ok := traits[key]
		if !ok || raw == nil {
			return ""
		}
		switch value := raw.(type) {
		case string:
			return strings.TrimSpace(value)
		default:
			return strings.TrimSpace(fmt.Sprint(value))
		}
	}

	displayName := getString("displayname")
	if displayName == "" {
		displayName = getString("name")
	}
	if displayName != "" {
		claims["name"] = displayName
	}
	primaryEmail := getString("primary_email")
	if primaryEmail == "" {
		primaryEmail = getString("email")
	}
	if primaryEmail != "" {
		claims["email"] = primaryEmail
	}

	if _, ok := scopeSet["profile"]; ok {
		profile := map[string]any{}
		names := map[string]any{}
		for _, key := range []string{
			"name",
			"displayname",
			"preferred_username",
			"given_name",
			"family_name",
			"middle_name",
			"nickname",
		} {
			if value := getString(key); value != "" {
				names[key] = value
			}
		}
		if len(names) > 0 {
			profile["names"] = names
		}

		emails := collectEmailList(traits, primaryEmail)
		if len(emails) > 0 {
			profile["emails"] = emails
		}
		if len(profile) > 0 {
			claims["profile"] = profile
		}

		for _, key := range []string{
			"department",
			"affiliationType",
			"companyCode",
			"displayname",
			"team",
			"grade",
			"familyCompany",
			"taxCode",
			"familyUniqueKey",
			"personal",
		} {
			if raw, ok := traits[key]; ok && raw != nil {
				switch value := raw.(type) {
				case string:
					if strings.TrimSpace(value) != "" {
						claims[key] = strings.TrimSpace(value)
					}
				default:
					claims[key] = value
				}
			}
		}
	}

	if _, ok := scopeSet["phone"]; ok {
		if phone := getString("phone_number"); phone != "" {
			claims["phone_number"] = phone
		}
	}

	// [New] Dynamic Claim Injection for Multi-tenancy
	if tenantID != "" {
		claims["tenant_id"] = tenantID
		if includeTenantDetails {
			// tenant 스코프가 있을 때만 대표소속 namespace metadata를 top-level claim으로 펼칩니다.
			if namespaced, ok := traits[tenantID].(map[string]any); ok {
				maps.Copy(claims, sanitizeTenantClaimMetadata(namespaced))
			}
		}
	}

	// [Update] Pass ALL tenants the user belongs to
	allTenants := map[string]any{}
	joinedTenants := joinedTenantIDsFromTraits(traits, tenantID)

	// Heuristic: if a trait value is a map, it's treated as namespaced metadata for a tenant
	for k, v := range traits {
		if isReservedTenantTraitKey(k) {
			continue
		}
		if m, ok := v.(map[string]any); ok {
			allTenants[k] = sanitizeTenantClaimMetadata(m)
		}
	}

	if len(joinedTenants) > 0 {
		claims["joined_tenants"] = joinedTenants
	}

	if includeTenantDetails && len(allTenants) > 0 {
		claims["tenants"] = allTenants
	}

	return applyGlobalCustomClaims(claims, traits)
}

func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string]any {
	if claims == nil {
		claims = map[string]any{}
	}
	sessionID = strings.TrimSpace(sessionID)
	if sessionID != "" {
		claims["session_id"] = sessionID
		claims["sid"] = sessionID
	}
	return claims
}

func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any {
	claims := buildOidcClaimsFromTraits(traits, scopes, tenantID)
	claims = applyConfiguredIDTokenClaims(claims, client.Metadata)
	return withOidcSessionMetadata(claims, sessionID)
}

func applyGlobalCustomClaims(baseClaims map[string]any, traits map[string]any) map[string]any {
	if baseClaims == nil {
		baseClaims = map[string]any{}
	}
	if traits == nil {
		return baseClaims
	}

	rawClaims, ok := traits["global_custom_claims"]
	if !ok || rawClaims == nil {
		return baseClaims
	}
	customClaims, ok := rawClaims.(map[string]any)
	if !ok {
		return baseClaims
	}

	for key, value := range customClaims {
		key = strings.TrimSpace(key)
		if key == "" || value == nil {
			continue
		}
		if isReservedTopLevelCustomClaimKey(key) {
			continue
		}
		if _, exists := baseClaims[key]; exists {
			continue
		}
		baseClaims[key] = value
	}
	return baseClaims
}

func (h *AuthHandler) withHanmacFamilyTenantClaims(ctx context.Context, claims map[string]any, traits map[string]any, scopes []string) map[string]any {
	if claims == nil {
		claims = map[string]any{}
	}
	if h == nil || h.TenantService == nil {
		return claims
	}

	appointments := tenantClaimAppointmentsFromTraits(traits)
	includeTenantDetails := tenantClaimScopeRequested(scopes)
	tenants, hadTenantClaims := claims["tenants"].(map[string]any)
	if !hadTenantClaims {
		tenants = map[string]any{}
	}
	createdTenantClaims := map[string]bool{}
	if tenantID := tenantClaimString(claims, "tenant_id"); tenantID != "" {
		if _, exists := tenants[tenantID]; !exists {
			tenants[tenantID] = map[string]any{}
			createdTenantClaims[tenantID] = true
		}
	}
	for _, tenantKey := range tenantClaimAppointmentPrimaryKeys(appointments) {
		if _, exists := tenants[tenantKey]; !exists {
			tenants[tenantKey] = map[string]any{}
			createdTenantClaims[tenantKey] = true
		}
	}
	if len(tenants) == 0 {
		return claims
	}

	leadTenantIDs := make([]string, 0)
	joinedTenantIDs := make([]string, 0)

	for tenantKey, rawTenantClaim := range tenants {
		tenantClaim, ok := rawTenantClaim.(map[string]any)
		if !ok {
			continue
		}
		tenantClaim = sanitizeTenantClaimMetadata(tenantClaim)

		tenant, ancestors, inHanmacFamily := h.resolveHanmacFamilyTenantClaimAncestry(ctx, tenantKey)
		if !inHanmacFamily || tenant == nil {
			if createdTenantClaims[tenantKey] {
				delete(tenants, tenantKey)
			}
			continue
		}
		joinedTenantIDs = append(joinedTenantIDs, tenant.ID)
		if !includeTenantDetails {
			if createdTenantClaims[tenantKey] {
				delete(tenants, tenantKey)
			}
			continue
		}

		tenantClaim["id"] = tenant.ID
		tenantClaim["slug"] = tenant.Slug
		tenantClaim["name"] = tenant.Name
		tenantClaim["type"] = tenant.Type
		tenantClaim["ancestors"] = ancestors
		if len(ancestors) > 0 {
			tenantClaim["parentTenantId"] = ancestors[0]["id"]
		} else {
			tenantClaim["parentTenantId"] = nil
		}
		delete(tenantClaim, "parentTenant")

		if appointment := lookupTenantClaimAppointment(appointments, tenantKey, tenant); appointment != nil {
			mergeTenantAppointmentClaim(tenantClaim, appointment)
		}

		if lead, ok := metadataBoolFromMap(tenantClaim, "lead", "isLead", "isOwner", "isManager"); ok {
			tenantClaim["lead"] = lead
			if lead {
				leadTenantIDs = append(leadTenantIDs, tenant.ID)
			}
		}
		if representative, ok := metadataBoolFromMap(tenantClaim, "representative", "isPrimary", "primary"); ok {
			tenantClaim["representative"] = representative
			tenantClaim["isPrimary"] = representative
		}

		tenants[tenantKey] = tenantClaim
	}

	if len(leadTenantIDs) > 0 {
		claims["lead_tenants"] = uniqueSortedStrings(leadTenantIDs)
	}
	if len(joinedTenantIDs) > 0 {
		claims["joined_tenants"] = mergeClaimStringList(claims["joined_tenants"], joinedTenantIDs)
	}
	if !includeTenantDetails {
		if !hadTenantClaims {
			delete(claims, "tenants")
		}
		delete(claims, "lead_tenants")
		return claims
	}
	if len(tenants) > 0 {
		claims["tenants"] = tenants
	} else if !hadTenantClaims {
		delete(claims, "tenants")
	}
	return claims
}

func tenantClaimScopeRequested(scopes []string) bool {
	for _, scope := range scopes {
		if strings.EqualFold(strings.TrimSpace(scope), "tenant") {
			return true
		}
	}
	return false
}

func mergeClaimStringList(raw any, values []string) []string {
	merged := make([]string, 0, len(values))
	switch current := raw.(type) {
	case []string:
		merged = append(merged, current...)
	case []any:
		for _, item := range current {
			if s, ok := item.(string); ok {
				merged = append(merged, s)
			}
		}
	}
	merged = append(merged, values...)
	return uniqueSortedStrings(merged)
}

func tenantClaimAppointmentPrimaryKeys(appointments map[string]map[string]any) []string {
	if len(appointments) == 0 {
		return nil
	}
	seen := map[string]bool{}
	keys := make([]string, 0, len(appointments))
	for _, appointment := range appointments {
		for _, key := range []string{"tenantId", "tenant_id", "tenantSlug", "tenant_slug"} {
			value := tenantClaimString(appointment, key)
			if value == "" || seen[value] {
				continue
			}
			seen[value] = true
			keys = append(keys, value)
			break
		}
	}
	sort.Strings(keys)
	return keys
}

func tenantClaimAppointmentsFromTraits(traits map[string]any) map[string]map[string]any {
	raw := rawAdditionalAppointments(traits)
	if raw == nil {
		return nil
	}
	items, ok := raw.([]any)
	if !ok {
		return nil
	}

	appointments := make(map[string]map[string]any)
	for _, item := range items {
		appointment, ok := item.(map[string]any)
		if !ok {
			continue
		}
		for _, key := range []string{"tenantId", "tenant_id", "tenantSlug", "tenant_slug"} {
			if id := tenantClaimString(appointment, key); id != "" {
				appointments[id] = appointment
			}
		}
	}
	return appointments
}

func rawAdditionalAppointments(traits map[string]any) any {
	if traits == nil {
		return nil
	}
	if raw, ok := traits["additionalAppointments"]; ok {
		return raw
	}
	if metadata, ok := traits["metadata"].(map[string]any); ok {
		return metadata["additionalAppointments"]
	}
	return nil
}

func lookupTenantClaimAppointment(appointments map[string]map[string]any, tenantKey string, tenant *domain.Tenant) map[string]any {
	if len(appointments) == 0 {
		return nil
	}
	for _, key := range []string{tenantKey, tenant.ID, tenant.Slug} {
		if appointment, ok := appointments[key]; ok {
			return appointment
		}
	}
	return nil
}

func mergeTenantAppointmentClaim(tenantClaim map[string]any, appointment map[string]any) {
	for _, key := range []string{"grade", "jobTitle", "job_title", "position"} {
		if value := tenantClaimString(appointment, key); value != "" {
			switch key {
			case "job_title":
				tenantClaim["jobTitle"] = value
			default:
				tenantClaim[key] = value
			}
		}
	}
	if lead, ok := metadataBoolFromMap(appointment, "lead", "isLead", "isOwner", "isManager"); ok {
		tenantClaim["lead"] = lead
	}
	if representative, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok {
		tenantClaim["representative"] = representative
		tenantClaim["isPrimary"] = representative
	}
}

func tenantClaimString(values map[string]any, key string) string {
	raw, ok := values[key]
	if !ok || raw == nil {
		return ""
	}
	switch value := raw.(type) {
	case string:
		return strings.TrimSpace(value)
	default:
		return strings.TrimSpace(fmt.Sprint(value))
	}
}

func (h *AuthHandler) resolveHanmacFamilyTenantClaimAncestry(ctx context.Context, identifier string) (*domain.Tenant, []map[string]any, bool) {
	tenant, err := h.resolveTenantClaimTenant(ctx, identifier)
	if err != nil || tenant == nil {
		return nil, nil, false
	}
	if strings.EqualFold(tenant.Slug, hanmacFamilyTenantSlug) {
		return tenant, []map[string]any{}, true
	}

	ancestors := make([]*domain.Tenant, 0)
	visited := map[string]bool{tenant.ID: true}
	current := tenant
	for current.ParentID != nil && strings.TrimSpace(*current.ParentID) != "" {
		parentID := strings.TrimSpace(*current.ParentID)
		if visited[parentID] {
			return tenant, tenantClaimAncestorSummaries(ancestors), false
		}
		visited[parentID] = true

		parent, err := h.TenantService.GetTenant(ctx, parentID)
		if err != nil || parent == nil {
			return tenant, tenantClaimAncestorSummaries(ancestors), false
		}
		ancestors = append(ancestors, parent)
		if strings.EqualFold(parent.Slug, hanmacFamilyTenantSlug) {
			return tenant, tenantClaimAncestorSummaries(ancestors), true
		}
		current = parent
	}

	return tenant, tenantClaimAncestorSummaries(ancestors), false
}

func (h *AuthHandler) resolveTenantClaimTenant(ctx context.Context, identifier string) (*domain.Tenant, error) {
	identifier = strings.TrimSpace(identifier)
	if identifier == "" {
		return nil, errors.New("tenant identifier is required")
	}
	if tenant, err := h.TenantService.GetTenant(ctx, identifier); err == nil && tenant != nil {
		return tenant, nil
	}
	return h.TenantService.GetTenantBySlug(ctx, identifier)
}

func tenantClaimTenantSummary(tenant *domain.Tenant) map[string]any {
	return map[string]any{
		"id":   tenant.ID,
		"slug": tenant.Slug,
		"name": tenant.Name,
		"type": tenant.Type,
	}
}

func tenantClaimAncestorSummaries(ancestors []*domain.Tenant) []map[string]any {
	if len(ancestors) == 0 {
		return []map[string]any{}
	}
	items := make([]map[string]any, 0, len(ancestors))
	for i, ancestor := range ancestors {
		item := tenantClaimTenantSummary(ancestor)
		if i+1 < len(ancestors) {
			item["parentTenantId"] = ancestors[i+1].ID
		} else {
			item["parentTenantId"] = nil
		}
		items = append(items, item)
	}
	return items
}

func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]any) map[string]any {
	if baseClaims == nil {
		baseClaims = map[string]any{}
	}
	if metadata == nil {
		return baseClaims
	}

	rawClaims, ok := metadata[domain.MetadataIDTokenClaims]
	if !ok || rawClaims == nil {
		return baseClaims
	}

	normalizedClaims, err := normalizeIDTokenClaims(rawClaims)
	if err != nil {
		slog.Warn("failed to normalize configured id token claims", "error", err)
		return baseClaims
	}

	rpClaims, _ := baseClaims["rp_claims"].(map[string]any)
	if rpClaims == nil {
		rpClaims = map[string]any{}
	}

	for _, claim := range normalizedClaims {
		if claim.Namespace == "rp_claims" && isReservedRPClaimKey(claim.Key) {
			continue
		}
		if claim.Nullable && strings.TrimSpace(claim.Value) == "" {
			if claim.Namespace == "rp_claims" {
				rpClaims[claim.Key] = buildRPClaimPayload(nil, claim, nil)
				continue
			}
			if _, exists := baseClaims[claim.Key]; !exists {
				baseClaims[claim.Key] = nil
			}
			continue
		}

		value, err := parseConfiguredClaimValue(claim.Value, claim.ValueType)
		if err != nil {
			slog.Warn("failed to parse configured id token claim", "namespace", claim.Namespace, "key", claim.Key, "error", err)
			continue
		}

		if claim.Namespace == "rp_claims" {
			rpClaims[claim.Key] = buildRPClaimPayload(value, claim, nil)
			continue
		}

		if _, exists := baseClaims[claim.Key]; exists {
			continue
		}
		baseClaims[claim.Key] = value
	}

	if len(rpClaims) > 0 {
		baseClaims["rp_claims"] = rpClaims
	}
	return baseClaims
}

func (h *AuthHandler) withRPUserMetadataClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
	if claims == nil {
		claims = map[string]any{}
	}
	if h == nil || h.RPUserMetadataRepo == nil {
		return claims
	}

	clientID := strings.TrimSpace(client.ClientID)
	subject = strings.TrimSpace(subject)
	if clientID == "" || subject == "" {
		return claims
	}

	rpClaimDefinitions := extractRPClaimDefinitions(client.Metadata)
	if len(rpClaimDefinitions) == 0 {
		return claims
	}

	row, err := h.RPUserMetadataRepo.Get(ctx, clientID, subject)
	if err != nil || row == nil || len(row.Metadata) == 0 {
		return claims
	}

	rpClaims, _ := claims["rp_claims"].(map[string]any)
	if rpClaims == nil {
		rpClaims = map[string]any{}
	}

	for _, claim := range rpClaimDefinitions {
		if isReservedRPClaimKey(claim.Key) {
			continue
		}
		raw, ok := row.Metadata[claim.Key]
		if !ok || raw == nil {
			continue
		}
		value, ok := coerceRPUserMetadataClaimValue(raw, claim.ValueType)
		if !ok {
			slog.Warn("failed to coerce rp user metadata claim", "client_id", clientID, "subject", subject, "key", claim.Key, "value_type", claim.ValueType)
			continue
		}
		rpClaims[claim.Key] = buildRPClaimPayload(value, claim, row.Metadata[claim.Key+"_permissions"])
	}

	if len(rpClaims) > 0 {
		claims["rp_claims"] = rpClaims
	}
	return claims
}

func extractRPClaimDefinitions(metadata map[string]any) []normalizedIDTokenClaim {
	if metadata == nil {
		return nil
	}
	rawClaims, ok := metadata[domain.MetadataIDTokenClaims]
	if !ok || rawClaims == nil {
		return nil
	}
	normalizedClaims, err := normalizeIDTokenClaims(rawClaims)
	if err != nil {
		slog.Warn("failed to normalize rp claim definitions", "error", err)
		return nil
	}
	definitions := make([]normalizedIDTokenClaim, 0, len(normalizedClaims))
	seen := make(map[string]struct{}, len(normalizedClaims))
	for _, claim := range normalizedClaims {
		if claim.Namespace != "rp_claims" {
			continue
		}
		if _, exists := seen[claim.Key]; exists {
			continue
		}
		seen[claim.Key] = struct{}{}
		definitions = append(definitions, claim)
	}
	return definitions
}

func isReservedTopLevelCustomClaimKey(key string) bool {
	return strings.HasPrefix(strings.TrimSpace(key), "rp_")
}

func isReservedRPClaimKey(key string) bool {
	key = strings.TrimSpace(key)
	if strings.HasPrefix(key, "rp_") {
		return true
	}
	switch key {
	case "", "tenant_id", "tenants", "joined_tenants", "lead_tenants":
		return true
	default:
		return false
	}
}

func isReservedTenantTraitKey(key string) bool {
	key = strings.TrimSpace(key)
	if strings.HasPrefix(key, "rp_") {
		return true
	}
	switch key {
	case "metadata",
		"global_custom_claims",
		"global_custom_claim_types",
		"global_custom_claim_permissions":
		return true
	default:
		return false
	}
}

func isRPClaimRelatedTenantMetadataKey(key string) bool {
	return strings.HasPrefix(strings.TrimSpace(key), "rp_")
}

func sanitizeTenantClaimMetadata(raw map[string]any) map[string]any {
	cleaned := make(map[string]any, len(raw))
	for key, value := range raw {
		if isRPClaimRelatedTenantMetadataKey(key) {
			continue
		}
		cleaned[key] = sanitizeTenantClaimValue(value)
	}
	return cleaned
}

func sanitizeTenantClaimValue(value any) any {
	switch typed := value.(type) {
	case map[string]any:
		return sanitizeTenantClaimMetadata(typed)
	case []any:
		items := make([]any, 0, len(typed))
		for _, item := range typed {
			items = append(items, sanitizeTenantClaimValue(item))
		}
		return items
	default:
		return value
	}
}

func buildRPClaimPayload(value any, claim normalizedIDTokenClaim, rawPermission any) map[string]any {
	readPermission := normalizeCustomClaimPermission(claim.ReadPermission)
	writePermission := normalizeCustomClaimPermission(claim.WritePermission)

	if permissions, ok := rawPermission.(map[string]any); ok {
		if rawRead := readInterfaceString(permissions["readPermission"], ""); rawRead != "" {
			readPermission = normalizeCustomClaimPermission(rawRead)
		}
		if rawWrite := readInterfaceString(permissions["writePermission"], ""); rawWrite != "" {
			writePermission = normalizeCustomClaimPermission(rawWrite)
		}
	}
	if writePermission == "user_and_admin" {
		readPermission = "user_and_admin"
	}

	return map[string]any{
		"value":           value,
		"readPermission":  readPermission,
		"writePermission": writePermission,
	}
}

func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
	switch value := raw.(type) {
	case string:
		if strings.TrimSpace(value) == "" {
			return nil, false
		}
		parsed, err := parseConfiguredClaimValue(value, valueType)
		return parsed, err == nil
	case []any:
		if valueType == "array" {
			return value, true
		}
	case []string:
		if valueType == "array" {
			items := make([]any, 0, len(value))
			for _, item := range value {
				items = append(items, item)
			}
			return items, true
		}
	case map[string]any:
		if valueType == "object" {
			return value, true
		}
	case bool:
		if valueType == "boolean" {
			return value, true
		}
	case float64:
		if valueType == "date" || valueType == "datetime" {
			if value == math.Trunc(value) {
				return value, true
			}
			return nil, false
		}
		if valueType == "float" {
			return value, true
		}
		if valueType == "number" && value == math.Trunc(value) {
			return value, true
		}
	case float32:
		floatValue := float64(value)
		if valueType == "date" || valueType == "datetime" {
			if floatValue == math.Trunc(floatValue) {
				return floatValue, true
			}
			return nil, false
		}
		if valueType == "float" {
			return floatValue, true
		}
		if valueType == "number" && floatValue == math.Trunc(floatValue) {
			return floatValue, true
		}
	case int:
		if valueType == "date" || valueType == "datetime" {
			return float64(value), true
		}
		if valueType == "number" {
			return float64(value), true
		}
		if valueType == "float" {
			return float64(value), true
		}
	case int64:
		if valueType == "date" || valueType == "datetime" {
			return float64(value), true
		}
		if valueType == "number" {
			return float64(value), true
		}
		if valueType == "float" {
			return float64(value), true
		}
	case json.Number:
		if valueType == "date" || valueType == "datetime" {
			parsed, err := value.Int64()
			return float64(parsed), err == nil
		}
		if valueType == "number" {
			parsed, err := value.Int64()
			return float64(parsed), err == nil
		}
		if valueType == "float" {
			parsed, err := value.Float64()
			return parsed, err == nil
		}
	}

	parsed, err := parseConfiguredClaimValue(fmt.Sprint(raw), valueType)
	return parsed, err == nil
}

func collectEmailList(traits map[string]any, primaryEmail string) []string {
	emails := make([]string, 0)
	seen := make(map[string]struct{})
	add := func(value string) {
		value = strings.TrimSpace(value)
		if value == "" {
			return
		}
		if _, ok := seen[value]; ok {
			return
		}
		seen[value] = struct{}{}
		emails = append(emails, value)
	}

	add(primaryEmail)
	for _, key := range []string{"email", "primary_email"} {
		if raw, ok := traits[key]; ok {
			if value, ok := raw.(string); ok {
				add(value)
			}
		}
	}

	if raw, ok := traits["emails"]; ok {
		switch value := raw.(type) {
		case []string:
			for _, email := range value {
				add(email)
			}
		case []any:
			for _, email := range value {
				add(fmt.Sprint(email))
			}
		}
	}

	if raw, ok := traits["sub_email"]; ok {
		switch value := raw.(type) {
		case []string:
			for _, email := range value {
				add(email)
			}
		case []any:
			for _, email := range value {
				add(fmt.Sprint(email))
			}
		}
	}

	if raw, ok := traits["additional_emails"]; ok {
		switch value := raw.(type) {
		case []string:
			for _, email := range value {
				add(email)
			}
		case []any:
			for _, email := range value {
				add(fmt.Sprint(email))
			}
		}
	}

	return emails
}

func buildIdentityLookupCandidates(loginID string) []string {
	seen := make(map[string]struct{})
	candidates := make([]string, 0, 3)
	add := func(value string) {
		candidate := strings.TrimSpace(value)
		if candidate == "" {
			return
		}
		if _, ok := seen[candidate]; ok {
			return
		}
		seen[candidate] = struct{}{}
		candidates = append(candidates, candidate)
	}

	normalized := strings.TrimSpace(loginID)
	add(normalized)
	if normalized != "" {
		add(strings.ToLower(normalized))
	}
	if normalized != "" && !strings.Contains(normalized, "@") {
		add(normalizePhoneForLoginID(normalized))
	}

	return candidates
}

func (h *AuthHandler) resolveKratosIdentityID(ctx context.Context, identifiers ...string) (string, error) {
	if h.KratosAdmin == nil {
		return "", fmt.Errorf("kratos admin unavailable")
	}
	for _, identifier := range identifiers {
		candidate := strings.TrimSpace(identifier)
		if candidate == "" {
			continue
		}
		identityID, err := h.KratosAdmin.FindIdentityIDByIdentifier(ctx, candidate)
		if err == nil && identityID != "" {
			return identityID, nil
		}
	}
	return "", fmt.Errorf("kratos identity not found")
}

func (h *AuthHandler) resolveKratosIdentityIDFromLoginID(ctx context.Context, loginID string) (string, error) {
	candidates := buildIdentityLookupCandidates(loginID)
	return h.resolveKratosIdentityID(ctx, candidates...)
}

func (h *AuthHandler) getSignupState(key string) (*signupState, error) {
	val, err := h.RedisService.Get(key)
	if err != nil || val == "" {
		return nil, err
	}
	var state signupState
	if err := json.Unmarshal([]byte(val), &state); err != nil {
		return nil, err
	}
	return &state, nil
}

func (h *AuthHandler) saveSignupState(key string, state *signupState, ttl time.Duration) error {
	data, err := json.Marshal(state)
	if err != nil {
		return err
	}
	return h.RedisService.Set(key, string(data), ttl)
}

// resolvePasswordPolicy는 IDP 정책을 우선 사용하고, 없으면 기본 정책을 반환합니다.
func (h *AuthHandler) resolvePasswordPolicy() *domain.PasswordPolicy {
	if h.IdpProvider != nil {
		policy, err := h.IdpProvider.GetPasswordPolicy()
		if err == nil && policy != nil {
			return policy
		}
	}
	return &domain.PasswordPolicy{
		MinLength:         12,
		Lowercase:         true,
		Uppercase:         false,
		Number:            true,
		NonAlphanumeric:   true,
		MinCharacterTypes: 0,
	}
}

// validatePasswordWithPolicy는 정책 기준으로 비밀번호를 검증합니다.
func validatePasswordWithPolicy(policy *domain.PasswordPolicy, password string) error {
	return utils.ValidatePasswordWithPolicy(policy, password)
}

// GetPasswordPolicy는 IDP 기준 비밀번호 정책을 제공합니다.
func (h *AuthHandler) GetPasswordPolicy(c *fiber.Ctx) error {
	policy := h.resolvePasswordPolicy()

	return c.JSON(fiber.Map{
		"minLength":         policy.MinLength,
		"lowercase":         policy.Lowercase,
		"uppercase":         policy.Uppercase,
		"number":            policy.Number,
		"nonAlphanumeric":   policy.NonAlphanumeric,
		"minCharacterTypes": policy.MinCharacterTypes,
	})
}

// SendSms sends a verification code via SMS. (Restored for completeness)
func (h *AuthHandler) SendSms(c *fiber.Ctx) error {
	var req domain.SmsRequest
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
	}

	slog.Info("[SMS] Sending code", "phoneNumber", req.PhoneNumber)
	sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "")
	rand.Seed(time.Now().UnixNano())
	code := fmt.Sprintf("%06d", rand.Intn(1000000))
	content := fmt.Sprintf("[Baron 로그인] 인증번호: %s", code)

	h.RedisService.StoreVerificationCode(sanitizedPhone, code)
	if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS")
	}

	return c.JSON(fiber.Map{"message": "SMS sent successfully"})
}

// VerifySms verifies the provided SMS code. (Restored)
func (h *AuthHandler) VerifySms(c *fiber.Ctx) error {
	var req domain.SmsVerifyRequest
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
	}

	sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "")
	storedCode, _ := h.RedisService.GetVerificationCode(sanitizedPhone)

	if storedCode == "" || storedCode != req.Code {
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid or expired code")
	}

	h.RedisService.DeleteVerificationCode(sanitizedPhone)

	if h.IdpProvider == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "Authentication service not configured")
	}

	loginID := normalizePhoneForLoginID(req.PhoneNumber)
	authInfo, err := h.IdpProvider.IssueSession(loginID)
	if err != nil {
		if errors.Is(err, domain.ErrNotSupported) {
			return errorJSON(c, fiber.StatusNotImplemented, "Login method not supported")
		}
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session")
	}
	if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session")
	}
	c.Locals("login_id", loginID)
	setSessionIDLocal(c, authInfo.SessionToken)

	return c.JSON(fiber.Map{
		"token":   authInfo.SessionToken.JWT,
		"message": "Login successful",
	})
}

// InitEnchantedLink - Custom Implementation (Restored)
func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
	var req domain.EnchantedLinkInitRequest
	if err := c.BodyParser(&req); err != nil {
		slog.Error("[Enchanted] Body parse error", "error", err)
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
	}

	loginID := strings.ReplaceAll(req.LoginID, "-", "")
	loginID = strings.ReplaceAll(loginID, " ", "")
	lookupLoginID := loginID
	if !strings.Contains(loginID, "@") {
		lookupLoginID = normalizePhoneForLoginID(loginID)
	}

	// 사용자 존재 여부 확인
	if h.IdpProvider == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
	}
	exists, err := h.IdpProvider.UserExists(lookupLoginID)
	if err != nil {
		slog.Warn("[Enchanted] IDP user lookup failed", "loginID", loginID, "error", err)
		return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
	}
	if !exists {
		slog.Warn("[Enchanted] User not found", "loginID", loginID)
		return errorJSON(c, fiber.StatusNotFound, "User not registered")
	}

	userfrontURL := h.resolveUserfrontURL(c)
	if req.URI != "" {
		userfrontURL = req.URI
	}

	drySend := (req.DrySend || req.DryRun) && service.IsDryRunAllowed()
	if (req.DrySend || req.DryRun) && !service.IsDryRunAllowed() {
		slog.Warn("[Enchanted] DrySend ignored in production", "loginID", loginID)
	}
	if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" {
		keyLoginID := lookupLoginID
		if init.LoginID != "" {
			keyLoginID = init.LoginID
		}
		if !strings.Contains(loginID, "@") && req.CodeOnly {
			_ = h.RedisService.Set(prefixLoginCodeSmsOnly+keyLoginID, "1", loginCodeExpiration)
		} else {
			_ = h.RedisService.Delete(prefixLoginCodeSmsOnly + keyLoginID)
		}
		if init.FlowID != "" {
			_ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
		}
		pendingRef := GenerateSecureToken(3)
		sessionData, _ := json.Marshal(map[string]string{
			"status":  statusPending,
			"loginId": keyLoginID,
		})
		h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
		intent := loginFlowLink
		if req.CodeOnly {
			intent = loginFlowCode
		}
		h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowCode, loginCodeExpiration)
		_ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration)
		if drySend {
			_ = h.RedisService.Set(prefixDrySend+keyLoginID, pendingRef, loginCodeExpiration)
			if keyLoginID != lookupLoginID {
				_ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, loginCodeExpiration)
			}
		}
		if !strings.Contains(loginID, "@") && keyLoginID != lookupLoginID {
			_ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration)
			_ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration)
		}
		expiresIn := 0
		if !init.ExpiresAt.IsZero() {
			expiresIn = int(time.Until(init.ExpiresAt).Seconds())
		}
		if expiresIn <= 0 {
			expiresIn = int(loginCodeExpiration.Seconds())
		}
		return c.JSON(fiber.Map{
			"linkId":      "Sent",
			"pendingRef":  pendingRef,
			"maskedEmail": loginID,
			"mode":        init.Mode,
			"provider":    h.IdpProvider.Name(),
			"expiresIn":   expiresIn,
			"interval":    int(minPollInterval.Seconds()),
			"resendAfter": int(linkResendCooldown.Seconds()),
		})
	} else if err != nil && !errors.Is(err, domain.ErrNotSupported) {
		slog.Error("[Enchanted] Link login init failed", "provider", h.IdpProvider.Name(), "error", err)
		return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
	}

	// [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정
	userCode := GenerateUserCode()
	token := GenerateSecureToken(3)
	pendingRef := GenerateSecureToken(3)

	slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)

	// Store in Redis
	sessionData, _ := json.Marshal(map[string]string{
		"status":  statusPending,
		"loginId": lookupLoginID,
	})
	h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
	h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration)
	if drySend {
		_ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, defaultExpiration)
	}
	intent := loginFlowLink
	if req.CodeOnly {
		intent = loginFlowCode
	}
	h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowLink, defaultExpiration)

	// Generate Link
	slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL)
	link := fmt.Sprintf("%s/verify/%s", userfrontURL, token)

	// Route based on LoginID type
	if strings.Contains(loginID, "@") {
		// Send Email
		if !drySend && h.EmailService == nil {
			slog.Error("[Enchanted] Email Service not configured")
			return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured")
		}

		subject := "[Baron 로그인] 링크"
		body := fmt.Sprintf(`
			<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
				<h2 style="color: #1A1F2C;">Baron 로그인</h2>
				<p>안녕하세요,</p>
				<p>아래 버튼을 클릭하여 로그인을 완료해 주세요. 이 링크는 5분 동안 유효합니다.</p>
				<div style="margin: 30px 0;">
					<a href="%s" style="background-color: #1A1F2C; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">로그인 완료하기</a>
				</div>
				<p style="font-size: 14px;">간편 코드: <strong>%s</strong></p>
				<p style="font-size: 12px; color: #888;">만약 본인이 요청하지 않았다면 이 메일을 무시하셔도 됩니다.</p>
			</div>
		`, link, userCode)

		if drySend {
			slog.Info("[Enchanted][DrySend] Email send skipped", "loginID", loginID, "link", link, "userCode", userCode)
		} else {
			slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID)
			if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
				slog.Error("[Enchanted] Email Failed", "error", err)
				return errorJSON(c, fiber.StatusInternalServerError, "Failed to send Email")
			}
		}
	} else {
		// Send SMS
		content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 코드: %s", link, userCode)
		if drySend {
			slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", loginID, "content", content)
		} else {
			slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
			if err := h.SmsService.SendSms(loginID, content); err != nil {
				slog.Error("[Enchanted] SMS Failed", "error", err)
				return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS")
			}
		}
	}

	return c.JSON(fiber.Map{
		"linkId":      "Sent",
		"pendingRef":  pendingRef,
		"maskedEmail": loginID,
		"expiresIn":   int(defaultExpiration.Seconds()),
		"interval":    int(minPollInterval.Seconds()),
		"resendAfter": int(linkResendCooldown.Seconds()),
		"userCode":    userCode,
	})
}

// PollEnchantedLink - Check status (Restored)
func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
	var req domain.EnchantedLinkPollRequest
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
	}

	pollKey := prefixPollMeta + "enchanted:" + req.PendingRef
	if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown {
		return c.JSON(fiber.Map{
			"error":    "slow_down",
			"code":     "slow_down",
			"interval": interval,
		})
	}

	val, err := h.RedisService.Get(prefixSession + req.PendingRef)
	if err != nil || val == "" {
		return c.JSON(fiber.Map{
			"error": "expired_token",
			"code":  "expired_token",
		})
	}

	var data map[string]string
	json.Unmarshal([]byte(val), &data)

	if data["status"] == statusSuccess {
		if blocked, err := h.rejectSessionSubjectOverwrite(c, data["subject"], data["loginId"]); blocked || err != nil {
			return err
		}
		slog.Info("[Poll] Success", "pendingRef", req.PendingRef)
		return c.JSON(fiber.Map{
			"sessionJwt": data["jwt"],
			"status":     "ok",
		})
	}

	if data["status"] == "approved" {
		_, authInfo, err := h.completeApprovedLinkLogin(c, req.PendingRef)
		if err != nil {
			return err
		}
		if authInfo == nil {
			return nil
		}

		return c.JSON(fiber.Map{
			"sessionJwt": authInfo.SessionToken.JWT,
			"status":     "ok",
		})
	}

	return c.JSON(fiber.Map{
		"error":    "authorization_pending",
		"code":     "authorization_pending",
		"interval": int(minPollInterval.Seconds()),
	})
}

// VerifyMagicLink - Validate token and login (Restored)
func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
	var req domain.MagicLinkVerifyRequest
	if err := c.BodyParser(&req); err != nil {
		slog.Error("[Verify] Body parse error", "error", err)
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
	}

	slog.Info("[Verify] Attempting to verify token", "token", req.Token)

	tokenKey := prefixToken + req.Token
	val, err := h.RedisService.Get(tokenKey)
	if err != nil || val == "" {
		slog.Warn("[Verify] Token not found or expired in Redis", "token", req.Token)
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid or expired token")
	}

	var tokenData map[string]string
	json.Unmarshal([]byte(val), &tokenData)
	pendingRef := tokenData["pendingRef"]
	loginID := tokenData["loginId"]

	slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef)

	if req.VerifyOnly {
		c.Locals("auth_timeline_skip", true)
		if pendingRef == "" || loginID == "" {
			slog.Warn("[Verify] Missing pendingRef/loginID for verify-only", "token", req.Token)
			return errorJSON(c, fiber.StatusBadRequest, "Invalid session reference")
		}

		h.storeLoginApproverMeta(pendingRef, c, defaultExpiration)

		// 승인 전용: 세션 발급 없이 승인 상태만 기록
		sessionData, _ := json.Marshal(map[string]string{
			"status":  "approved",
			"loginId": loginID,
		})
		h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)

		return c.JSON(fiber.Map{
			"status":     "approved",
			"pendingRef": pendingRef,
			"message":    "Login approved",
		})
	}

	if h.IdpProvider == nil {
		slog.Error("[Verify] IDP Provider is nil")
		return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured")
	}

	authInfo, err := h.IdpProvider.IssueSession(loginID)
	if err != nil {
		if errors.Is(err, domain.ErrNotSupported) {
			slog.Warn("[Verify] IDP session issue not supported", "provider", h.IdpProvider.Name())
			return errorJSON(c, fiber.StatusNotImplemented, "Login method not supported")
		}
		slog.Error("[Verify] IDP session issue failed", "error", err)
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session")
	}
	if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
		slog.Error("[Verify] IDP returned empty session")
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session")
	}
	if blocked, err := h.rejectSessionSubjectOverwrite(c, authInfo.Subject, loginID); blocked || err != nil {
		return err
	}
	sessionToken := authInfo.SessionToken.JWT
	c.Locals("login_id", loginID)
	setSessionIDLocal(c, authInfo.SessionToken)

	// Write Kratos session cookies to the response
	h.writeAuthCookies(c, authInfo.SetCookies)

	sessionID := extractSessionIDFromToken(authInfo.SessionToken)

	slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef)
	sessionData := map[string]string{
		"status": statusSuccess,
		"jwt":    sessionToken,
	}
	if sessionID != "" {
		sessionData["session_id"] = sessionID
	}
	if authInfo.Subject != "" {
		sessionData["subject"] = authInfo.Subject
	}
	if loginID != "" {
		sessionData["loginId"] = loginID
	}
	sessionDataJSON, _ := json.Marshal(sessionData)
	h.RedisService.Set(prefixSession+pendingRef, string(sessionDataJSON), defaultExpiration)

	h.writeLinkAuditLog(loginID, pendingRef, authInfo.SessionToken, c)

	return c.JSON(fiber.Map{
		"token":   sessionToken,
		"message": "Login successful",
	})
}

// VerifyLoginCode - Verify Kratos login code and issue session.
func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
	var req struct {
		LoginID    string `json:"loginId"`
		Code       string `json:"code"`
		PendingRef string `json:"pendingRef"`
		VerifyOnly bool   `json:"verifyOnly,omitempty"`
	}
	if err := c.BodyParser(&req); err != nil {
		slog.Error("[LoginCode] Body parse error", "error", err)
		return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
	}

	loginID := strings.TrimSpace(req.LoginID)
	loginID = strings.ReplaceAll(loginID, " ", "+")
	if loginID == "" || req.Code == "" {
		return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "loginId and code are required")
	}

	lookupLoginID := loginID
	if !strings.Contains(loginID, "@") {
		lookupLoginID = normalizePhoneForLoginID(loginID)
	}
	smsLookupLoginID := ""
	if !strings.Contains(loginID, "@") {
		smsLookupLoginID = lookupLoginID
		if mapped, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + smsLookupLoginID); mapped != "" {
			lookupLoginID = mapped
		}
	}

	if h.IdpProvider == nil {
		return errorJSONCode(c, fiber.StatusServiceUnavailable, "service_unavailable", "Identity provider unavailable")
	}

	flowID, err := h.RedisService.Get(prefixLoginCode + lookupLoginID)
	if err != nil || flowID == "" {
		return errorJSONCode(c, fiber.StatusNotFound, "not_found", "Login flow expired")
	}

	if req.VerifyOnly {
		c.Locals("auth_timeline_skip", true)
		effectiveLoginID := lookupLoginID
		pendingRef := strings.TrimSpace(req.PendingRef)
		storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID)
		if pendingRef == "" {
			pendingRef = storedRef
		} else if storedRef != "" && pendingRef != storedRef {
			return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference")
		}
		if pendingRef == "" {
			return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference")
		}
		expectedCode, _ := h.RedisService.Get(prefixLoginCodeValue + pendingRef)
		expectedCode = normalizeLoginCode(expectedCode)
		inputCode := normalizeLoginCode(req.Code)
		if expectedCode == "" || inputCode == "" || inputCode != expectedCode {
			return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code")
		}
		h.storeLoginApproverMeta(pendingRef, c, loginCodeExpiration)
		sessionData, _ := json.Marshal(map[string]string{
			"status":  "approved",
			"loginId": effectiveLoginID,
		})
		h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
		return c.JSON(fiber.Map{
			"status":     "approved",
			"pendingRef": pendingRef,
			"message":    "Login approved",
		})
	}

	authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code)
	if err != nil {
		if errors.Is(err, domain.ErrNotSupported) {
			return errorJSONCode(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported")
		}
		slog.Error("[LoginCode] Verify failed", "loginID", loginID, "error", err)
		return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code")
	}
	if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
		return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to issue session")
	}
	subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), lookupLoginID)
	if resolveErr != nil || subject == "" {
		slog.Error("[LoginCode] Failed to resolve kratos identity", "loginID", lookupLoginID, "error", resolveErr)
		return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity")
	}
	authInfo.Subject = subject
	if blocked, err := h.rejectSessionSubjectOverwrite(c, subject, lookupLoginID); blocked || err != nil {
		return err
	}
	c.Locals("login_id", lookupLoginID)
	setSessionIDLocal(c, authInfo.SessionToken)

	// Write Kratos session cookies to the response
	h.writeAuthCookies(c, authInfo.SetCookies)

	h.RedisService.Delete(prefixLoginCode + lookupLoginID)
	h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
	if smsLookupLoginID != "" {
		h.RedisService.Delete(prefixLoginCodeSmsLookup + smsLookupLoginID)
	}

	pendingRef := strings.TrimSpace(req.PendingRef)
	if pendingRef == "" {
		storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID)
		pendingRef = storedRef
	}
	if pendingRef != "" {
		sessionData, _ := json.Marshal(map[string]string{
			"status":  statusSuccess,
			"jwt":     authInfo.SessionToken.JWT,
			"subject": subject,
			"loginId": lookupLoginID,
		})
		h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
		h.RedisService.Delete(prefixLoginCodePending + lookupLoginID)
		h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
		if smsLookupLoginID != "" {
			h.RedisService.Delete(prefixLoginCodeSmsLookup + smsLookupLoginID)
		}
		return c.JSON(fiber.Map{
			"status":     "approved",
			"pendingRef": pendingRef,
			"provider":   h.IdpProvider.Name(),
			"subject":    subject,
			"message":    "Login approved",
		})
	}

	return c.JSON(fiber.Map{
		"token":      authInfo.SessionToken.JWT,
		"sessionJwt": authInfo.SessionToken.JWT,
		"provider":   h.IdpProvider.Name(),
		"subject":    subject,
		"message":    "Login successful",
	})
}

// VerifyLoginShortCode - Verify short code (2 letters + 6 digits) and issue/approve session.
func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
	var req struct {
		ShortCode  string `json:"shortCode"`
		VerifyOnly bool   `json:"verifyOnly,omitempty"`
	}
	if err := c.BodyParser(&req); err != nil {
		slog.Error("[LoginShortCode] Body parse error", "error", err)
		return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
	}

	shortCode := strings.ToUpper(strings.TrimSpace(req.ShortCode))
	if shortCode == "" {
		return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "shortCode is required")
	}

	val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode)
	if val == "" {
		return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_or_expired_code", "Invalid or expired code")
	}

	var payload shortLoginCodePayload
	if err := json.Unmarshal([]byte(val), &payload); err != nil {
		return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Invalid code payload")
	}
	if payload.LoginID == "" || payload.Code == "" {
		return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_or_expired_code", "Invalid or expired code")
	}

	if req.VerifyOnly {
		c.Locals("auth_timeline_skip", true)
		if payload.PendingRef == "" {
			return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference")
		}
		normalizedCode := normalizeLoginCode(payload.Code)
		if normalizedCode != "" {
			h.RedisService.Set(prefixLoginCodeValue+payload.PendingRef, normalizedCode, loginCodeExpiration)
		}
		h.storeLoginApproverMeta(payload.PendingRef, c, loginCodeExpiration)
		sessionData, _ := json.Marshal(map[string]string{
			"status":  "approved",
			"loginId": payload.LoginID,
		})
		h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration)
		h.RedisService.Delete(prefixLoginCodeShort + shortCode)
		return c.JSON(fiber.Map{
			"status":     "approved",
			"pendingRef": payload.PendingRef,
			"message":    "Login approved",
		})
	}

	if h.IdpProvider == nil {
		return errorJSONCode(c, fiber.StatusServiceUnavailable, "service_unavailable", "Identity provider unavailable")
	}

	flowID, err := h.RedisService.Get(prefixLoginCode + payload.LoginID)
	if err != nil || flowID == "" {
		return errorJSONCode(c, fiber.StatusNotFound, "not_found", "Login flow expired")
	}

	authInfo, err := h.IdpProvider.VerifyLoginCode(payload.LoginID, flowID, payload.Code)
	if err != nil {
		if errors.Is(err, domain.ErrNotSupported) {
			return errorJSONCode(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported")
		}
		slog.Error("[LoginShortCode] Verify failed", "loginID", payload.LoginID, "error", err)
		return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code")
	}
	if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
		return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to issue session")
	}
	subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), payload.LoginID)
	if resolveErr != nil || subject == "" {
		slog.Error("[LoginShortCode] Failed to resolve kratos identity", "loginID", payload.LoginID, "error", resolveErr)
		return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity")
	}
	authInfo.Subject = subject
	if blocked, err := h.rejectSessionSubjectOverwrite(c, subject, payload.LoginID); blocked || err != nil {
		return err
	}
	c.Locals("login_id", payload.LoginID)
	setSessionIDLocal(c, authInfo.SessionToken)

	h.RedisService.Delete(prefixLoginCode + payload.LoginID)
	h.RedisService.Delete(prefixLoginCodeShort + shortCode)
	h.RedisService.Delete(prefixLoginCodeSmsTarget + payload.LoginID)
	h.RedisService.Delete(prefixLoginCodeSmsLookup + payload.LoginID)

	if payload.PendingRef != "" {
		sessionData, _ := json.Marshal(map[string]string{
			"status":  statusSuccess,
			"jwt":     authInfo.SessionToken.JWT,
			"subject": subject,
			"loginId": payload.LoginID,
		})
		h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration)
		h.RedisService.Delete(prefixLoginCodePending + payload.LoginID)
		return c.JSON(fiber.Map{
			"status":     "approved",
			"pendingRef": payload.PendingRef,
			"token":      authInfo.SessionToken.JWT,
			"sessionJwt": authInfo.SessionToken.JWT,
			"provider":   h.IdpProvider.Name(),
			"subject":    subject,
			"message":    "Login approved",
		})
	}

	return c.JSON(fiber.Map{
		"token":      authInfo.SessionToken.JWT,
		"sessionJwt": authInfo.SessionToken.JWT,
		"provider":   h.IdpProvider.Name(),
		"subject":    subject,
		"message":    "Login successful",
	})
}

// PasswordLogin - Authenticate a user with login ID and password.
func logOidcRedirectSummary(source, redirectTo string) {
	parsed, err := url.Parse(redirectTo)
	if err != nil {
		slog.Warn(
			"OIDC redirect parse failed",
			"source", source,
			"redirectToLength", len(redirectTo),
			"error", err,
		)
		return
	}

	query := parsed.Query()
	slog.Info(
		"OIDC redirect summary",
		"source", source,
		"redirectToLength", len(redirectTo),
		"redirectToHost", parsed.Host,
		"redirectToPath", parsed.Path,
		"redirectHasLoginVerifier", query.Has("login_verifier"),
		"redirectHasRedirectURI", query.Has("redirect_uri"),
	)
}

func (h *AuthHandler) authenticatePasswordLogin(ctx context.Context, loginID, password string) (*domain.AuthInfo, error) {
	if h.IdpProvider == nil {
		return nil, fmt.Errorf("authentication service not configured")
	}

	authInfo, err := h.IdpProvider.SignIn(loginID, password)
	if err != nil {
		return nil, err
	}

	subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(ctx, loginID)
	if resolveErr != nil || subject == "" {
		slog.Error("Failed to resolve kratos identity after login", "loginID", loginID, "error", resolveErr)
		return nil, fmt.Errorf("failed to resolve user identity")
	}
	if err := h.ensureUserActivityAllowed(ctx, subject); err != nil {
		return nil, err
	}

	authInfo.Subject = subject
	return authInfo, nil
}

func passwordLoginErrorSpec(err error) (int, string, string) {
	if err == nil {
		return fiber.StatusOK, "", ""
	}
	if errors.Is(err, domain.ErrNotSupported) {
		return fiber.StatusNotImplemented, "not_supported", "Login method not supported"
	}
	if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") {
		return fiber.StatusNotFound, "not_found", "User not registered"
	}
	if strings.Contains(err.Error(), "failed to resolve user identity") {
		return fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity"
	}
	if strings.Contains(err.Error(), "cannot perform Baron activity") {
		return fiber.StatusForbidden, "user_status_forbidden", "This user status cannot sign in"
	}
	return fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials"
}

func (h *AuthHandler) ensureUserActivityAllowed(ctx context.Context, userID string) error {
	if h == nil || h.UserRepo == nil || strings.TrimSpace(userID) == "" {
		return nil
	}
	user, err := h.UserRepo.FindByID(ctx, userID)
	if err != nil || user == nil {
		return nil
	}
	if !domain.IsBaronActivityAllowedStatus(user.Status) {
		return fmt.Errorf("user status %s cannot perform Baron activity", domain.NormalizeUserStatus(user.Status))
	}
	return nil
}

func isUserActivityForbiddenError(err error) bool {
	return err != nil && strings.Contains(err.Error(), "cannot perform Baron activity")
}

func headlessAssertionAudiences(c *fiber.Ctx) []string {
	if c == nil {
		return nil
	}

	path := strings.TrimSpace(c.Path())
	if path == "" {
		return nil
	}

	base := resolvePublicRequestBaseURL(c, os.Getenv("BACKEND_PUBLIC_URL"))
	if base == "" {
		return []string{path}
	}

	return []string{base + path, path}
}

func containsHeadlessAudience(expected []string, actual headlessAssertionAud) bool {
	for _, audience := range actual {
		for _, candidate := range expected {
			if strings.TrimSpace(audience) == strings.TrimSpace(candidate) {
				return true
			}
		}
	}
	return false
}

func joinHeadlessAudiences(values []string) string {
	if len(values) == 0 {
		return ""
	}
	trimmed := make([]string, 0, len(values))
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value == "" {
			continue
		}
		trimmed = append(trimmed, value)
	}
	return strings.Join(trimmed, ", ")
}

func headlessRequestID(c *fiber.Ctx) string {
	if c == nil {
		return ""
	}
	reqID := strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID))
	if reqID != "" {
		return reqID
	}
	return strings.TrimSpace(c.Get(fiber.HeaderXRequestID))
}

func isHeadlessDebugLoggingEnabled() bool {
	return slog.Default().Enabled(context.Background(), slog.LevelDebug)
}

func truncateHeadlessLogValue(value string, limit int) string {
	value = strings.TrimSpace(value)
	if limit <= 0 || len(value) <= limit {
		return value
	}
	return value[:limit]
}

func logHeadlessLoginFailure(c *fiber.Ctx, message string, failure *headlessLoginFailure, clientID, loginChallenge string) {
	if failure == nil {
		return
	}

	args := []any{
		"reason_code", failure.code,
		"client_id", strings.TrimSpace(clientID),
		"path", c.Path(),
	}

	if reqID := headlessRequestID(c); reqID != "" {
		args = append(args, "req_id", reqID)
	}

	if trimmedChallenge := truncateHeadlessLogValue(loginChallenge, 12); trimmedChallenge != "" {
		args = append(args, "login_challenge_prefix", trimmedChallenge)
	}

	if isHeadlessDebugLoggingEnabled() {
		keys := make([]string, 0, len(failure.debugFields))
		for key := range failure.debugFields {
			keys = append(keys, key)
		}
		sort.Strings(keys)
		for _, key := range keys {
			args = append(args, key, failure.debugFields[key])
		}
	}

	level := slog.LevelWarn
	if failure.status >= 500 {
		level = slog.LevelError
	}
	slog.Log(context.Background(), level, message, args...)
}

func logHeadlessLoginSuccess(c *fiber.Ctx, clientID, loginChallenge, redirectTo string) {
	args := []any{
		"client_id", strings.TrimSpace(clientID),
		"path", c.Path(),
		"response_status", fiber.StatusOK,
	}

	if reqID := headlessRequestID(c); reqID != "" {
		args = append(args, "req_id", reqID)
	}

	if trimmedChallenge := truncateHeadlessLogValue(loginChallenge, 12); trimmedChallenge != "" {
		args = append(args, "login_challenge_prefix", trimmedChallenge)
	}

	parsed, err := url.Parse(redirectTo)
	if err != nil {
		args = append(args, "redirect_to_length", len(redirectTo), "redirect_parse_error", err.Error())
		slog.Info("headless password login succeeded", args...)
		return
	}

	query := parsed.Query()
	args = append(
		args,
		"redirect_to_length", len(redirectTo),
		"redirect_to_host", parsed.Host,
		"redirect_to_path", parsed.Path,
		"redirect_has_login_verifier", query.Has("login_verifier"),
		"redirect_has_redirect_uri", query.Has("redirect_uri"),
	)
	slog.Info("headless password login succeeded", args...)
}

func respondHeadlessLoginFailure(c *fiber.Ctx, failure *headlessLoginFailure) error {
	if failure == nil {
		return nil
	}
	return errorJSONCode(c, failure.status, failure.code, failure.safeMessage)
}

func newHeadlessCredentialFailure(status int, code, safeMessage string) *headlessLoginFailure {
	return newHeadlessLoginFailure(
		status,
		code,
		safeMessage,
		"headless password login credential authentication failed",
		nil,
	)
}

func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient, expectedKid string) (*jose.JSONWebKeySet, bool, error) {
	if h.HeadlessJWKS == nil {
		h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.RedisService, nil)
	}
	keySet, _, refreshed, err := h.HeadlessJWKS.EnsureFreshKeySet(ctx, client, expectedKid)
	if err != nil {
		return nil, refreshed, err
	}
	return keySet, refreshed, nil
}

func validateHeadlessClientAssertionClaims(c *fiber.Ctx, claims headlessClientAssertionClaims, clientID string) *headlessLoginFailure {
	now := time.Now().Unix()
	expectedAudiences := headlessAssertionAudiences(c)
	receivedAudiences := []string(claims.Audience)
	debugFields := map[string]any{
		"claim_issuer":            claims.Issuer,
		"claim_subject":           claims.Subject,
		"claim_expires_at":        claims.ExpiresAt,
		"claim_not_before":        claims.NotBefore,
		"claim_issued_at":         claims.IssuedAt,
		"received_audiences":      receivedAudiences,
		"expected_audiences":      expectedAudiences,
		"received_audiences_text": joinHeadlessAudiences(receivedAudiences),
		"expected_audiences_text": joinHeadlessAudiences(expectedAudiences),
	}
	if claims.Issuer != clientID || claims.Subject != clientID {
		return newHeadlessLoginFailure(
			fiber.StatusUnauthorized,
			"invalid_client_assertion_iss_sub",
			"Client assertion issuer or subject mismatch",
			"headless password login client assertion claims mismatch",
			debugFields,
		)
	}
	if claims.ExpiresAt == 0 || claims.ExpiresAt <= now {
		return newHeadlessLoginFailure(
			fiber.StatusUnauthorized,
			"invalid_client_assertion_expired",
			"Client assertion has expired",
			"headless password login client assertion expired",
			debugFields,
		)
	}
	if claims.NotBefore != 0 && claims.NotBefore > now {
		return newHeadlessLoginFailure(
			fiber.StatusUnauthorized,
			"invalid_client_assertion_not_before",
			"Client assertion is not active yet",
			"headless password login client assertion not active yet",
			debugFields,
		)
	}
	if claims.IssuedAt != 0 && claims.IssuedAt > now+60 {
		return newHeadlessLoginFailure(
			fiber.StatusUnauthorized,
			"invalid_client_assertion_iat_future",
			"Client assertion issued-at time is invalid",
			"headless password login client assertion issued in the future",
			debugFields,
		)
	}
	if !containsHeadlessAudience(expectedAudiences, claims.Audience) {
		return newHeadlessLoginFailure(
			fiber.StatusUnauthorized,
			"invalid_client_assertion_audience",
			"Client assertion audience mismatch",
			"headless password login client assertion audience mismatch",
			debugFields,
		)
	}
	return nil
}

func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.HydraClient, clientID, clientAssertion string) *headlessLoginFailure {
	assertion := strings.TrimSpace(clientAssertion)
	if assertion == "" {
		return newHeadlessLoginFailure(
			fiber.StatusBadRequest,
			"bad_request",
			"client_assertion is required",
			"headless password login client assertion missing",
			nil,
		)
	}

	token, err := josejwt.ParseSigned(assertion, []jose.SignatureAlgorithm{
		jose.RS256, jose.RS384, jose.RS512,
		jose.PS256, jose.PS384, jose.PS512,
		jose.ES256, jose.ES384, jose.ES512,
		jose.EdDSA,
	})
	if err != nil {
		return newHeadlessLoginFailure(
			fiber.StatusUnauthorized,
			"invalid_client_assertion_parse",
			"Client assertion format is invalid",
			"headless password login client assertion parse failed",
			nil,
		)
	}

	expectedKid := ""
	if len(token.Headers) > 0 {
		expectedKid = strings.TrimSpace(token.Headers[0].KeyID)
	}

	keySet, refreshed, err := h.loadHeadlessJWKS(c.Context(), client, expectedKid)
	if err != nil {
		slog.Error("failed to load jwks for headless client assertion", "clientID", clientID, "error", err)
		return newHeadlessLoginFailure(
			fiber.StatusUnauthorized,
			"invalid_client_assertion_jwks_load",
			headlessClientAssertionErrorMessage(err),
			"headless password login client assertion jwks load failed",
			map[string]any{
				"received_kid": expectedKid,
			},
		)
	}

	matchingKidPresent := expectedKid != "" && containsHeadlessKeyID(keySet, expectedKid)
	for _, key := range keySet.Keys {
		if expectedKid != "" && key.KeyID != "" && key.KeyID != expectedKid {
			continue
		}

		var claims headlessClientAssertionClaims
		if err := token.Claims(key.Key, &claims); err != nil {
			continue
		}
		if failure := validateHeadlessClientAssertionClaims(c, claims, clientID); failure != nil {
			if failure.debugFields == nil {
				failure.debugFields = map[string]any{}
			}
			failure.debugFields["received_kid"] = expectedKid
			failure.debugFields["jwks_refreshed"] = refreshed
			return failure
		}
		_ = h.HeadlessJWKS.MarkVerificationSuccess(clientID)
		return nil
	}

	if matchingKidPresent && !refreshed && h.HeadlessJWKS != nil {
		refreshedKeySet, _, refreshErr := h.HeadlessJWKS.ForceRefreshKeySet(c.Context(), client, "signature_verification_failed")
		if refreshErr == nil && refreshedKeySet != nil {
			for _, key := range refreshedKeySet.Keys {
				if expectedKid != "" && key.KeyID != "" && key.KeyID != expectedKid {
					continue
				}

				var claims headlessClientAssertionClaims
				if err := token.Claims(key.Key, &claims); err != nil {
					continue
				}
				if failure := validateHeadlessClientAssertionClaims(c, claims, clientID); failure != nil {
					if failure.debugFields == nil {
						failure.debugFields = map[string]any{}
					}
					failure.debugFields["received_kid"] = expectedKid
					failure.debugFields["jwks_refreshed"] = true
					return failure
				}
				_ = h.HeadlessJWKS.MarkVerificationSuccess(clientID)
				return nil
			}
		}
	}

	return newHeadlessLoginFailure(
		fiber.StatusUnauthorized,
		"invalid_client_assertion_signature",
		"Client assertion signature verification failed",
		"headless password login client assertion signature verification failed",
		map[string]any{
			"received_kid":   expectedKid,
			"jwks_refreshed": refreshed,
		},
	)
}

func headlessClientAssertionErrorMessage(err error) string {
	if err == nil {
		return "Failed to verify client assertion"
	}
	message := strings.TrimSpace(err.Error())
	switch {
	case strings.Contains(message, "requires jwksUri"):
		return "Headless login requires jwksUri. Inline jwks is not supported."
	case strings.Contains(message, "no keys"):
		return "Configured jwksUri returned no keys for headless login."
	case strings.Contains(message, "failed to fetch jwksUri"):
		return "Failed to refresh headless login jwks from jwksUri."
	case strings.Contains(message, "failed to decode jwks"):
		return "Configured jwksUri returned an invalid jwks document."
	default:
		return "Failed to verify client assertion"
	}
}

func containsHeadlessKeyID(keySet *jose.JSONWebKeySet, expectedKid string) bool {
	if keySet == nil {
		return false
	}
	for _, key := range keySet.Keys {
		if strings.TrimSpace(key.KeyID) == strings.TrimSpace(expectedKid) {
			return true
		}
	}
	return false
}

func (h *AuthHandler) storeHeadlessLinkState(pendingRef string, state headlessLinkState, ttl time.Duration) {
	if h.RedisService == nil || pendingRef == "" {
		return
	}
	raw, err := json.Marshal(state)
	if err != nil {
		return
	}
	_ = h.RedisService.Set(prefixHeadlessLinkState+pendingRef, string(raw), ttl)
}

func (h *AuthHandler) loadHeadlessLinkState(pendingRef string) (headlessLinkState, bool) {
	if h.RedisService == nil || pendingRef == "" {
		return headlessLinkState{}, false
	}
	raw, err := h.RedisService.Get(prefixHeadlessLinkState + pendingRef)
	if err != nil || raw == "" {
		return headlessLinkState{}, false
	}
	var state headlessLinkState
	if err := json.Unmarshal([]byte(raw), &state); err != nil {
		return headlessLinkState{}, false
	}
	return state, true
}

func (h *AuthHandler) resolveCurrentBrowserSessionEvidence(c *fiber.Ctx) (string, string) {
	if h == nil || c == nil {
		return "", ""
	}
	if token := h.getBearerToken(c); token != "" {
		if identityID, err := h.resolveIdentityID(c, token); err == nil && strings.TrimSpace(identityID) != "" {
			sessionID, _ := h.getKratosSessionID(token)
			return strings.TrimSpace(identityID), strings.TrimSpace(sessionID)
		}
	}
	if cookie := strings.TrimSpace(c.Get("Cookie")); cookie != "" {
		if identityID, _, _, sessionID, err := h.getKratosIdentityWithCookieAndSession(cookie); err == nil && strings.TrimSpace(identityID) != "" {
			return strings.TrimSpace(identityID), strings.TrimSpace(sessionID)
		}
	}
	return "", ""
}

func (h *AuthHandler) resolveCurrentBrowserSubject(c *fiber.Ctx) string {
	identityID, _ := h.resolveCurrentBrowserSessionEvidence(c)
	return identityID
}

func (h *AuthHandler) resolveHeadlessOIDCSubjectEvidence(c *fiber.Ctx, loginReq *domain.HydraLoginRequest, pendingRef string) string {
	if loginReq != nil && loginReq.Skip && strings.TrimSpace(loginReq.Subject) != "" {
		return strings.TrimSpace(loginReq.Subject)
	}
	if currentSubject := h.resolveCurrentBrowserSubject(c); currentSubject != "" {
		return currentSubject
	}
	if meta, ok := h.loadLoginApproverMeta(pendingRef); ok && strings.TrimSpace(meta.ApproverSubject) != "" {
		return strings.TrimSpace(meta.ApproverSubject)
	}
	return ""
}

func (h *AuthHandler) rejectSessionSubjectOverwrite(c *fiber.Ctx, targetSubject, targetLoginID string) (bool, error) {
	currentSubject := h.resolveCurrentBrowserSubject(c)
	if currentSubject == "" {
		return false, nil
	}

	targetSubject = strings.TrimSpace(targetSubject)
	if targetSubject == "" && strings.TrimSpace(targetLoginID) != "" && h.KratosAdmin != nil {
		if resolved, err := h.resolveKratosIdentityIDFromLoginID(c.Context(), targetLoginID); err == nil {
			targetSubject = strings.TrimSpace(resolved)
		} else {
			slog.Warn(
				"session-changing login target subject resolution failed",
				"loginID", targetLoginID,
				"error", err,
			)
		}
	}
	if targetSubject == "" {
		slog.Warn("session-changing login blocked because target subject is unknown", "current_subject", currentSubject)
		return true, errorJSONCode(c, fiber.StatusConflict, "session_subject_conflict", "Current browser session must be signed out before signing in as another user")
	}
	if targetSubject != currentSubject {
		slog.Warn(
			"session-changing login blocked by subject conflict",
			"target_subject", targetSubject,
			"current_subject", currentSubject,
		)
		return true, errorJSONCode(c, fiber.StatusConflict, "session_subject_conflict", "Current browser session must be signed out before signing in as another user")
	}
	return false, nil
}

func (h *AuthHandler) rejectHeadlessOIDCSubjectConflict(c *fiber.Ctx, currentSubject, targetSubject string) (bool, error) {
	currentSubject = strings.TrimSpace(currentSubject)
	targetSubject = strings.TrimSpace(targetSubject)
	if currentSubject == "" || targetSubject == "" || currentSubject == targetSubject {
		return false, nil
	}
	slog.Warn(
		"headless login blocked by OIDC/UserFront subject conflict",
		"current_subject", currentSubject,
		"target_subject", targetSubject,
	)
	return true, c.Status(fiber.StatusConflict).JSON(fiber.Map{
		"error":             "OIDC/UserFront subject conflicts with headless login target. Sign out of UserFront or restart through the standard UserFront login flow.",
		"code":              "oidc_subject_conflict",
		"status":            "oidc_subject_conflict",
		"currentSubject":    currentSubject,
		"targetSubject":     targetSubject,
		"recommendedAction": "redirect_to_userfront_login",
	})
}

func (h *AuthHandler) completeApprovedLinkLogin(c *fiber.Ctx, pendingRef string) (string, *domain.AuthInfo, error) {
	val, err := h.RedisService.Get(prefixSession + pendingRef)
	if err != nil || val == "" {
		return "", nil, errorJSON(c, fiber.StatusBadRequest, "Invalid session reference")
	}

	var data map[string]string
	_ = json.Unmarshal([]byte(val), &data)
	loginID := data["loginId"]
	if loginID == "" {
		loginID = data["login_id"]
	}
	if loginID == "" {
		slog.Warn("[Poll] Approved but missing loginId", "pendingRef", pendingRef)
		return "", nil, errorJSON(c, fiber.StatusBadRequest, "Invalid session reference")
	}
	if h.IdpProvider == nil {
		return "", nil, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
	}

	loginStrategy := h.loadLoginStrategy(pendingRef)
	if loginStrategy == "" {
		loginStrategy = loginFlowLink
	}

	var authInfo *domain.AuthInfo
	if loginStrategy == loginFlowCode {
		code, _ := h.RedisService.Get(prefixLoginCodeValue + pendingRef)
		code = normalizeLoginCode(code)
		if code == "" {
			slog.Warn("[Poll] Missing login code for approved flow", "pendingRef", pendingRef)
			return "", nil, errorJSON(c, fiber.StatusBadRequest, "Login code expired")
		}
		flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
		if flowID == "" {
			return "", nil, errorJSON(c, fiber.StatusNotFound, "Login flow expired")
		}
		authInfo, err = h.IdpProvider.VerifyLoginCode(loginID, flowID, code)
		if err != nil {
			if errors.Is(err, domain.ErrNotSupported) {
				return "", nil, errorJSON(c, fiber.StatusNotImplemented, "Login method not supported")
			}
			slog.Error("[Poll] IDP code verify failed", "error", err)
			return "", nil, errorJSON(c, fiber.StatusInternalServerError, "Failed to verify login code")
		}
	} else {
		authInfo, err = h.IdpProvider.IssueSession(loginID)
		if err != nil {
			if errors.Is(err, domain.ErrNotSupported) {
				return "", nil, errorJSON(c, fiber.StatusNotImplemented, "Login method not supported")
			}
			slog.Error("[Poll] IDP session issue failed", "error", err)
			return "", nil, errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session")
		}
	}
	if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
		return "", nil, errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session")
	}
	if blocked, err := h.rejectSessionSubjectOverwrite(c, authInfo.Subject, loginID); blocked || err != nil {
		return "", nil, err
	}

	c.Locals("login_id", loginID)
	setSessionIDLocal(c, authInfo.SessionToken)

	// Write Kratos session cookies to the response
	h.writeAuthCookies(c, authInfo.SetCookies)

	sessionID := extractSessionIDFromToken(authInfo.SessionToken)
	if sessionID == "" && authInfo.SessionToken != nil && authInfo.SessionToken.JWT != "" {
		if resolved, err := h.getKratosSessionID(authInfo.SessionToken.JWT); err == nil && resolved != "" {
			sessionID = resolved
			authInfo.SessionToken.SessionID = resolved
			setSessionIDLocal(c, authInfo.SessionToken)
		}
	}

	sessionData := map[string]string{
		"status": statusSuccess,
		"jwt":    authInfo.SessionToken.JWT,
	}
	if sessionID != "" {
		sessionData["session_id"] = sessionID
	}
	if authInfo.Subject != "" {
		sessionData["subject"] = authInfo.Subject
	}
	if loginID != "" {
		sessionData["loginId"] = loginID
	}
	sessionDataJSON, _ := json.Marshal(sessionData)
	_ = h.RedisService.Set(prefixSession+pendingRef, string(sessionDataJSON), defaultExpiration)

	h.writeLinkAuditLog(loginID, pendingRef, authInfo.SessionToken, c)
	h.clearLoginMeta(pendingRef)
	if loginStrategy == loginFlowCode {
		_ = h.RedisService.Delete(prefixLoginCode + loginID)
		_ = h.RedisService.Delete(prefixLoginCodePending + loginID)
		_ = h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID)
		_ = h.RedisService.Delete(prefixLoginCodeSmsLookup + loginID)
		_ = h.RedisService.Delete(prefixLoginCodeValue + pendingRef)
	}

	return loginID, authInfo, nil
}

func (h *AuthHandler) validateHeadlessPasswordLoginClient(loginReq *domain.HydraLoginRequest, clientID string) error {
	if loginReq == nil {
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request")
	}

	if strings.TrimSpace(loginReq.Client.ClientID) != strings.TrimSpace(clientID) {
		return fiber.NewError(fiber.StatusForbidden, "The client application is not allowed to use this login request.")
	}

	if metadata := loginReq.Client.Metadata; metadata != nil {
		if status, ok := metadata["status"].(string); ok && strings.ToLower(status) == "inactive" {
			return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
		}
	}

	if !loginReq.Client.IsHeadlessLoginEnabled() {
		return fiber.NewError(fiber.StatusForbidden, "The client application is not allowed to use headless password login.")
	}

	return nil
}

func (h *AuthHandler) HeadlessPasswordLogin(c *fiber.Ctx) error {
	var req struct {
		ClientID        string `json:"client_id"`
		ClientAssertion string `json:"client_assertion"`
		LoginID         string `json:"loginId"`
		Password        string `json:"password"`
		LoginChallenge  string `json:"login_challenge"`
	}

	if err := c.BodyParser(&req); err != nil {
		return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
	}

	clientID := strings.TrimSpace(req.ClientID)
	loginID := strings.TrimSpace(req.LoginID)
	loginChallenge := strings.TrimSpace(req.LoginChallenge)
	if clientID == "" || loginID == "" || strings.TrimSpace(req.Password) == "" || loginChallenge == "" {
		return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "client_id, loginId, password and login_challenge are required")
	}

	if h.IdpProvider == nil || h.Hydra == nil {
		return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured")
	}

	loginReq, err := h.Hydra.GetLoginRequest(c.Context(), loginChallenge)
	if err != nil {
		slog.Error("failed to get hydra login request for headless password login", "error", err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request")
	}
	if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil {
		return err
	}
	if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil {
		logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, loginChallenge)
		return respondHeadlessLoginFailure(c, failure)
	}

	authInfo, authErr := h.authenticatePasswordLogin(c.Context(), loginID, req.Password)
	if authErr != nil {
		status, code, message := passwordLoginErrorSpec(authErr)
		logHeadlessLoginFailure(
			c,
			"headless password login credential authentication failed",
			newHeadlessCredentialFailure(status, code, message),
			clientID,
			loginChallenge,
		)
		return errorJSONCode(c, status, code, message)
	}
	if authInfo == nil || strings.TrimSpace(authInfo.Subject) == "" {
		return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity")
	}
	if blocked, err := h.rejectHeadlessOIDCSubjectConflict(
		c,
		h.resolveHeadlessOIDCSubjectEvidence(c, loginReq, ""),
		authInfo.Subject,
	); blocked || err != nil {
		return err
	}

	c.Locals("user_id", authInfo.Subject)
	c.Locals("login_id", loginID)
	setSessionIDLocal(c, authInfo.SessionToken)
	attachAuditClientDetails(c, loginReq.Client)
	appendAuditDetail(c, "login_challenge", loginChallenge)

	acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), loginChallenge, authInfo.Subject)
	if err != nil {
		slog.Error("failed to accept hydra login request in headless password login", "error", err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
	}

	logOidcRedirectSummary("headless_password_login", acceptResp.RedirectTo)
	if err := c.Status(fiber.StatusOK).JSON(fiber.Map{
		"redirectTo": acceptResp.RedirectTo,
		"status":     "ok",
		"provider":   h.IdpProvider.Name(),
	}); err != nil {
		return err
	}
	logHeadlessLoginSuccess(c, clientID, loginChallenge, acceptResp.RedirectTo)
	return nil
}

func (h *AuthHandler) startHeadlessPhoneLink(c *fiber.Ctx, loginID string) (fiber.Map, string, string, time.Duration, error) {
	rawLoginID := strings.ReplaceAll(loginID, "-", "")
	rawLoginID = strings.ReplaceAll(rawLoginID, " ", "")
	if rawLoginID == "" || strings.Contains(rawLoginID, "@") {
		return nil, "", "", 0, errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "phone-based loginId is required")
	}

	lookupLoginID := normalizePhoneForLoginID(rawLoginID)
	if h.IdpProvider == nil {
		return nil, "", "", 0, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
	}
	exists, err := h.IdpProvider.UserExists(lookupLoginID)
	if err != nil {
		slog.Warn("[HeadlessLink] IDP user lookup failed", "loginID", rawLoginID, "error", err)
		return nil, "", "", 0, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
	}
	if !exists {
		slog.Warn("[HeadlessLink] User not found", "loginID", rawLoginID)
		return nil, "", "", 0, errorJSON(c, fiber.StatusNotFound, "User not registered")
	}

	userfrontURL := h.resolveUserfrontURL(c)
	if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" {
		keyLoginID := lookupLoginID
		if init.LoginID != "" {
			keyLoginID = init.LoginID
		}
		if init.FlowID != "" {
			_ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
		}
		pendingRef := GenerateSecureToken(3)
		sessionData, _ := json.Marshal(map[string]string{
			"status":  statusPending,
			"loginId": keyLoginID,
		})
		_ = h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
		h.storeLoginMeta(pendingRef, rawLoginID, "sms", loginFlowLink, loginFlowCode, loginCodeExpiration)
		_ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration)
		if keyLoginID != lookupLoginID {
			_ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration)
			_ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration)
		}
		expiresIn := int(loginCodeExpiration.Seconds())
		if !init.ExpiresAt.IsZero() {
			if seconds := int(time.Until(init.ExpiresAt).Seconds()); seconds > 0 {
				expiresIn = seconds
			}
		}
		return fiber.Map{
			"pendingRef":  pendingRef,
			"status":      "pending",
			"mode":        init.Mode,
			"provider":    h.IdpProvider.Name(),
			"expiresIn":   expiresIn,
			"interval":    int(minPollInterval.Seconds()),
			"resendAfter": int(linkResendCooldown.Seconds()),
		}, pendingRef, keyLoginID, loginCodeExpiration, nil
	} else if err != nil && !errors.Is(err, domain.ErrNotSupported) {
		slog.Error("[HeadlessLink] Link login init failed", "provider", h.IdpProvider.Name(), "error", err)
		return nil, "", "", 0, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
	}

	if h.SmsService == nil {
		return nil, "", "", 0, errorJSON(c, fiber.StatusInternalServerError, "SMS service not configured")
	}

	token := GenerateSecureToken(3)
	pendingRef := GenerateSecureToken(3)
	sessionData, _ := json.Marshal(map[string]string{
		"status":  statusPending,
		"loginId": lookupLoginID,
	})
	_ = h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
	_ = h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration)
	h.storeLoginMeta(pendingRef, rawLoginID, "sms", loginFlowLink, loginFlowLink, defaultExpiration)

	link := fmt.Sprintf("%s/verify/%s", strings.TrimRight(userfrontURL, "/"), token)
	content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s", link)
	if err := h.SmsService.SendSms(rawLoginID, content); err != nil {
		slog.Error("[HeadlessLink] SMS send failed", "error", err)
		return nil, "", "", 0, errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS")
	}

	return fiber.Map{
		"pendingRef":  pendingRef,
		"status":      "pending",
		"provider":    h.IdpProvider.Name(),
		"expiresIn":   int(defaultExpiration.Seconds()),
		"interval":    int(minPollInterval.Seconds()),
		"resendAfter": int(linkResendCooldown.Seconds()),
	}, pendingRef, lookupLoginID, defaultExpiration, nil
}

func (h *AuthHandler) HeadlessLinkInit(c *fiber.Ctx) error {
	var req struct {
		ClientID        string `json:"client_id"`
		ClientAssertion string `json:"client_assertion"`
		LoginID         string `json:"loginId"`
		LoginChallenge  string `json:"login_challenge"`
	}

	if err := c.BodyParser(&req); err != nil {
		return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
	}

	clientID := strings.TrimSpace(req.ClientID)
	loginChallenge := strings.TrimSpace(req.LoginChallenge)
	loginID := strings.TrimSpace(req.LoginID)
	if clientID == "" || loginChallenge == "" || loginID == "" {
		return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "client_id, client_assertion, loginId and login_challenge are required")
	}
	if h.Hydra == nil {
		return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured")
	}

	loginReq, err := h.Hydra.GetLoginRequest(c.Context(), loginChallenge)
	if err != nil {
		slog.Error("failed to get hydra login request for headless link init", "error", err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request")
	}
	if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil {
		return err
	}
	if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil {
		logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, loginChallenge)
		return respondHeadlessLoginFailure(c, failure)
	}

	resp, pendingRef, resolvedLoginID, ttl, err := h.startHeadlessPhoneLink(c, loginID)
	if err != nil {
		return err
	}
	h.storeHeadlessLinkState(pendingRef, headlessLinkState{
		ClientID:       clientID,
		ClientName:     strings.TrimSpace(loginReq.Client.ClientName),
		LoginChallenge: loginChallenge,
		LoginID:        resolvedLoginID,
	}, ttl)
	return c.JSON(resp)
}

func (h *AuthHandler) HeadlessLinkPoll(c *fiber.Ctx) error {
	var req struct {
		ClientID        string `json:"client_id"`
		ClientAssertion string `json:"client_assertion"`
		PendingRef      string `json:"pendingRef"`
	}

	if err := c.BodyParser(&req); err != nil {
		return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
	}

	clientID := strings.TrimSpace(req.ClientID)
	pendingRef := strings.TrimSpace(req.PendingRef)
	if clientID == "" || pendingRef == "" {
		return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "client_id, client_assertion and pendingRef are required")
	}

	state, ok := h.loadHeadlessLinkState(pendingRef)
	if !ok {
		return c.JSON(fiber.Map{
			"error": "expired_token",
			"code":  "expired_token",
		})
	}
	if state.ClientID != clientID {
		return fiber.NewError(fiber.StatusForbidden, "The client application is not allowed to use this pending login.")
	}
	if state.RedirectTo != "" {
		return c.JSON(fiber.Map{
			"redirectTo": state.RedirectTo,
			"status":     "ok",
		})
	}
	if h.Hydra == nil {
		return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured")
	}

	loginReq, err := h.Hydra.GetLoginRequest(c.Context(), state.LoginChallenge)
	if err != nil {
		slog.Error("failed to get hydra login request for headless link poll", "error", err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request")
	}
	if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil {
		return err
	}
	if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil {
		logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, state.LoginChallenge)
		return respondHeadlessLoginFailure(c, failure)
	}

	val, err := h.RedisService.Get(prefixSession + pendingRef)
	if err != nil || val == "" {
		return c.JSON(fiber.Map{
			"error": "expired_token",
			"code":  "expired_token",
		})
	}

	var session map[string]string
	_ = json.Unmarshal([]byte(val), &session)
	if session["status"] == statusPending {
		return c.JSON(fiber.Map{
			"error":    "authorization_pending",
			"code":     "authorization_pending",
			"interval": int(minPollInterval.Seconds()),
		})
	}

	loginID := strings.TrimSpace(state.LoginID)
	targetSubject := strings.TrimSpace(session["subject"])
	if session["status"] == "approved" || session["status"] == statusSuccess {
		if storedLoginID := strings.TrimSpace(session["loginId"]); storedLoginID != "" {
			loginID = storedLoginID
		} else if storedLoginID := strings.TrimSpace(session["login_id"]); storedLoginID != "" {
			loginID = storedLoginID
		}
	}

	if loginID == "" {
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to resolve approved user identity")
	}
	if targetSubject == "" {
		targetSubject, err = h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID)
	}
	if err != nil || targetSubject == "" {
		slog.Error("failed to resolve kratos identity for headless link poll", "loginID", loginID, "error", err)
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to resolve user identity")
	}
	if blocked, err := h.rejectHeadlessOIDCSubjectConflict(
		c,
		h.resolveHeadlessOIDCSubjectEvidence(c, loginReq, pendingRef),
		targetSubject,
	); blocked || err != nil {
		return err
	}

	acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), state.LoginChallenge, targetSubject)
	if err != nil {
		slog.Error("failed to accept hydra login request in headless link poll", "error", err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
	}

	state.RedirectTo = acceptResp.RedirectTo
	h.storeHeadlessLinkState(pendingRef, state, defaultExpiration)
	h.writeLinkAuditLog(loginID, pendingRef, nil, c)
	h.clearLoginMeta(pendingRef)
	logOidcRedirectSummary("headless_link_poll", acceptResp.RedirectTo)
	return c.JSON(fiber.Map{
		"redirectTo": acceptResp.RedirectTo,
		"status":     "ok",
	})
}

func (h *AuthHandler) writeAuthCookies(c *fiber.Ctx, cookies []*http.Cookie) {
	if len(cookies) == 0 {
		return
	}

	host := c.Hostname()
	domain := ""

	// IP address or localhost check
	if ip := net.ParseIP(host); ip != nil || host == "localhost" {
		domain = host
	} else {
		// Extract root domain (e.g., .hmac.kr from sso.hmac.kr)
		parts := strings.Split(host, ".")
		if len(parts) >= 2 {
			domain = "." + strings.Join(parts[len(parts)-2:], ".")
		}
	}

	for _, cookie := range cookies {
		c.Cookie(&fiber.Cookie{
			Name:     cookie.Name,
			Value:    cookie.Value,
			Path:     "/",
			Domain:   domain,
			MaxAge:   cookie.MaxAge,
			Expires:  cookie.Expires,
			Secure:   true,
			HTTPOnly: true,
			SameSite: "Lax",
		})
	}
}

func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
	startTime := time.Now()
	ale := logger.NewAuditLogEntry(c, "login")
	ale.Operation = "Auth.Password().SignIn"

	var req struct {
		LoginID        string `json:"loginId"`
		Password       string `json:"password"`
		LoginChallenge string `json:"login_challenge,omitempty"`
	}

	if err := c.BodyParser(&req); err != nil {
		ale.Status = fiber.StatusBadRequest
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = err.Error()
		ale.Log(slog.LevelError, "Body parse error")
		return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
	}

	loginID := strings.TrimSpace(req.LoginID)
	ale.LoginIDs["loginId"] = req.LoginID // 원문
	ale.LoginIDs["loginId_normalized"] = loginID

	ale.Log(slog.LevelInfo, "Attempting to login")

	if h.IdpProvider == nil {
		ale.Status = fiber.StatusInternalServerError
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = "IDP Provider is nil"
		ale.Log(slog.LevelError, "IDP Provider is nil")
		return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured")
	}

	authInfo, err := h.authenticatePasswordLogin(c.Context(), loginID, req.Password)
	if err != nil {
		status, code, message := passwordLoginErrorSpec(err)
		ale.Status = status
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = err.Error()
		ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name()))
		return errorJSONCode(c, status, code, message)
	}

	ale.Status = fiber.StatusOK
	ale.LatencyMs = time.Since(startTime)
	if blocked, err := h.rejectSessionSubjectOverwrite(c, authInfo.Subject, loginID); blocked || err != nil {
		ale.Status = fiber.StatusConflict
		ale.ProviderError = "session_subject_conflict"
		ale.Log(slog.LevelWarn, "Login blocked by existing browser session")
		return err
	}
	c.Locals("user_id", authInfo.Subject)
	c.Locals("login_id", loginID)
	setSessionIDLocal(c, authInfo.SessionToken)

	// Write Kratos session cookies to the response
	h.writeAuthCookies(c, authInfo.SetCookies)

	if req.LoginChallenge == "" {
		attachAuditClientDetails(c, domain.HydraClient{
			ClientID:   "userfront",
			ClientName: "UserFront",
		})
	}
	ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))

	// --- OIDC 로그인 흐름 처리 ---
	if req.LoginChallenge != "" {
		slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge)

		// Check if the client is active
		loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge)
		if err == nil && loginReq != nil {
			attachAuditClientDetails(c, loginReq.Client)
			if loginReq.Client.Metadata != nil {
				if status, ok := loginReq.Client.Metadata["status"].(string); ok {
					if strings.ToLower(status) == "inactive" {
						slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID)
						return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
					}
				}
			}
		}

		acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, authInfo.Subject)
		if err != nil {
			slog.Error("failed to accept hydra login request", "error", err)
			return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
		}
		logOidcRedirectSummary("password_login", acceptResp.RedirectTo)

		// IMPORTANT: Also return sessionJwt and token during OIDC flow to ensure portal session.
		return c.JSON(fiber.Map{
			"redirectTo": acceptResp.RedirectTo,
			"status":     "ok",
			"provider":   h.IdpProvider.Name(),
			"sessionJwt": authInfo.SessionToken.JWT,
			"token":      authInfo.SessionToken.JWT,
			"subject":    authInfo.Subject,
		})
	}
	// --- OIDC 로그인 흐름 처리 끝 ---

	resp := fiber.Map{
		"sessionJwt": authInfo.SessionToken.JWT,
		"token":      authInfo.SessionToken.JWT,
		"status":     "ok",
		"provider":   h.IdpProvider.Name(),
	}
	if authInfo.RefreshToken != nil {
		resp["refreshJwt"] = authInfo.RefreshToken.JWT
	}
	if authInfo.Subject != "" {
		resp["subject"] = authInfo.Subject
	}
	return c.JSON(resp)
}

func attachAuditClientDetails(c *fiber.Ctx, client domain.HydraClient) {
	if c == nil {
		return
	}

	clientID := strings.TrimSpace(client.ClientID)
	if clientID == "" {
		return
	}

	clientName := strings.TrimSpace(client.ClientName)
	if clientName == "" {
		clientName = clientID
	}

	if client.Metadata != nil {
		if tid, ok := client.Metadata["tenant_id"].(string); ok && tid != "" {
			c.Locals("tenant_id", tid)
		}
	}

	c.Locals("audit_details_extra", map[string]any{
		"client_id":   clientID,
		"client_name": clientName,
	})
}

func appendAuditDetail(c *fiber.Ctx, key string, value any) {
	if c == nil || strings.TrimSpace(key) == "" || value == nil {
		return
	}

	extra, _ := c.Locals("audit_details_extra").(map[string]any)
	if extra == nil {
		extra = make(map[string]any)
	}
	extra[key] = value
	c.Locals("audit_details_extra", extra)
}

// InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 이메일 또는 SMS를 보냅니다.
func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
	startTime := time.Now()
	ale := logger.NewAuditLogEntry(c, "initiate")

	var req domain.PasswordResetInitiateRequest
	if err := c.BodyParser(&req); err != nil {
		ale.Status = fiber.StatusBadRequest
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = err.Error()
		ale.Log(slog.LevelError, "Body parse error")
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
	}

	loginID := strings.TrimSpace(req.LoginID)
	ale.LoginIDs["loginId"] = req.LoginID // 원문
	ale.LoginIDs["loginId_normalized"] = loginID

	if loginID == "" {
		ale.Status = fiber.StatusBadRequest
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = "Login ID is required"
		ale.Log(slog.LevelWarn, "Login ID missing")
		return errorJSON(c, fiber.StatusBadRequest, "Login ID is required")
	}

	if h.IdpProvider == nil {
		ale.Status = fiber.StatusInternalServerError
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = "IDP Provider is not initialized"
		ale.Log(slog.LevelError, "IDP Provider is not initialized")
		return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured")
	}

	userfrontURL := h.resolveUserfrontURL(c)
	// 비밀번호 재설정 링크는 backend verify 엔드포인트를 거쳐서 userfront로 이동합니다.
	// 이렇게 해야 메일/SMS 링크 프리뷰나 자동 스캔으로 토큰이 직접 노출되는 경로를 줄일 수 있습니다.
	verifyBaseURL := fmt.Sprintf("%s/api/v1/auth/password/reset/v", userfrontURL)

	// 내부 토큰 발급 + 우리 채널로 전송
	resetToken := GenerateSecureToken(32)
	if resetToken == "" {
		ale.Status = fiber.StatusInternalServerError
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = "Failed to generate reset token"
		ale.Log(slog.LevelError, "Failed to generate reset token")
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to generate reset token")
	}

	if err := h.RedisService.Set(prefixPwdResetToken+resetToken, loginID, pwdResetExpiration); err != nil {
		ale.Status = fiber.StatusInternalServerError
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = err.Error()
		ale.Log(slog.LevelError, "Failed to store reset token in Redis")
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token")
	}

	resetLink := fmt.Sprintf("%s/%s", verifyBaseURL, resetToken)
	ale.RedirectTo = resetLink
	ale.Operation = "SendPasswordReset"
	ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
	drySend := (req.DrySend || req.DryRun) && service.IsDryRunAllowed()
	if (req.DrySend || req.DryRun) && !service.IsDryRunAllowed() {
		ale.Log(slog.LevelWarn, "DrySend ignored in production")
	}

	if strings.Contains(loginID, "@") {
		if !drySend && h.EmailService == nil {
			ale.Status = fiber.StatusInternalServerError
			ale.LatencyMs = time.Since(startTime)
			ale.ProviderError = "Email service not configured"
			ale.Log(slog.LevelError, "Email service not configured")
			return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured")
		}
		subject := "[Baron 로그인] 비밀번호 재설정"
		body := fmt.Sprintf(`
			<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
				<h2 style="color: #1A1F2C;">Baron 로그인 비밀번호 재설정</h2>
				<p>아래 버튼을 클릭해 비밀번호 재설정을 진행해 주세요. 링크는 15분간 유효합니다.</p>
				<div style="margin: 30px 0;">
					<a href="%s" style="background-color: #1A1F2C; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">비밀번호 재설정</a>
				</div>
				<p style="font-size: 12px; color: #888;">요청하지 않았다면 이 메일을 무시하세요.</p>
			</div>
		`, resetLink)
		if drySend {
			ale.Log(slog.LevelInfo, "Email send skipped (dry-send)", slog.String("loginId", loginID), slog.String("link", resetLink))
		} else {
			if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
				ale.Status = fiber.StatusInternalServerError
				ale.LatencyMs = time.Since(startTime)
				ale.ProviderError = err.Error()
				ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID))
				return errorJSON(c, fiber.StatusInternalServerError, "Failed to send reset email")
			}
		}
	} else {
		resetSms := fmt.Sprintf("[Baron 로그인] 비밀번호 재설정 링크: %s", resetLink)
		if drySend {
			ale.Log(slog.LevelInfo, "SMS send skipped (dry-send)", slog.String("loginId", loginID), slog.String("content", resetSms))
		} else {
			if err := h.SmsService.SendSms(loginID, resetSms); err != nil {
				ale.Status = fiber.StatusInternalServerError
				ale.LatencyMs = time.Since(startTime)
				ale.ProviderError = err.Error()
				ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID))
				return errorJSON(c, fiber.StatusInternalServerError, "Failed to send reset SMS")
			}
		}
	}

	ale.Status = fiber.StatusOK
	ale.LatencyMs = time.Since(startTime)
	ale.Log(slog.LevelInfo, "Password reset link sent successfully (internal token)")
	return c.JSON(fiber.Map{"message": "If an account with that login ID exists, a reset link has been sent."})
}

// VerifyPasswordResetPage - Serves an interstitial page to prevent link scanners from consuming the token.
func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error {
	token := c.Query("token")
	if token == "" {
		token = c.Query("t")
	}
	if token == "" {
		token = c.Params("token")
	}

	if token == "" {
		return c.Status(fiber.StatusBadRequest).SendString("Missing token")
	}

	// Simple HTML page with a form to trigger the POST request
	html := fmt.Sprintf(`
		<!DOCTYPE html>
		<html>
		<head>
			<title>Baron 로그인 - 비밀번호 재설정</title>
			<meta name="viewport" content="width=device-width, initial-scale=1">
			<style>
				body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f5f5f5; }
				.container { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center; max-width: 400px; width: 100%%; }
				h2 { color: #1A1F2C; margin-bottom: 1rem; }
				p { color: #666; margin-bottom: 2rem; }
				button { background-color: #1A1F2C; color: white; padding: 12px 24px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; width: 100%%; transition: background 0.2s; }
				button:hover { background-color: #333; }
			</style>
		</head>
		<body>
			<div class="container">
				<h2>비밀번호 재설정</h2>
				<p>아래 버튼을 클릭하여 비밀번호 재설정을 계속해 주세요.</p>
				<form action="/api/v1/auth/password/reset/verify" method="POST">
					<input type="hidden" name="token" value="%s">
					<button type="submit">계속하기</button>
				</form>
			</div>
		</body>
		</html>
	`, token)

	c.Set("Content-Type", "text/html; charset=utf-8")
	return c.SendString(html)
}

// ProcessPasswordResetToken - Handles the POST request from the interstitial page.
// Verifies the token, sets the refresh token cookie, and redirects to the userfront.
func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
	startTime := time.Now()
	ale := logger.NewAuditLogEntry(c, "verify")
	ale.Operation = "Verify"

	// Token comes from Form Body in POST or query
	token := c.FormValue("token")
	if token == "" {
		token = c.Query("token")
		if token == "" {
			token = c.Query("t")
		}
	}
	if token == "" {
		token = c.Params("token")
	}
	ale.Token = token

	if token == "" {
		ale.Status = fiber.StatusBadRequest
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = "Missing token"
		ale.Log(slog.LevelWarn, "Missing token in request")
		return c.Status(fiber.StatusBadRequest).SendString("Missing token")
	}

	loginID, err := h.RedisService.Get(prefixPwdResetToken + token)
	if err != nil || loginID == "" {
		ale.Status = fiber.StatusUnauthorized
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = "Invalid or expired reset token"
		ale.Log(slog.LevelWarn, "Reset token invalid or expired")
		return c.Status(fiber.StatusUnauthorized).SendString("Invalid or expired token")
	}

	ale.LoginIDs["loginId"] = loginID
	ale.LoginIDs["loginId_normalized"] = loginID

	userfrontURL := h.resolveUserfrontURL(c)
	redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password")
	if parseErr != nil {
		ale.Status = fiber.StatusInternalServerError
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = parseErr.Error()
		ale.Log(slog.LevelError, "Failed to compose reset redirect URL")
		return c.Status(fiber.StatusInternalServerError).SendString("Failed to compose redirect URL")
	}
	query := redirectBase.Query()
	query.Set("loginId", loginID)
	query.Set("token", token)
	redirectBase.RawQuery = query.Encode()
	redirectURL := redirectBase.String()

	ale.RedirectTo = redirectURL
	ale.Status = fiber.StatusFound
	ale.LatencyMs = time.Since(startTime)
	ale.Log(slog.LevelInfo, "Token verified, redirecting to userfront")

	return c.Redirect(redirectURL)
}

// CompletePasswordReset - 제공된 loginID와 새 비밀번호로 IDP 비밀번호를 업데이트합니다.
// 리프레시 토큰은 요청 쿠키에 포함되어 있어야 합니다.
func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
	startTime := time.Now()
	ale := logger.NewAuditLogEntry(c, "complete")
	ale.Operation = "UpdateUserPassword"

	providerName := "unknown"
	if h.IdpProvider != nil {
		providerName = h.IdpProvider.Name()
	}

	var req struct {
		NewPassword string `json:"newPassword"`
	}
	if err := c.BodyParser(&req); err != nil {
		ale.Status = fiber.StatusBadRequest
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = err.Error()
		ale.Log(slog.LevelError, "Body parse error")
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
	}

	// loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다.
	loginID := strings.TrimSpace(c.Query("loginId"))
	resetToken := strings.TrimSpace(c.Query("token"))
	if resetToken != "" {
		val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
		if err != nil || strings.TrimSpace(val) == "" {
			if usedLoginID, usedErr := h.RedisService.Get(prefixPwdResetUsed + resetToken); usedErr == nil && strings.TrimSpace(usedLoginID) != "" {
				ale.Status = fiber.StatusOK
				ale.LatencyMs = time.Since(startTime)
				ale.Token = resetToken
				ale.LoginIDs["loginId"] = strings.TrimSpace(usedLoginID)
				ale.Log(slog.LevelInfo, "Duplicate reset completion ignored after successful use")
				return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
			}
			ale.Status = fiber.StatusUnauthorized
			ale.LatencyMs = time.Since(startTime)
			ale.ProviderError = "Invalid or expired reset token"
			ale.Token = resetToken
			ale.Log(slog.LevelWarn, "Reset token invalid or expired")
			return errorJSON(c, fiber.StatusUnauthorized, "Invalid or expired reset token")
		}
		loginID = strings.TrimSpace(val)
		ale.Token = resetToken
	}
	if loginID != "" && !strings.Contains(loginID, "@") {
		loginID = normalizePhoneForLoginID(loginID)
	}

	ale.LoginIDs["loginId"] = loginID

	// 요청 쿠키는 원문을 기록하지 않고 존재 여부만 기록합니다.
	if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" {
		if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" {
			ale.ParsedCookieDSRF = dsrfCookie
			ale.HasCookieDSRF = true
		} else {
			ale.HasCookieDSRF = false
		}
	}

	if loginID == "" || req.NewPassword == "" {
		ale.Status = fiber.StatusBadRequest
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = "Login ID and new password are required"
		ale.Log(slog.LevelWarn, "Login ID or new password missing")
		return errorJSON(c, fiber.StatusBadRequest, "Login ID and new password are required")
	}

	// 새 비밀번호 값은 기록하지 않고, 요청 수신 이벤트만 남깁니다.
	ale.Log(slog.LevelInfo, "Received new password for reset")

	policy := h.resolvePasswordPolicy()
	if err := validatePasswordWithPolicy(policy, req.NewPassword); err != nil {
		ale.Status = fiber.StatusBadRequest
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = err.Error()
		ale.Log(slog.LevelWarn, "Validation failed: "+err.Error())
		return errorJSON(c, fiber.StatusBadRequest, err.Error())
	}

	ale.Log(slog.LevelInfo, "Attempting to update password via IDP", slog.String("idp", providerName))

	if h.IdpProvider == nil {
		ale.Status = fiber.StatusInternalServerError
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = "IDP Provider is nil"
		ale.Log(slog.LevelError, "IDP Provider is nil")
		return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured")
	}

	if err := h.IdpProvider.UpdateUserPassword(loginID, req.NewPassword, nil); err != nil {
		ale.Status = fiber.StatusInternalServerError
		ale.LatencyMs = time.Since(startTime)
		ale.ProviderError = err.Error()
		ale.Log(slog.LevelError, "Failed to update password via IDP")
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to update password")
	}

	ale.Status = fiber.StatusOK
	ale.LatencyMs = time.Since(startTime)
	ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
	if resetToken != "" {
		_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
		_ = h.RedisService.Set(prefixPwdResetUsed+resetToken, loginID, pwdResetUsedExpiration)
	}
	return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
}

// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
	pendingRef := GenerateSecureToken(16)
	qrRef := GenerateSecureAlnumToken(64)
	if qrRef == "" {
		qrRef = GenerateSecureToken(16)
	}

	// QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다.
	userfrontURL := h.resolveUserfrontURL(c)
	qrPayload := fmt.Sprintf("%s/ql/%s", strings.TrimRight(userfrontURL, "/"), qrRef)

	slog.Info("[QR] Init", "pendingRef", pendingRef, "qrRef", qrRef, "url", qrPayload)

	// Redis에 초기 상태 저장 (5분 만료)
	h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute)
	h.RedisService.Set(prefixQrRef+qrRef, pendingRef, 5*time.Minute)
	h.storeQrMeta(pendingRef, c)

	return c.JSON(fiber.Map{
		"qrCode":     qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환
		"pendingRef": pendingRef,
		"expiresIn":  300,
		"interval":   int(minPollInterval.Seconds()),
	})
}

// PollQRLogin - Step 2: 웹에서 승인 여부를 폴링합니다.
func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error {
	var req struct {
		PendingRef string `json:"pendingRef"`
	}
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid body")
	}

	pollKey := prefixPollMeta + "qr:" + req.PendingRef
	if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error":    "slow_down",
			"code":     "slow_down",
			"interval": interval,
		})
	}

	val, err := h.RedisService.Get(prefixSession + req.PendingRef)
	if err != nil || val == "" {
		return errorJSON(c, fiber.StatusBadRequest, "expired_token")
	}

	var data map[string]string
	json.Unmarshal([]byte(val), &data)

	if data["status"] == statusSuccess {
		slog.Info("[QR] Poll Success", "pendingRef", req.PendingRef)
		return c.JSON(fiber.Map{
			"status":     "ok",
			"sessionJwt": data["jwt"],
		})
	}

	return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
		"error":    "authorization_pending",
		"code":     "authorization_pending",
		"interval": int(minPollInterval.Seconds()),
	})
}

// ScanQRLogin - Step 3: 모바일 앱에서 QR 스캔 후 승인할 때 호출합니다.
// (이미 로그인된 세션이 필요함)
func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
	var req struct {
		PendingRef string `json:"pendingRef"`
		Token      string `json:"token"` // 모바일 사용자의 세션 토큰 (검증용)
	}
	if err := c.BodyParser(&req); err != nil {
		slog.Error("[QR] Scan body parse error", "error", err)
		return errorJSON(c, fiber.StatusBadRequest, "Invalid body")
	}

	rawRef := strings.TrimSpace(req.PendingRef)
	pendingRef, err := h.resolveQrPendingRef(rawRef)
	if err != nil || pendingRef == "" {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid pendingRef")
	}

	slog.Info("[QR] Scan & Approve", "pendingRef", pendingRef)

	// 1. Redis에서 세션 확인
	val, err := h.RedisService.Get(prefixSession + pendingRef)
	if err != nil || val == "" {
		return errorJSON(c, fiber.StatusNotFound, "Session expired or not found")
	}

	if req.Token == "" {
		cookie := c.Get(fiber.HeaderCookie)
		if cookie == "" {
			return errorJSON(c, fiber.StatusUnauthorized, "Missing session token")
		}
		_, traits, _, err := h.getKratosIdentityWithCookie(cookie)
		if err != nil {
			slog.Warn("[QR] Cookie session invalid", "error", err)
			return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
		}
		if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil && sessionID != "" {
			h.storeQrApproverSessionID(pendingRef, sessionID)
		}
		loginID := pickLoginIDFromTraits(traits)
		if loginID == "" {
			return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
		}
		if !strings.Contains(loginID, "@") {
			loginID = normalizePhoneForLoginID(loginID)
		}
		if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil {
			slog.Error("[QR] Start code login failed", "error", err)
			return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue web session")
		}
		return c.JSON(fiber.Map{"message": "QR Login Approved"})
	}

	// 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급
	approvedSessionID := ""
	if req.Token != "" {
		if sessionID, err := h.getKratosSessionID(req.Token); err == nil {
			approvedSessionID = sessionID
		}
	}
	if approvedSessionID != "" {
		h.storeQrApproverSessionID(pendingRef, approvedSessionID)
	}

	loginID, err := h.resolveKratosLoginID(req.Token)
	if err != nil {
		slog.Warn("[QR] Invalid token", "error", err)
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
	}
	if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil {
		slog.Error("[QR] Start code login failed", "error", err)
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue web session")
	}

	return c.JSON(fiber.Map{"message": "QR Login Approved"})
}

type kratosCourierRequest struct {
	Recipient    string         `json:"recipient"`
	TemplateType string         `json:"template_type"`
	TemplateData map[string]any `json:"template_data"`
	Subject      string         `json:"subject"`
	Body         string         `json:"body"`
}

// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
	var req kratosCourierRequest
	if err := c.BodyParser(&req); err != nil {
		slog.Error("[Kratos Courier] Body parsing failed", "error", err)
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
	}

	if req.Recipient == "" {
		slog.Warn("[Kratos Courier] Missing recipient")
		return errorJSON(c, fiber.StatusBadRequest, "Missing recipient")
	}

	loginID := req.Recipient
	if !strings.Contains(loginID, "@") {
		loginID = normalizePhoneForLoginID(loginID)
	}
	drySend := false
	if service.IsDryRunAllowed() {
		if val, _ := h.RedisService.Get(prefixDrySend + loginID); val != "" {
			if pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID); pendingRef != "" && pendingRef == val {
				drySend = true
			}
		}
	}
	if pendingRef, _ := h.RedisService.Get(prefixLoginCodeQrPending + loginID); pendingRef != "" {
		code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
		if code == "" {
			slog.Error("[QR] Missing login code in courier", "loginID", loginID)
			return errorJSON(c, fiber.StatusInternalServerError, "Missing login code")
		}
		flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
		if flowID == "" {
			slog.Error("[QR] Missing login flow for code verify", "loginID", loginID)
			return errorJSON(c, fiber.StatusInternalServerError, "Login flow expired")
		}
		authInfo, err := h.IdpProvider.VerifyLoginCode(loginID, flowID, code)
		if err != nil || authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
			slog.Error("[QR] Code verify failed", "loginID", loginID, "error", err)
			return errorJSON(c, fiber.StatusInternalServerError, "Failed to verify login code")
		}
		sessionData, _ := json.Marshal(map[string]string{
			"status": statusSuccess,
			"jwt":    authInfo.SessionToken.JWT,
		})
		h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
		h.writeQrAuditLog(loginID, pendingRef, authInfo.SessionToken, "")
		h.RedisService.Delete(prefixLoginCodeQrPending + loginID)
		h.RedisService.Delete(prefixLoginCode + loginID)
		h.RedisService.Delete(prefixLoginCodeQr + pendingRef)
		slog.Info("[QR] Code verified and session issued", "loginID", loginID, "pendingRef", pendingRef)
		return c.JSON(fiber.Map{"status": "ok"})
	}
	if pendingRef, _ := h.RedisService.Get(prefixQrPending + loginID); pendingRef != "" {
		code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
		if code == "" {
			slog.Error("[QR] Missing login code in courier", "loginID", loginID)
			return errorJSON(c, fiber.StatusInternalServerError, "Missing login code")
		}
		flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
		if flowID == "" {
			slog.Error("[QR] Missing login flow for code verify", "loginID", loginID)
			return errorJSON(c, fiber.StatusInternalServerError, "Login flow expired")
		}
		authInfo, err := h.IdpProvider.VerifyLoginCode(loginID, flowID, code)
		if err != nil || authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
			slog.Error("[QR] Code verify failed", "loginID", loginID, "error", err)
			return errorJSON(c, fiber.StatusInternalServerError, "Failed to verify login code")
		}
		sessionData, _ := json.Marshal(map[string]string{
			"status": statusSuccess,
			"jwt":    authInfo.SessionToken.JWT,
		})
		h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
		h.writeQrAuditLog(loginID, pendingRef, authInfo.SessionToken, "")
		h.RedisService.Delete(prefixQrPending + loginID)
		h.RedisService.Delete(prefixLoginCode + loginID)
		h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID)
		h.RedisService.Delete(prefixLoginCodeSmsLookup + loginID)
		slog.Info("[QR] Code verified and session issued", "loginID", loginID, "pendingRef", pendingRef)
		return c.JSON(fiber.Map{"status": "ok"})
	}

	subject, body := h.buildKratosCourierMessage(&req)
	if strings.TrimSpace(body) == "" {
		slog.Warn("[Kratos Courier] Empty body", "recipient", req.Recipient, "template", req.TemplateType)
		return errorJSON(c, fiber.StatusBadRequest, "Empty message")
	}

	if strings.Contains(req.Recipient, "@") {
		if target, _ := h.RedisService.Get(prefixLoginCodeSmsTarget + req.Recipient); target != "" {
			phone := sanitizePhoneForSms(target)
			smsBody := h.buildKratosShortSmsBody(&req, req.Recipient, phone)
			if smsBody == "" {
				smsBody = body
			}
			if drySend {
				slog.Info("[Kratos Courier][DrySend] SMS send skipped (email relay)", "to", phone, "template", req.TemplateType, "content", smsBody)
				return c.JSON(fiber.Map{"status": "ok"})
			}
			if err := h.SmsService.SendSms(phone, smsBody); err != nil {
				slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
				return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS")
			}
			slog.Info("[Kratos Courier] SMS sent (email relay)", "to", phone, "template", req.TemplateType)
			return c.JSON(fiber.Map{"status": "ok"})
		}
	}

	if strings.Contains(req.Recipient, "@") {
		if !drySend && h.EmailService == nil {
			return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured")
		}
		if shortSubject, shortBody := h.buildKratosShortEmailBody(&req, req.Recipient); shortBody != "" {
			subject = shortSubject
			body = shortBody
		}
		if drySend {
			slog.Info("[Kratos Courier][DrySend] Email send skipped", "to", req.Recipient, "template", req.TemplateType, "subject", subject)
			return c.JSON(fiber.Map{"status": "ok"})
		}
		if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil {
			slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err)
			return errorJSON(c, fiber.StatusInternalServerError, "Failed to send email")
		}
		slog.Info("[Kratos Courier] Email sent", "to", req.Recipient, "template", req.TemplateType)
		return c.JSON(fiber.Map{"status": "ok"})
	}

	if !drySend && h.SmsService == nil {
		return errorJSON(c, fiber.StatusInternalServerError, "SMS service not configured")
	}
	phone := sanitizePhoneForSms(req.Recipient)
	smsLoginID := req.Recipient
	if !strings.Contains(smsLoginID, "@") {
		lookup := normalizePhoneForLoginID(smsLoginID)
		if email, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookup); email != "" {
			smsLoginID = email
		} else {
			smsLoginID = lookup
		}
	}
	smsBody := h.buildKratosShortSmsBody(&req, smsLoginID, phone)
	if smsBody == "" {
		smsBody = body
	}
	if drySend {
		slog.Info("[Kratos Courier][DrySend] SMS send skipped", "to", phone, "template", req.TemplateType, "content", smsBody)
		return c.JSON(fiber.Map{"status": "ok"})
	}
	if err := h.SmsService.SendSms(phone, smsBody); err != nil {
		slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS")
	}
	slog.Info("[Kratos Courier] SMS sent", "to", phone, "template", req.TemplateType)
	return c.JSON(fiber.Map{"status": "ok"})
}

func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (string, string) {
	subject := strings.TrimSpace(req.Subject)
	body := strings.TrimSpace(req.Body)
	if body != "" || subject != "" {
		if subject == "" {
			subject = "[Baron 로그인] 알림"
		}
		return subject, body
	}

	templateType := strings.ToLower(req.TemplateType)
	loginCode := extractFirstString(req.TemplateData, "login_code")
	verificationCode := extractFirstString(req.TemplateData, "verification_code")
	recoveryCode := extractFirstString(req.TemplateData, "recovery_code")
	code := firstNonEmpty(loginCode, verificationCode, recoveryCode, extractFirstString(req.TemplateData, "code"))

	label := "알림"
	if loginCode != "" || strings.Contains(templateType, "login") {
		label = "로그인"
	} else if verificationCode != "" || strings.Contains(templateType, "verification") {
		label = "인증"
	} else if recoveryCode != "" || strings.Contains(templateType, "recovery") {
		label = "복구"
	} else if strings.Contains(templateType, "code") {
		label = "인증"
	}

	if subject == "" {
		if label == "알림" {
			subject = "[Baron 로그인] 알림"
		} else {
			subject = fmt.Sprintf("[Baron 로그인] %s 코드", label)
		}
	}

	if loginCode != "" && label == "로그인" {
		loginID := req.Recipient
		if !strings.Contains(loginID, "@") {
			loginID = normalizePhoneForLoginID(loginID)
		}
		pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID)
		if pendingRef != "" {
			normalizedCode := normalizeLoginCode(loginCode)
			if normalizedCode != "" {
				_ = h.RedisService.Set(prefixLoginCodeValue+pendingRef, normalizedCode, loginCodeExpiration)
			}
		}
	}

	if code == "" {
		return subject, fmt.Sprintf("[Baron 로그인] %s 요청이 도착했습니다", label)
	}

	message := fmt.Sprintf("[Baron 로그인] %s 코드: %s", label, code)
	if label == "로그인" {
		baseURL := os.Getenv("USERFRONT_URL")
		if baseURL == "" {
			baseURL = "http://localhost:5000"
		}
		baseURL = strings.TrimRight(baseURL, "/")
		loginID := req.Recipient
		if !strings.Contains(loginID, "@") {
			loginID = normalizePhoneForLoginID(loginID)
		}
		pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID)
		if pendingRef != "" {
			message = fmt.Sprintf("%s | 링크: %s/verify?loginId=%s&code=%s&pendingRef=%s",
				message,
				baseURL,
				url.QueryEscape(req.Recipient),
				url.QueryEscape(code),
				url.QueryEscape(pendingRef),
			)
			return subject, message
		}
		link := fmt.Sprintf("%s/verify?loginId=%s&code=%s",
			baseURL,
			url.QueryEscape(req.Recipient),
			url.QueryEscape(code),
		)
		message = fmt.Sprintf("%s | 링크: %s", message, link)
	}

	return subject, message
}

type shortLoginCodePayload struct {
	LoginID    string `json:"loginId"`
	Code       string `json:"code"`
	PendingRef string `json:"pendingRef"`
}

func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string {
	shortCode, link, ok := h.prepareKratosShortLogin(req, loginID)
	if !ok {
		return ""
	}
	if h.isSmsCodeOnly(loginID) {
		return fmt.Sprintf("[Baron 로그인] 로그인 코드: %s", shortCode)
	}
	return fmt.Sprintf("[Baron 로그인] %s", link)
}

func (h *AuthHandler) buildKratosShortEmailBody(req *kratosCourierRequest, loginID string) (string, string) {
	shortCode, link, ok := h.prepareKratosShortLogin(req, loginID)
	if !ok {
		return "", ""
	}
	subject := "[Baron 로그인] 로그인 링크"
	body := fmt.Sprintf(`
		<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
			<h2 style="color: #1A1F2C;">Baron 로그인</h2>
			<p>아래 버튼을 클릭하여 로그인을 완료해 주세요.</p>
			<div style="margin: 24px 0;">
				<a href="%s" style="background-color: #1A1F2C; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">로그인 완료하기</a>
			</div>
			<p style="font-size: 14px;">간편 코드: <strong>%s</strong></p>
			<p style="font-size: 12px; color: #888;">링크가 열리지 않으면 위 간편 코드를 입력해 로그인할 수 있습니다.</p>
		</div>
	`, link, shortCode)
	return subject, body
}

func (h *AuthHandler) prepareKratosShortLogin(req *kratosCourierRequest, loginID string) (string, string, bool) {
	if req == nil || loginID == "" {
		return "", "", false
	}
	code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
	if code == "" {
		return "", "", false
	}
	shortCode := h.generateShortCode(code)
	if shortCode == "" {
		return "", "", false
	}

	pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID)
	payload := shortLoginCodePayload{
		LoginID:    loginID,
		Code:       code,
		PendingRef: pendingRef,
	}
	raw, _ := json.Marshal(payload)
	_ = h.RedisService.Set(prefixLoginCodeShort+shortCode, string(raw), loginCodeExpiration)

	baseURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
	if baseURL == "" {
		baseURL = "http://localhost:5000"
	}

	link := fmt.Sprintf("%s/l/%s", baseURL, shortCode)
	return shortCode, link, true
}

func (h *AuthHandler) isSmsCodeOnly(loginID string) bool {
	if loginID == "" {
		return false
	}
	val, _ := h.RedisService.Get(prefixLoginCodeSmsOnly + loginID)
	return val != ""
}

func (h *AuthHandler) generateShortCode(code string) string {
	const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
	for range 10 {
		b := make([]byte, 2)
		if _, err := crand.Read(b); err != nil {
			break
		}
		prefix := string(letters[int(b[0])%len(letters)]) + string(letters[int(b[1])%len(letters)])
		shortCode := prefix + code
		if val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode); val == "" {
			return shortCode
		}
	}
	return ""
}

func normalizeLoginCode(code string) string {
	if code == "" {
		return ""
	}
	digits := make([]rune, 0, len(code))
	for _, ch := range code {
		if ch >= '0' && ch <= '9' {
			digits = append(digits, ch)
		}
	}
	if len(digits) < 6 {
		return ""
	}
	if len(digits) > 6 {
		digits = digits[:6]
	}
	return string(digits)
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if value != "" {
			return value
		}
	}
	return ""
}

func extractFirstString(data map[string]any, keys ...string) string {
	if data == nil {
		return ""
	}
	for _, key := range keys {
		if val, ok := data[key]; ok {
			if str, ok := val.(string); ok && str != "" {
				return str
			}
			// Handle numeric types by converting to string
			if num, ok := val.(float64); ok {
				return fmt.Sprint(num)
			}
			if num, ok := val.(int); ok {
				return fmt.Sprint(num)
			}
		}
	}
	return ""
}

func sanitizePhoneForSms(phone string) string {
	sanitized := domain.NormalizePhoneNumber(phone)
	if strings.HasPrefix(sanitized, "+82") {
		sanitized = "0" + sanitized[3:]
	}
	sanitized = strings.ReplaceAll(sanitized, "-", "")
	sanitized = strings.ReplaceAll(sanitized, " ", "")
	return sanitized
}

// --- User Profile Handlers ---

func (h *AuthHandler) formatPhoneForDisplay(phone string) string {
	if strings.HasPrefix(phone, "+8210") {
		return "010" + phone[5:]
	}
	return phone
}

func (h *AuthHandler) formatPhoneForStorage(phone string) string {
	return domain.NormalizePhoneNumber(phone)
}

// GetMe - Returns current user's profile with enriched data from local DB
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
	profile, err := h.resolveCurrentProfile(c)
	if err != nil {
		if isUserActivityForbiddenError(err) {
			return errorJSON(c, fiber.StatusForbidden, "This user status cannot perform Baron activity")
		}
		return errorJSON(c, fiber.StatusUnauthorized, err.Error())
	}
	return c.JSON(profile)
}

func (h *AuthHandler) resolveProfileForSubject(ctx context.Context, subject string) (*domain.UserProfileResponse, error) {
	subject = strings.TrimSpace(subject)
	if subject == "" || h.KratosAdmin == nil {
		return nil, fmt.Errorf("subject profile unavailable")
	}

	identity, err := h.KratosAdmin.GetIdentity(ctx, subject)
	if err != nil {
		return nil, err
	}
	if identity == nil {
		return nil, fmt.Errorf("identity not found")
	}

	profile := h.mapKratosIdentityToProfile(identity.ID, identity.Traits)
	if profile == nil {
		return nil, fmt.Errorf("failed to map identity profile")
	}
	return h.hydrateResolvedProfile(ctx, profile), nil
}

func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domain.UserProfileResponse) *domain.UserProfileResponse {
	if profile == nil {
		return nil
	}

	profile.Role = domain.NormalizeRole(profile.Role)
	if profile.Role == "" {
		profile.Role = domain.RoleUser
	}

	if h.TenantService != nil {
		if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
			if tenant, err := h.TenantService.GetTenant(ctx, *profile.TenantID); err == nil {
				profile.Tenant = tenant
			}
		}
	}

	if h.TenantService != nil {
		if profile.Role == "tenant_admin" {
			manageable, err := h.TenantService.ListManageableTenants(ctx, profile.ID)
			if err == nil {
				profile.ManageableTenants = manageable
			}
		}

		joined, err := h.TenantService.ListJoinedTenants(ctx, profile.ID)
		if err == nil {
			profile.JoinedTenants = joined
		}
	}

	return profile
}

// GetEnrichedProfile - Exported wrapper for resolveCurrentProfile used by middlewares
func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
	return h.resolveCurrentProfile(c)
}

func looksLikeJWT(token string) bool {
	return strings.Count(token, ".") == 2
}

func setSessionIDLocal(c *fiber.Ctx, token *domain.Token) {
	if c == nil || token == nil {
		return
	}
	if sessionID := extractSessionIDFromToken(token); sessionID != "" {
		c.Locals("session_id", sessionID)
	}
}

func extractSessionIDFromToken(token *domain.Token) string {
	if token == nil {
		return ""
	}
	if token.SessionID != "" {
		return token.SessionID
	}
	if token.JWT != "" {
		return extractSessionIDFromJWT(token.JWT)
	}
	return ""
}

func extractSessionIDFromJWT(token string) string {
	if !looksLikeJWT(token) {
		return ""
	}
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		return ""
	}
	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		payload, err = base64.URLEncoding.DecodeString(parts[1])
		if err != nil {
			return ""
		}
	}
	var claims map[string]any
	if err := json.Unmarshal(payload, &claims); err != nil {
		return ""
	}
	for _, key := range []string{"sid", "session_id", "sessionId", "jti"} {
		if raw, ok := claims[key]; ok {
			switch value := raw.(type) {
			case string:
				if value != "" {
					return value
				}
			default:
				return fmt.Sprint(value)
			}
		}
	}
	return ""
}

type qrMeta struct {
	IPAddress         string `json:"ip_address"`
	UserAgent         string `json:"user_agent"`
	ApproverSubject   string `json:"approver_subject,omitempty"`
	ApproverSessionID string `json:"approver_session_id,omitempty"`
}

func (h *AuthHandler) storeQrMeta(pendingRef string, c *fiber.Ctx) {
	if h.RedisService == nil || pendingRef == "" || c == nil {
		return
	}
	meta := qrMeta{
		IPAddress: extractClientIPFromHeaders(c),
		UserAgent: c.Get("User-Agent"),
	}
	raw, err := json.Marshal(meta)
	if err != nil {
		return
	}
	_ = h.RedisService.Set(prefixQrMeta+pendingRef, string(raw), 5*time.Minute)
}

func (h *AuthHandler) loadQrMeta(pendingRef string) (qrMeta, bool) {
	if h.RedisService == nil || pendingRef == "" {
		return qrMeta{}, false
	}
	val, err := h.RedisService.Get(prefixQrMeta + pendingRef)
	if err != nil || val == "" {
		return qrMeta{}, false
	}
	var meta qrMeta
	if err := json.Unmarshal([]byte(val), &meta); err != nil {
		return qrMeta{}, false
	}
	return meta, true
}

func (h *AuthHandler) storeLoginApproverMeta(pendingRef string, c *fiber.Ctx, ttl time.Duration) {
	if h.RedisService == nil || pendingRef == "" || c == nil {
		return
	}
	approverSubject, approverSessionID := h.resolveCurrentBrowserSessionEvidence(c)
	meta := qrMeta{
		IPAddress:         extractClientIPFromHeaders(c),
		UserAgent:         c.Get("User-Agent"),
		ApproverSubject:   approverSubject,
		ApproverSessionID: approverSessionID,
	}
	raw, err := json.Marshal(meta)
	if err != nil {
		return
	}
	_ = h.RedisService.Set(prefixLoginApproverMeta+pendingRef, string(raw), ttl)
}

func (h *AuthHandler) loadLoginApproverMeta(pendingRef string) (qrMeta, bool) {
	if h.RedisService == nil || pendingRef == "" {
		return qrMeta{}, false
	}
	val, err := h.RedisService.Get(prefixLoginApproverMeta + pendingRef)
	if err != nil || val == "" {
		return qrMeta{}, false
	}
	var meta qrMeta
	if err := json.Unmarshal([]byte(val), &meta); err != nil {
		return qrMeta{}, false
	}
	if meta.IPAddress == "" && meta.UserAgent == "" {
		return qrMeta{}, false
	}
	return meta, true
}

func (h *AuthHandler) storeQrApproverSessionID(pendingRef, sessionID string) {
	if h.RedisService == nil || pendingRef == "" || sessionID == "" {
		return
	}
	_ = h.RedisService.Set(prefixQrApproverSession+pendingRef, sessionID, loginCodeExpiration)
}

func (h *AuthHandler) loadQrApproverSessionID(pendingRef string) string {
	if h.RedisService == nil || pendingRef == "" {
		return ""
	}
	val, err := h.RedisService.Get(prefixQrApproverSession + pendingRef)
	if err != nil {
		return ""
	}
	return strings.TrimSpace(val)
}

func (h *AuthHandler) storeLoginMeta(pendingRef, loginID, rawMethod, flow, strategy string, ttl time.Duration) {
	if h.RedisService == nil || pendingRef == "" {
		return
	}
	method := resolveLoginMethod(rawMethod, loginID)
	if method != "" {
		_ = h.RedisService.Set(prefixLoginMethod+pendingRef, method, ttl)
	}
	if flow != "" {
		_ = h.RedisService.Set(prefixLoginFlow+pendingRef, flow, ttl)
	}
	if strategy != "" {
		_ = h.RedisService.Set(prefixLoginStrategy+pendingRef, strategy, ttl)
	}
	if strings.TrimSpace(loginID) != "" {
		_ = h.RedisService.Set(prefixLoginIDRaw+pendingRef, loginID, ttl)
	}
}

func (h *AuthHandler) loadLoginMeta(pendingRef string) (string, string, string, string) {
	if h.RedisService == nil || pendingRef == "" {
		return "", "", "", ""
	}
	method, _ := h.RedisService.Get(prefixLoginMethod + pendingRef)
	flow, _ := h.RedisService.Get(prefixLoginFlow + pendingRef)
	strategy, _ := h.RedisService.Get(prefixLoginStrategy + pendingRef)
	rawLoginID, _ := h.RedisService.Get(prefixLoginIDRaw + pendingRef)
	return strings.TrimSpace(method), strings.TrimSpace(flow), strings.TrimSpace(strategy), strings.TrimSpace(rawLoginID)
}

func (h *AuthHandler) loadLoginFlow(pendingRef string) string {
	_, flow, _, _ := h.loadLoginMeta(pendingRef)
	return flow
}

func (h *AuthHandler) loadLoginStrategy(pendingRef string) string {
	_, _, strategy, _ := h.loadLoginMeta(pendingRef)
	return strategy
}

func (h *AuthHandler) clearLoginMeta(pendingRef string) {
	if h.RedisService == nil || pendingRef == "" {
		return
	}
	_ = h.RedisService.Delete(prefixLoginMethod + pendingRef)
	_ = h.RedisService.Delete(prefixLoginFlow + pendingRef)
	_ = h.RedisService.Delete(prefixLoginStrategy + pendingRef)
	_ = h.RedisService.Delete(prefixLoginIDRaw + pendingRef)
	_ = h.RedisService.Delete(prefixLoginApproverMeta + pendingRef)
}

func (h *AuthHandler) writeQrAuditLog(loginID, pendingRef string, sessionToken *domain.Token, approvedSessionID string) {
	if h.AuditRepo == nil || pendingRef == "" {
		return
	}
	meta, ok := h.loadQrMeta(pendingRef)
	if !ok {
		meta = qrMeta{
			IPAddress: "",
			UserAgent: "",
		}
	}
	if approvedSessionID == "" {
		approvedSessionID = h.loadQrApproverSessionID(pendingRef)
	}
	sessionID := extractSessionIDFromToken(sessionToken)
	details := map[string]any{
		"path":        "/api/v1/auth/qr/approve",
		"login_id":    loginID,
		"pending_ref": pendingRef,
	}
	if sessionID != "" {
		details["session_id"] = sessionID
	}
	if approvedSessionID != "" {
		details["approved_session_id"] = approvedSessionID
	}
	detailsJSON, _ := json.Marshal(details)

	log := &domain.AuditLog{
		EventID:    GenerateSecureToken(16),
		Timestamp:  time.Now(),
		UserID:     "",
		SessionID:  sessionID,
		EventType:  "POST /api/v1/auth/qr/approve",
		Status:     "success",
		IPAddress:  meta.IPAddress,
		UserAgent:  meta.UserAgent,
		Details:    string(detailsJSON),
		AuthMethod: "QR",
	}
	_ = h.AuditRepo.Create(log)
}

func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken *domain.Token, c *fiber.Ctx) {
	if h.AuditRepo == nil {
		return
	}
	meta := qrMeta{
		IPAddress: extractClientIPFromHeaders(c),
		UserAgent: "",
	}
	if c != nil {
		meta.UserAgent = c.Get("User-Agent")
	}
	sessionID := extractSessionIDFromToken(sessionToken)
	loginMethod, loginFlow, loginStrategy, rawLoginID := h.loadLoginMeta(pendingRef)
	path := "/api/v1/auth/magic-link/verify"
	authLabel := "링크"
	if loginStrategy == loginFlowCode {
		path = "/api/v1/auth/login/code/verify"
	}
	displayFlow := loginFlow
	if displayFlow == "" {
		displayFlow = loginStrategy
	}
	if displayFlow == loginFlowCode {
		authLabel = "코드"
	} else if displayFlow == loginFlowLink {
		authLabel = "링크"
	}
	logLoginID := loginID
	if rawLoginID != "" {
		logLoginID = rawLoginID
	}
	details := map[string]any{
		"path":        path,
		"login_id":    logLoginID,
		"pending_ref": pendingRef,
	}
	if sessionID != "" {
		details["session_id"] = sessionID
	}
	if loginMethod != "" {
		details["login_method"] = loginMethod
	}
	if loginFlow != "" {
		details["login_flow"] = loginFlow
	}
	if loginStrategy != "" {
		details["login_strategy"] = loginStrategy
	}
	if rawLoginID != "" && rawLoginID != loginID {
		details["login_id_effective"] = loginID
	}
	if state, ok := h.loadHeadlessLinkState(pendingRef); ok {
		if strings.TrimSpace(state.ClientID) != "" {
			details["client_id"] = strings.TrimSpace(state.ClientID)
		}
		clientName := strings.TrimSpace(state.ClientName)
		if clientName == "" && strings.TrimSpace(state.ClientID) != "" {
			clientName = strings.TrimSpace(state.ClientID)
		}
		if clientName != "" {
			details["client_name"] = clientName
		}
		if strings.TrimSpace(state.LoginChallenge) != "" {
			details["login_challenge"] = strings.TrimSpace(state.LoginChallenge)
		}
	}
	if approverMeta, ok := h.loadLoginApproverMeta(pendingRef); ok {
		if approverMeta.IPAddress != "" {
			details["approved_ip"] = approverMeta.IPAddress
		}
		if approverMeta.UserAgent != "" {
			details["approved_user_agent"] = approverMeta.UserAgent
		}
	}
	detailsJSON, _ := json.Marshal(details)

	log := &domain.AuditLog{
		EventID:    GenerateSecureToken(16),
		Timestamp:  time.Now(),
		UserID:     "",
		SessionID:  sessionID,
		EventType:  fmt.Sprintf("POST %s", path),
		Status:     "success",
		IPAddress:  meta.IPAddress,
		UserAgent:  meta.UserAgent,
		Details:    string(detailsJSON),
		AuthMethod: authLabel,
	}
	_ = h.AuditRepo.Create(log)
}

func extractClientIPFromHeaders(c *fiber.Ctx) string {
	if c == nil {
		return ""
	}
	return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP())
}

type authTimelineItem struct {
	EventID         string    `json:"event_id"`
	Timestamp       time.Time `json:"timestamp"`
	UserID          string    `json:"user_id"`
	SessionID       string    `json:"session_id,omitempty"`
	EventType       string    `json:"event_type"`
	Status          string    `json:"status"`
	AuthMethod      string    `json:"auth_method,omitempty"`
	IPAddress       string    `json:"ip_address"`
	UserAgent       string    `json:"user_agent"`
	Details         string    `json:"details,omitempty"`
	Source          string    `json:"source,omitempty"`
	ClientID        string    `json:"client_id,omitempty"`
	AppName         string    `json:"app_name,omitempty"`
	ParentSessionID string    `json:"parent_session_id,omitempty"`
}

type consentClientInfo struct {
	ClientID  string
	Name      string
	ConsentAt time.Time
}

type loginClientInfo struct {
	ClientID string
	Name     string
}

func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
	if h.AuditRepo == nil && h.OathkeeperRepo == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
	}

	limit := c.QueryInt("limit", 20)
	if limit <= 0 {
		limit = 20
	}
	if limit > 100 {
		limit = 100
	}

	cursorRaw := strings.TrimSpace(c.Query("cursor"))
	var cursor *domain.AuditCursor
	if cursorRaw != "" {
		var err error
		cursor, err = parseAuditCursor(cursorRaw)
		if err != nil {
			return errorJSON(c, fiber.StatusBadRequest, "Invalid cursor")
		}
	}

	profile, err := h.resolveCurrentProfile(c)
	if err != nil {
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
	}
	currentSessionID := ""
	if token := h.getBearerToken(c); token != "" {
		currentSessionID = extractSessionIDFromJWT(token)
	}
	if currentSessionID == "" {
		if cookie := c.Get("Cookie"); cookie != "" {
			if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil {
				currentSessionID = sessionID
			}
		}
	}

	subject := ""
	if h.OathkeeperRepo != nil {
		if value, err := h.resolveConsentSubject(c); err == nil {
			subject = value
		}
	}

	consentMap := make(map[string]consentClientInfo)
	if subject != "" && h.Hydra != nil {
		if sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, ""); err == nil {
			for _, session := range sessions {
				client := session.Client
				if client.ClientID == "" && session.ConsentRequest != nil {
					client = session.ConsentRequest.Client
				}
				clientID := strings.TrimSpace(client.ClientID)
				if clientID == "" {
					continue
				}
				name := strings.TrimSpace(client.ClientName)
				if name == "" {
					name = clientID
				}
				consentAt := time.Time{}
				if session.AuthenticatedAt != nil {
					consentAt = *session.AuthenticatedAt
				} else if session.RequestedAt != nil {
					consentAt = *session.RequestedAt
				} else if session.HandledAt != nil {
					consentAt = *session.HandledAt
				}
				if existing, ok := consentMap[clientID]; ok {
					if !consentAt.IsZero() && (existing.ConsentAt.IsZero() || consentAt.Before(existing.ConsentAt)) {
						existing.ConsentAt = consentAt
						consentMap[clientID] = existing
					}
					if existing.Name == "" {
						existing.Name = name
						consentMap[clientID] = existing
					}
					continue
				}
				consentMap[clientID] = consentClientInfo{
					ClientID:  clientID,
					Name:      name,
					ConsentAt: consentAt,
				}
			}
		}
	}

	candidates := buildLoginCandidates(profile)
	fetchLimit := max(limit*10, limit)
	if fetchLimit > 500 {
		fetchLimit = 500
	}

	authLogs := make([]domain.AuditLog, 0, fetchLimit)
	if h.AuditRepo != nil {
		currentCursor := cursor
		const maxBatches = 10
		for batch := 0; batch < maxBatches && len(authLogs) < fetchLimit; batch++ {
			logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor, "")
			if err != nil {
				return errorJSON(c, fiber.StatusInternalServerError, "Failed to retrieve audit logs")
			}
			if len(logs) == 0 {
				break
			}

			var lastScanned *domain.AuditLog
			for i := range logs {
				log := logs[i]
				lastScanned = &log
				if !isAuthEventType(log.EventType) {
					continue
				}
				if !matchesAuthTimelineUser(log, profile, candidates, currentSessionID) {
					continue
				}
				if shouldSkipAuthTimeline(log) {
					continue
				}
				if log.UserID == "" {
					log.UserID = profile.ID
				}
				log.AuthMethod = deriveAuthMethod(log)
				if log.AuthMethod == "" {
					continue
				}
				if log.SessionID == "" {
					log.SessionID = extractSessionIDFromAuditDetails(log.Details)
				}
				authLogs = append(authLogs, log)
				if len(authLogs) >= fetchLimit {
					break
				}
			}

			if len(logs) < fetchLimit || lastScanned == nil {
				break
			}
			currentCursor = &domain.AuditCursor{
				Timestamp: lastScanned.Timestamp,
				EventID:   lastScanned.EventID,
			}
		}
	}

	oathkeeperLogs := make([]domain.OathkeeperAccessLog, 0, fetchLimit)
	if h.OathkeeperRepo != nil && subject != "" {
		currentCursor := cursor
		const maxBatches = 10
		for batch := 0; batch < maxBatches && len(oathkeeperLogs) < fetchLimit; batch++ {
			logs, err := h.OathkeeperRepo.FindPageBySubject(c.Context(), subject, fetchLimit, currentCursor)
			if err != nil {
				slog.Warn("Failed to retrieve oathkeeper logs", "error", err)
				break
			}
			if len(logs) == 0 {
				break
			}
			var lastScanned *domain.OathkeeperAccessLog
			for i := range logs {
				log := logs[i]
				lastScanned = &log
				clientID := extractClientIDFromOathkeeperLog(log)
				if clientID == "" {
					continue
				}
				consent, ok := consentMap[clientID]
				if ok {
					if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) {
						continue
					}
				}
				oathkeeperLogs = append(oathkeeperLogs, log)
				if len(oathkeeperLogs) >= fetchLimit {
					break
				}
			}

			if len(logs) < fetchLimit || lastScanned == nil {
				break
			}
			currentCursor = &domain.AuditCursor{
				Timestamp: lastScanned.Timestamp,
				EventID:   oathkeeperEventID(*lastScanned),
			}
		}
	}

	loginChallengeCache := make(map[string]loginClientInfo)
	resolveLoginClient := func(challenge string) (loginClientInfo, bool) {
		challenge = strings.TrimSpace(challenge)
		if challenge == "" || h.Hydra == nil {
			return loginClientInfo{}, false
		}
		if cached, ok := loginChallengeCache[challenge]; ok {
			return cached, cached.ClientID != ""
		}
		loginReq, err := h.Hydra.GetLoginRequest(c.Context(), challenge)
		if err != nil || loginReq == nil {
			loginChallengeCache[challenge] = loginClientInfo{}
			return loginClientInfo{}, false
		}
		clientID := strings.TrimSpace(loginReq.Client.ClientID)
		if clientID == "" {
			loginChallengeCache[challenge] = loginClientInfo{}
			return loginClientInfo{}, false
		}
		name := strings.TrimSpace(loginReq.Client.ClientName)
		if name == "" {
			name = clientID
		}
		info := loginClientInfo{
			ClientID: clientID,
			Name:     name,
		}
		loginChallengeCache[challenge] = info
		return info, true
	}

	clientCache := make(map[string]loginClientInfo)
	resolveClientByID := func(cid string) (loginClientInfo, bool) {
		cid = strings.TrimSpace(cid)
		if cid == "" || h.Hydra == nil {
			return loginClientInfo{}, false
		}
		if cached, ok := clientCache[cid]; ok {
			return cached, cached.ClientID != ""
		}
		client, err := h.Hydra.GetClient(c.Context(), cid)
		if err != nil || client == nil {
			clientCache[cid] = loginClientInfo{}
			return loginClientInfo{}, false
		}
		name := strings.TrimSpace(client.ClientName)
		if name == "" {
			name = cid
		}
		info := loginClientInfo{
			ClientID: cid,
			Name:     name,
		}
		clientCache[cid] = info
		return info, true
	}

	items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs))
	for i := range authLogs {
		log := authLogs[i]
		appName := "Baron 로그인"
		clientID := ""
		path := strings.ToLower(extractAuditPath(log))

		isOidcAccept := strings.Contains(path, "/api/v1/auth/oidc/login/accept")
		isPasswordLogin := strings.Contains(path, "/api/v1/auth/password/login") ||
			strings.Contains(path, "/api/v1/auth/headless/password/login")

		// 우선 audit details의 client 정보를 사용
		if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil {
			if cid, ok := details["client_id"].(string); ok && strings.TrimSpace(cid) != "" {
				clientID = strings.TrimSpace(cid)
			}
			if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" {
				appName = strings.TrimSpace(name)
			}
		}

		// 기본값이거나 클라이언트 ID인 경우 Hydra 조회로 보강
		if appName == "Baron 로그인" || appName == "" {
			if isOidcAccept {
				appName = "OIDC 로그인"
			}
			if clientID != "" {
				appName = clientID
				if info, ok := resolveClientByID(clientID); ok {
					appName = info.Name
				}
			}
		}

		if (isOidcAccept || isPasswordLogin) && (appName == "OIDC 로그인" || appName == "Baron 로그인" || appName == clientID) {
			loginChallenge := extractLoginChallengeFromAuditDetails(log.Details)
			if loginChallenge != "" {
				if info, ok := resolveLoginClient(loginChallenge); ok {
					appName = info.Name
					clientID = info.ClientID
				}
			}
		}

		item := authTimelineItem{
			EventID:    log.EventID,
			Timestamp:  log.Timestamp,
			UserID:     log.UserID,
			SessionID:  log.SessionID,
			EventType:  log.EventType,
			Status:     log.Status,
			AuthMethod: log.AuthMethod,
			IPAddress:  log.IPAddress,
			UserAgent:  log.UserAgent,
			Details:    log.Details,
			Source:     "backend",
			AppName:    appName,
			ClientID:   clientID,
		}
		items = append(items, item)
	}

	for i := range oathkeeperLogs {
		log := oathkeeperLogs[i]
		clientID := extractClientIDFromOathkeeperLog(log)
		if clientID == "" {
			continue
		}

		appName := clientID
		if consent, ok := consentMap[clientID]; ok {
			appName = consent.Name
		}
		if appName == "" || appName == clientID {
			if info, ok := resolveClientByID(clientID); ok {
				appName = info.Name
			}
		}

		details := map[string]any{
			"path":        log.Path,
			"client_id":   clientID,
			"decision":    log.Decision,
			"status_code": log.Status,
		}
		detailsJSON, _ := json.Marshal(details)
		status := "success"
		if log.Status >= 400 {
			status = "failure"
		}
		eventID := oathkeeperEventID(log)
		item := authTimelineItem{
			EventID:    eventID,
			Timestamp:  log.Timestamp,
			UserID:     profile.ID,
			SessionID:  extractSessionIDFromOathkeeperLog(log),
			EventType:  fmt.Sprintf("%s %s", log.Method, log.Path),
			Status:     status,
			AuthMethod: "세션 위임",
			IPAddress:  log.ClientIP,
			UserAgent:  log.UserAgent,
			Details:    string(detailsJSON),
			Source:     "oathkeeper",
			ClientID:   clientID,
			AppName:    appName,
		}
		items = append(items, item)
	}

	sort.Slice(items, func(i, j int) bool {
		if items[i].Timestamp.Equal(items[j].Timestamp) {
			return items[i].EventID > items[j].EventID
		}
		return items[i].Timestamp.After(items[j].Timestamp)
	})

	nextCursor := ""
	hasMore := len(authLogs) >= fetchLimit || len(oathkeeperLogs) >= fetchLimit
	if len(items) > limit {
		items = items[:limit]
		last := items[len(items)-1]
		nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID)
	} else if hasMore && len(items) > 0 {
		last := items[len(items)-1]
		nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID)
	}

	return c.JSON(fiber.Map{
		"items":       items,
		"limit":       limit,
		"cursor":      cursorRaw,
		"next_cursor": nextCursor,
	})
}

func encodeTimelineCursor(timestamp time.Time, eventID string) string {
	if eventID == "" {
		eventID = fmt.Sprintf("%d", timestamp.UnixNano())
	}
	payload := timestamp.UTC().Format(time.RFC3339Nano) + "|" + eventID
	return base64.RawURLEncoding.EncodeToString([]byte(payload))
}

func oathkeeperEventID(log domain.OathkeeperAccessLog) string {
	if log.RequestID != "" {
		return log.RequestID
	}
	if log.TraceID != "" {
		return log.TraceID
	}
	if log.SpanID != "" {
		return log.SpanID
	}
	return fmt.Sprintf("%d", log.Timestamp.UnixNano())
}

type linkedRpSummary struct {
	ID                  string   `json:"id"`
	Name                string   `json:"name"`
	Logo                string   `json:"logo,omitempty"`
	URL                 string   `json:"url,omitempty"`
	InitURL             string   `json:"init_url,omitempty"`
	AutoLoginSupported  bool     `json:"auto_login_supported"`
	AutoLoginURL        string   `json:"auto_login_url,omitempty"`
	LastAuthenticatedAt string   `json:"lastAuthenticatedAt,omitempty"`
	Status              string   `json:"status"`
	Scopes              []string `json:"scopes,omitempty"`
}

type linkedRpListResponse struct {
	Items []linkedRpSummary `json:"items"`
}

type linkedRpRecord struct {
	linkedRpSummary
	lastAuth time.Time
}

func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
	if h.Hydra == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "hydra admin unavailable")
	}

	subjects, err := h.resolveConsentSubjects(c)
	if err != nil || len(subjects) == 0 {
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
	}

	var sessions []domain.HydraConsentSession

	var lastErr error
	hasSuccess := false
	for _, subject := range subjects {
		subject = strings.TrimSpace(subject)
		if subject == "" {
			continue
		}
		linked, listErr := h.Hydra.ListConsentSessions(c.Context(), subject, "")
		if listErr != nil {
			lastErr = listErr
			continue
		}
		hasSuccess = true
		sessions = append(sessions, linked...)
	}
	if !hasSuccess && lastErr != nil {
		return errorJSON(c, fiber.StatusInternalServerError, lastErr.Error())
	}

	records := make(map[string]*linkedRpRecord)
	for _, session := range sessions {
		client := session.Client
		if client.ClientID == "" && session.ConsentRequest != nil {
			client = session.ConsentRequest.Client
		}
		clientID := strings.TrimSpace(client.ClientID)
		if clientID == "" {
			continue
		}

		name := strings.TrimSpace(client.ClientName)
		if name == "" {
			name = clientID
		}

		clientURL := resolveLinkedRPURL(
			client.ClientID,
			client.ClientURI,
			client.RedirectURIs,
		)

		lastAuth := time.Time{}
		if session.AuthenticatedAt != nil {
			lastAuth = *session.AuthenticatedAt
		} else if session.RequestedAt != nil {
			lastAuth = *session.RequestedAt
		} else if session.HandledAt != nil {
			lastAuth = *session.HandledAt
		}

		scopes := session.GrantedScope
		if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" {
			scopes = strings.Fields(client.Scope)
		}
		autoLoginSupported := resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
		autoLoginURL := resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
		initURL := resolveLinkedRPInitURL(client.ClientID, client.Metadata)

		existing := records[clientID]
		if existing == nil {
			records[clientID] = &linkedRpRecord{
				linkedRpSummary: linkedRpSummary{
					ID:                 clientID,
					Name:               name,
					Logo:               extractHydraClientLogo(client.Metadata),
					URL:                clientURL,
					InitURL:            initURL,
					AutoLoginSupported: autoLoginSupported,
					AutoLoginURL:       autoLoginURL,
					Status:             "active", // Hydra 세션이 있으면 활성
					Scopes:             scopes,
				},
				lastAuth: lastAuth,
			}
			continue
		}

		if existing.Name == "" {
			existing.Name = name
		}
		if existing.Logo == "" {
			existing.Logo = extractHydraClientLogo(client.Metadata)
		}
		if existing.URL == "" {
			existing.URL = clientURL
		}
		if existing.InitURL == "" {
			existing.InitURL = initURL
		}
		if !existing.AutoLoginSupported {
			existing.AutoLoginSupported = autoLoginSupported
		}
		if existing.AutoLoginURL == "" {
			existing.AutoLoginURL = autoLoginURL
		}
		existing.Scopes = mergeScopes(existing.Scopes, scopes)
		if lastAuth.After(existing.lastAuth) {
			existing.lastAuth = lastAuth
		}
	}

	// Consent session payload may omit metadata fields such as logo_url.
	// Rehydrate missing display fields from the full Hydra client object.
	for clientID, record := range records {
		if record == nil {
			continue
		}
		needsHydraLookup := record.Logo == "" || record.URL == "" || record.InitURL == ""
		if !needsHydraLookup {
			continue
		}

		client, err := h.Hydra.GetClient(c.Context(), clientID)
		if err != nil {
			continue
		}

		if record.Name == "" {
			name := strings.TrimSpace(client.ClientName)
			if name == "" {
				name = client.ClientID
			}
			record.Name = name
		}
		if record.Logo == "" {
			record.Logo = extractHydraClientLogo(client.Metadata)
		}
		if record.URL == "" {
			record.URL = resolveLinkedRPURL(
				client.ClientID,
				client.ClientURI,
				client.RedirectURIs,
			)
		}
		if record.InitURL == "" {
			record.InitURL = resolveLinkedRPInitURL(client.ClientID, client.Metadata)
		}
		if !record.AutoLoginSupported {
			record.AutoLoginSupported = resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
		}
		if record.AutoLoginURL == "" {
			record.AutoLoginURL = resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
		}
	}

	// [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함)
	if h.ConsentRepo != nil {
		for _, subject := range subjects {
			dbConsents, err := h.ConsentRepo.ListBySubject(c.Context(), subject)
			if err != nil {
				slog.Error("failed to list db consents for subject", "subject", subject, "error", err)
				continue
			}

			for _, dc := range dbConsents {
				if _, exists := records[dc.ClientID]; exists {
					// 이미 Hydra 세션으로 존재하면 skip (active 우선)
					continue
				}

				// 삭제된 권한일 경우
				status := "inactive"
				if dc.DeletedAt.Valid {
					status = "revoked"
				}

				// Hydra에서 클라이언트 정보 조회 (메타데이터용)
				client, err := h.Hydra.GetClient(c.Context(), dc.ClientID)
				if err != nil {
					slog.Error("failed to get client info from hydra for inactive rp", "client_id", dc.ClientID, "error", err)
					// Hydra에 정보가 없더라도 기본 정보로 추가
					records[dc.ClientID] = &linkedRpRecord{
						linkedRpSummary: linkedRpSummary{
							ID:     dc.ClientID,
							Name:   dc.ClientID,
							Status: status,
							Scopes: dc.GrantedScopes,
						},
						lastAuth: dc.UpdatedAt,
					}
					continue
				}

				name := strings.TrimSpace(client.ClientName)
				if name == "" {
					name = client.ClientID
				}

				clientURL := resolveLinkedRPURL(
					client.ClientID,
					client.ClientURI,
					client.RedirectURIs,
				)
				autoLoginSupported := resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
				autoLoginURL := resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
				initURL := resolveLinkedRPInitURL(client.ClientID, client.Metadata)

				records[dc.ClientID] = &linkedRpRecord{
					linkedRpSummary: linkedRpSummary{
						ID:                 dc.ClientID,
						Name:               name,
						Logo:               extractHydraClientLogo(client.Metadata),
						URL:                clientURL,
						InitURL:            initURL,
						AutoLoginSupported: autoLoginSupported,
						AutoLoginURL:       autoLoginURL,
						Status:             status,
						Scopes:             dc.GrantedScopes,
					},
					lastAuth: dc.UpdatedAt,
				}
			}
		}
	}

	// [New] Audit Log Scan for recent history fallback (timeline 200 items)
	// Hydra 세션이나 로컬 DB(ConsentRepo)에 없지만 최근 활동 이력이 있는 앱을 보강
	if h.AuditRepo != nil {
		for _, subject := range subjects {
			auditLogs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), subject, []string{"consent.granted", "consent.revoked"}, 200)
			if err != nil {
				slog.Error("failed to scan audit logs for linked rps", "error", err, "subject", subject)
				continue
			}
			for _, log := range auditLogs {
				var details struct {
					ClientID   string `json:"client_id"`
					ClientName string `json:"client_name"`
					Scopes     any    `json:"scopes"`
				}
				// 로그 Details 파싱
				if err := json.Unmarshal([]byte(log.Details), &details); err != nil {
					continue
				}
				if details.ClientID == "" {
					continue
				}

				// 이미 records에 있으면(Active or ConsentRepo) 패스
				if _, exists := records[details.ClientID]; exists {
					continue
				}

				// 스코프 추출 (consent.granted인 경우)
				scopes := []string{}
				if sList, ok := details.Scopes.([]any); ok {
					for _, s := range sList {
						if str, ok := s.(string); ok {
							scopes = append(scopes, str)
						}
					}
				}

				// 기본 레코드 생성
				record := &linkedRpRecord{
					linkedRpSummary: linkedRpSummary{
						ID:     details.ClientID,
						Name:   details.ClientName, // revoked 로그일 경우 비어있을 수 있음
						Status: "inactive",
						Scopes: scopes,
					},
					lastAuth: log.Timestamp,
				}

				// Hydra에서 최신 메타데이터 조회 시도
				client, err := h.Hydra.GetClient(c.Context(), details.ClientID)
				if err == nil {
					name := strings.TrimSpace(client.ClientName)
					if name == "" {
						name = client.ClientID
					}
					record.Name = name
					record.Logo = extractHydraClientLogo(client.Metadata)

					clientURL := strings.TrimSpace(client.ClientURI)
					if clientURL == "" && len(client.RedirectURIs) > 0 {
						if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
							clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
						}
					}
					record.URL = clientURL
					record.InitURL = resolveLinkedRPInitURL(client.ClientID, client.Metadata)
					record.AutoLoginSupported = resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
					record.AutoLoginURL = resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
				} else {
					// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
					if record.Name == "" {
						record.Name = details.ClientID
					}
				}

				records[details.ClientID] = record
			}
		}
	}

	ordered := make([]*linkedRpRecord, 0, len(records))
	for _, record := range records {
		ordered = append(ordered, record)
	}
	sort.Slice(ordered, func(i, j int) bool {
		return ordered[i].lastAuth.After(ordered[j].lastAuth)
	})

	items := make([]linkedRpSummary, 0, len(ordered))
	for i, record := range ordered {
		if i >= 100 {
			break
		}
		if !record.lastAuth.IsZero() {
			record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339)
		}
		items = append(items, record.linkedRpSummary)
	}

	return c.JSON(linkedRpListResponse{Items: items})
}

func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
	clientID := c.Params("id")
	if clientID == "" {
		return fiber.NewError(fiber.StatusBadRequest, "client_id is required")
	}

	subject, err := h.resolveConsentSubject(c)
	if err != nil || subject == "" {
		return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
	}
	profile, profileErr := h.resolveCurrentProfile(c)
	if (profileErr != nil || profile == nil) && subject != "" {
		if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), subject); fallbackErr == nil {
			profile = fallbackProfile
		}
	}

	slog.Info("RevokeLinkedRp called", "subject", subject, "client_id", clientID)

	if h.Hydra == nil {
		return fiber.NewError(fiber.StatusServiceUnavailable, "hydra admin unavailable")
	}

	// Hydra에서 해당 사용자와 클라이언트의 모든 동의 세션을 삭제
	if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
		slog.Error("failed to revoke hydra consent sessions", "error", err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke link")
	}
	if h.ConsentRepo != nil {
		if err := h.ConsentRepo.Delete(c.Context(), subject, clientID); err != nil {
			slog.Error("failed to delete local consent after hydra revoke", "error", err, "subject", subject, "client_id", clientID)
			return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke local consent")
		}
	}

	if h.AuditRepo != nil {
		detailsMap := map[string]any{
			"client_id": clientID,
		}
		detailsBytes, _ := json.Marshal(detailsMap)

		_ = h.AuditRepo.Create(&domain.AuditLog{
			EventID:   GenerateSecureToken(16),
			Timestamp: time.Now(),
			UserID:    subject,
			EventType: "consent.revoked",
			Status:    "success",
			IPAddress: c.IP(),
			UserAgent: string(c.Request().Header.UserAgent()),
			Details:   string(detailsBytes),
		})
	}

	if err := h.emitRPUsageAuthorizationRevoked(c, subject, clientID, profile, h.resolveCurrentSessionID(c)); err != nil {
		slog.Error("failed to emit rp usage event for revoked consent", "error", err, "client_id", clientID, "subject", subject)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
	}

	h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "")

	return c.Status(fiber.StatusOK).JSON(fiber.Map{
		"status":  "success",
		"message": "Link revoked successfully",
	})
}

func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
	challenge := c.Query("consent_challenge")
	if challenge == "" {
		return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required")
	}

	consentRequest, err := h.Hydra.GetConsentRequest(c.Context(), challenge)
	if err != nil {
		slog.Error("failed to get hydra consent request", "error", err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
	}
	consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)

	// [DEBUG] Hydra 응답 상세 로깅
	slog.Info("GetConsentRequest Debug",
		"challenge", challenge,
		"skip", consentRequest.Skip,
		"subject", consentRequest.Subject,
		"client_id", consentRequest.Client.ClientID,
		"scopes", consentRequest.RequestedScope,
	)

	profile, err := h.resolveCurrentProfile(c)
	if (err != nil || profile == nil) && consentRequest.Subject != "" {
		if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), consentRequest.Subject); fallbackErr == nil {
			profile = fallbackProfile
			err = nil
		}
	}
	if enforceClientTenantAccess(c, h.TenantService, consentRequest.Client, profile, err) {
		return nil
	}

	// [New] 로컬 DB에서 기존 동의 내역 확인 (강제 자동 승인 전략)
	// Hydra가 skip을 주지 않더라도, 우리 DB에 이미 기록이 있다면 승인 처리함
	if !consentRequest.Skip && h.ConsentRepo != nil && consentRequest.Subject != "" {
		existingConsent, err := h.ConsentRepo.Find(c.Context(), consentRequest.Client.ClientID, consentRequest.Subject)
		if err == nil && existingConsent != nil {
			// 요청된 스코프가 이미 동의된 스코프 내에 있는지 확인
			allGranted := true
			grantedMap := make(map[string]bool)
			for _, s := range existingConsent.GrantedScopes {
				grantedMap[s] = true
			}
			for _, s := range consentRequest.RequestedScope {
				if !grantedMap[s] {
					allGranted = false
					break
				}
			}

			if allGranted {
				slog.Info("Auto-approving based on local DB consent record", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID)
				identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject)
				if err == nil && identity != nil {
					currentSessionID := h.resolveCurrentSessionID(c)
					sessionClaims := composeOIDCSessionClaims(
						consentRequest.Client,
						identity.Traits,
						consentRequest.RequestedScope,
						representativeTenantIDFromTraits(identity.Traits),
						currentSessionID,
					)
					sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
					sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
					acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
					if err == nil {
						if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil {
							slog.Error("failed to emit rp usage event for local consent auto-accept", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
							return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
						}
						return c.JSON(acceptResp)
					}
					slog.Error("failed to force auto-accept based on local DB", "error", err)
				}
			}
		}
	}

	// Hydra가 이전에 동의한 이력이 있어 skip을 권장하는 경우, 즉시 승인 처리
	if consentRequest.Skip {
		identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject)
		if err != nil || identity == nil {
			slog.Error("failed to load identity for skip consent", "error", err, "subject", consentRequest.Subject, "client_id", consentRequest.Client.ClientID)
			// 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행
		} else {
			currentSessionID := h.resolveCurrentSessionID(c)
			var clientTenantID string
			if consentRequest.Client.Metadata != nil {
				if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok {
					clientTenantID = tid
				}
			}

			sessionClaims := composeOIDCSessionClaims(
				consentRequest.Client,
				identity.Traits,
				consentRequest.RequestedScope,
				representativeTenantIDFromTraits(identity.Traits),
				currentSessionID,
			)
			sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
			sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)

			// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
			appEnv := strings.ToLower(os.Getenv("APP_ENV"))
			if appEnv == "dev" || appEnv == "development" || appEnv == "" {
				if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", "  "); err == nil {
					slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS (SKIP) ===", "claims", string(debugClaimsJSON))
				}
			}

			acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
			if err != nil {
				slog.Error("failed to auto-accept hydra consent request", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
				// 자동 승인 실패 시 일반 흐름으로 진행
			} else {
				// [New] Sync to local DB even on auto-accept to ensure data consistency
				if h.ConsentRepo != nil {
					consent := &domain.ClientConsent{
						ClientID:      consentRequest.Client.ClientID,
						Subject:       consentRequest.Subject,
						GrantedScopes: consentRequest.RequestedScope,
					}
					_ = h.ConsentRepo.Upsert(c.Context(), consent)
				}

				if h.AuditRepo != nil {
					detailsMap := map[string]any{
						"client_id":   consentRequest.Client.ClientID,
						"scopes":      consentRequest.RequestedScope,
						"client_name": consentRequest.Client.ClientName,
					}
					if currentSessionID != "" {
						detailsMap["session_id"] = currentSessionID
						detailsMap["approved_session_id"] = currentSessionID
					}
					detailsMap["auto_accepted"] = true
					detailsBytes, _ := json.Marshal(detailsMap)

					_ = h.AuditRepo.Create(&domain.AuditLog{
						EventID:   GenerateSecureToken(16),
						Timestamp: time.Now(),
						UserID:    consentRequest.Subject,
						TenantID:  clientTenantID,
						SessionID: currentSessionID,
						EventType: "consent.granted",
						Status:    "success",
						IPAddress: c.IP(),
						UserAgent: string(c.Request().Header.UserAgent()),
						Details:   string(detailsBytes),
					})
				}

				if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil {
					slog.Error("failed to emit rp usage event for skip consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
					return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
				}

				slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID, "session_id", currentSessionID)
				return c.JSON(acceptResp)
			}
		}
	}

	// Hydra 응답을 기본으로 하되, 메타데이터에서 커스텀 스코프 설명을 추출하여 추가
	response := fiber.Map{
		"challenge":                       consentRequest.Challenge,
		"requested_scope":                 consentRequest.RequestedScope,
		"requested_access_token_audience": consentRequest.RequestedAudience,
		"skip":                            consentRequest.Skip,
		"subject":                         consentRequest.Subject,
		"client":                          consentRequest.Client,
	}

	// structured_scopes 파싱 및 scope_details 생성
	if metadata := consentRequest.Client.Metadata; metadata != nil {
		if rawScopes, ok := metadata["structured_scopes"]; ok {
			scopeDetails := make(map[string]map[string]any)

			// JSON 언마샬링 등을 통해 map[string]interface{} 또는 []interface{}로 들어옴
			// 안전하게 처리
			rawBytes, _ := json.Marshal(rawScopes)
			var scopesList []map[string]any
			if err := json.Unmarshal(rawBytes, &scopesList); err == nil {
				for _, item := range scopesList {
					name, _ := item["name"].(string)
					if name == "" {
						continue
					}
					desc, _ := item["description"].(string)
					mandatory, _ := item["mandatory"].(bool)

					scopeDetails[name] = map[string]any{
						"description": desc,
						"mandatory":   mandatory,
					}
				}
			}
			response["scope_details"] = scopeDetails
		}
	}

	return c.JSON(response)
}

func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
	var req struct {
		ConsentChallenge string   `json:"consent_challenge"`
		GrantScope       []string `json:"grant_scope"` // 사용자가 선택한 스코프
	}

	if err := c.BodyParser(&req); err != nil {
		return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
	}

	if reqJson, err := json.Marshal(req); err == nil {
		slog.Info("AcceptConsentRequest: received request body", "body", string(reqJson))
	} else {
		slog.Error("AcceptConsentRequest: failed to marshal request for logging", "error", err)
	}

	if req.ConsentChallenge == "" {
		return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required")
	}

	// 1. Hydra에서 원래 요청 정보 조회
	consentRequest, err := h.Hydra.GetConsentRequest(c.Context(), req.ConsentChallenge)
	if err != nil {
		slog.Error("failed to get hydra consent request before accepting", "error", err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
	}
	consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)

	// 2. 스코프 필터링 (사용자가 선택한 것만 허용)
	if len(req.GrantScope) > 0 {
		allowedScopes := make(map[string]bool)
		for _, s := range consentRequest.RequestedScope {
			allowedScopes[s] = true
		}

		filteredScopes := make([]string, 0, len(req.GrantScope))
		for _, s := range req.GrantScope {
			if allowedScopes[s] {
				filteredScopes = append(filteredScopes, s)
			}
		}
		consentRequest.RequestedScope = filteredScopes
	}
	consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)

	profile, err := h.resolveCurrentProfile(c)
	if (err != nil || profile == nil) && consentRequest.Subject != "" {
		if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), consentRequest.Subject); fallbackErr == nil {
			profile = fallbackProfile
			err = nil
		}
	}
	if enforceClientTenantAccess(c, h.TenantService, consentRequest.Client, profile, err) {
		return nil
	}

	// 3. Hydra에 승인 요청
	if consentRequest.Subject == "" {
		return fiber.NewError(fiber.StatusInternalServerError, "Consent subject missing")
	}
	if h.KratosAdmin == nil {
		return fiber.NewError(fiber.StatusInternalServerError, "Kratos admin unavailable")
	}
	identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject)
	if err != nil || identity == nil {
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to load identity")
	}
	c.Locals("user_id", consentRequest.Subject)
	if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" {
		c.Locals("login_id", loginID)
	}
	currentSessionID := h.resolveCurrentSessionID(c)
	var clientTenantID string
	if consentRequest.Client.Metadata != nil {
		if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok {
			clientTenantID = tid
		}
	}

	sessionClaims := composeOIDCSessionClaims(
		consentRequest.Client,
		identity.Traits,
		consentRequest.RequestedScope,
		representativeTenantIDFromTraits(identity.Traits),
		currentSessionID,
	)
	sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
	sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)

	// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)
	appEnv := strings.ToLower(os.Getenv("APP_ENV"))
	if appEnv == "dev" || appEnv == "development" || appEnv == "" {
		if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", "  "); err == nil {
			slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS ===", "claims", string(debugClaimsJSON))
		}
	}

	acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims)
	if err != nil {
		slog.Error("failed to accept hydra consent request", "error", err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept consent request")
	}

	// [New] Sync to local DB for "List All Consents" feature
	if h.ConsentRepo != nil {
		consent := &domain.ClientConsent{
			ClientID:      consentRequest.Client.ClientID,
			Subject:       consentRequest.Subject,
			GrantedScopes: consentRequest.RequestedScope,
		}
		if err := h.ConsentRepo.Upsert(c.Context(), consent); err != nil {
			slog.Error("failed to sync consent to local DB", "error", err, "subject", consent.Subject, "client", consent.ClientID)
			// Don't fail the whole request, but log it
		}
	}

	if h.AuditRepo != nil {
		detailsMap := map[string]any{
			"client_id":   consentRequest.Client.ClientID,
			"scopes":      consentRequest.RequestedScope,
			"client_name": consentRequest.Client.ClientName,
		}
		if currentSessionID != "" {
			detailsMap["session_id"] = currentSessionID
			detailsMap["approved_session_id"] = currentSessionID
		}
		detailsBytes, _ := json.Marshal(detailsMap)

		_ = h.AuditRepo.Create(&domain.AuditLog{
			EventID:   GenerateSecureToken(16),
			Timestamp: time.Now(),
			UserID:    consentRequest.Subject,
			TenantID:  clientTenantID,
			SessionID: currentSessionID,
			EventType: "consent.granted",
			Status:    "success",
			IPAddress: c.IP(),
			UserAgent: string(c.Request().Header.UserAgent()),
			Details:   string(detailsBytes),
		})
	}

	if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, false, req.ConsentChallenge); err != nil {
		slog.Error("failed to emit rp usage event for accepted consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
	}

	return c.JSON(acceptResp)
}

func (h *AuthHandler) RejectConsentRequest(c *fiber.Ctx) error {
	var req struct {
		ConsentChallenge string `json:"consent_challenge"`
	}

	if err := c.BodyParser(&req); err != nil {
		return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
	}

	if req.ConsentChallenge == "" {
		return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required")
	}

	slog.Info("RejectConsentRequest called", "challenge", req.ConsentChallenge)

	rejectResp, err := h.Hydra.RejectConsentRequest(c.Context(), req.ConsentChallenge)
	if err != nil {
		slog.Error("failed to reject hydra consent request", "error", err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to reject consent request")
	}

	return c.JSON(rejectResp)
}

func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
	var req struct {
		LoginChallenge    string `json:"login_challenge"`
		ApprovedSessionID string `json:"approved_session_id,omitempty"`
		SessionID         string `json:"session_id,omitempty"`
	}
	if err := c.BodyParser(&req); err != nil {
		return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
	}
	if req.LoginChallenge == "" {
		return fiber.NewError(fiber.StatusBadRequest, "login_challenge is required")
	}

	// Check if the client is active
	loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge)
	if err == nil && loginReq != nil {
		attachAuditClientDetails(c, loginReq.Client)

		if loginReq.Client.Metadata != nil {
			if status, ok := loginReq.Client.Metadata["status"].(string); ok {
				if strings.ToLower(status) == "inactive" {
					slog.Warn("Login rejected for inactive client in AcceptOidcLoginRequest", "client_id", loginReq.Client.ClientID)
					return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
				}
			}
		}
	}

	profile, err := h.resolveCurrentProfile(c)
	if (err != nil || profile == nil) && loginReq != nil && strings.TrimSpace(loginReq.Subject) != "" {
		if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), loginReq.Subject); fallbackErr == nil {
			profile = fallbackProfile
			err = nil
		}
	}
	if loginReq != nil {
		if enforceClientTenantAccess(c, h.TenantService, loginReq.Client, profile, err) {
			return nil
		}
	}

	subject, err := h.resolveConsentSubject(c)
	if err != nil || subject == "" {
		return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
	}
	if err := h.ensureUserActivityAllowed(c.Context(), subject); err != nil {
		return fiber.NewError(fiber.StatusForbidden, "This user status cannot sign in")
	}
	c.Locals("user_id", subject)
	approvedSessionID := strings.TrimSpace(req.ApprovedSessionID)
	if approvedSessionID == "" {
		approvedSessionID = strings.TrimSpace(req.SessionID)
	}
	if approvedSessionID == "" {
		if sessionID, ok := c.Locals("session_id").(string); ok && sessionID != "" {
			approvedSessionID = sessionID
		}
	}
	if approvedSessionID == "" {
		if token := h.getBearerToken(c); token != "" {
			if resolved, err := h.getKratosSessionID(token); err == nil {
				approvedSessionID = resolved
			}
		}
	}
	if approvedSessionID == "" {
		if cookie := c.Get("Cookie"); cookie != "" {
			if derivedID, err := h.getKratosSessionIDWithCookie(cookie); err == nil {
				approvedSessionID = derivedID
			}
		}
	}
	if approvedSessionID != "" {
		c.Locals("approved_session_id", approvedSessionID)
	}
	if h.KratosAdmin != nil {
		if identity, err := h.KratosAdmin.GetIdentity(c.Context(), subject); err == nil && identity != nil {
			if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" {
				c.Locals("login_id", loginID)
			}
		}
	}

	acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
	if err != nil {
		slog.Error("failed to accept hydra login request", "error", err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
	}
	logOidcRedirectSummary("accept_oidc_login_request", acceptResp.RedirectTo)

	return c.JSON(acceptResp)
}

func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
	if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
		return profile, nil
	}

	appEnv := strings.ToLower(os.Getenv("APP_ENV"))
	isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""

	mockRole := c.Get("X-Test-Role")
	if mockRole == "" {
		mockRole = c.Get("X-Mock-Role")
	}

	token := h.getBearerToken(c)
	cookie := c.Get("Cookie")

	var profile *domain.UserProfileResponse
	var err error
	cacheKey := ""

	// 1. Try to fetch real profile if token/cookie exists
	if token != "" || cookie != "" {
		// Try Redis Cache
		if h.RedisService != nil && token == "" && cookie != "" {
			cacheKey = "cache:profile:cookie:" + cookie
			cached, _ := h.RedisService.Get(cacheKey)
			if cached != "" {
				if json.Unmarshal([]byte(cached), &profile) == nil {
					slog.Debug("Profile loaded from cache", "role", profile.Role)
				}
			}
		}

		if profile == nil {
			// Fetch from Kratos (SoT)
			if token != "" {
				profile, err = h.getKratosProfile(token)
				if err != nil && h.Hydra != nil {
					// Fallback to Hydra introspection. This is expected for API calls using Bearer tokens.
					slog.Debug("Kratos cookie session absent, falling back to Hydra token", "error", err.Error())
					profile, err = h.getHydraProfile(c.Context(), token)
				}
			} else if cookie != "" {
				profile, err = h.getKratosProfileWithCookie(cookie)
			}
		}
	}

	// 2. Role Override for real profile or fallback to Mock Profile
	if profile != nil {
		if isDev && mockRole != "" {
			normalizedMockRole := domain.NormalizeRole(mockRole)
			if profile.Role != normalizedMockRole {
				slog.Info("🔑 [AUTH] Overriding real profile role",
					"email", profile.Email, "originalRole", profile.Role, "overriddenRole", normalizedMockRole)
				profile.Role = normalizedMockRole
			}
		}
	} else if isDev && mockRole != "" && token == "" && cookie == "" {
		normalizedMockRole := domain.NormalizeRole(mockRole)
		slog.Info("🔑 [AUTH] Using full Mock Auth (no session)", "role", mockRole)
		profile = &domain.UserProfileResponse{
			ID:    "00000000-0000-0000-0000-000000000000",
			Email: "mock@hmac.kr",
			Name:  "Dev Mock User",
			Role:  normalizedMockRole,
		}
		if tid := c.Get("X-Tenant-ID"); tid != "" {
			profile.TenantID = &tid
		}
	}

	if profile == nil {
		slog.Warn("No profile resolved", "token_len", len(token), "cookie_len", len(cookie), "mockRole", mockRole)
		return nil, errors.New("invalid session (trace:resolve_profile)")
	}

	// 3. Post-Process (Defaults & Metadata Enrichment)
	profile.Role = domain.NormalizeRole(profile.Role)
	if profile.Role == "" {
		profile.Role = domain.RoleUser
	}

	// [New] Backtracking Logic for Session Tenant (Plan A)
	if usedID, ok := profile.Metadata["_used_identifier"].(string); ok && usedID != "" && h.UserRepo != nil {
		if tid, err := h.UserRepo.FindTenantIDByLoginID(c.Context(), usedID); err == nil && tid != "" {
			profile.SessionTenantID = &tid
			slog.Debug("Auto-assigned session tenant via backtracking", "loginID", usedID, "tenantID", tid)
		}
		delete(profile.Metadata, "_used_identifier") // Cleanup
	}

	profile = h.hydrateResolvedProfile(c.Context(), profile)

	// 4. Save to Redis Cache (Short TTL)
	// IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key
	// or we should include the mock role in the cache key.
	// For simplicity, let's skip caching if mockRole is present in dev.
	if h.RedisService != nil && token == "" && cacheKey != "" && err == nil && !(isDev && mockRole != "") {
		if data, err := json.Marshal(profile); err == nil {
			ttlStr := os.Getenv("PROFILE_CACHE_TTL")
			ttl := 30 * time.Minute // Default TTL
			if ttlStr != "" {
				if parsed, err := time.ParseDuration(ttlStr); err == nil {
					ttl = parsed
				}
			}
			_ = h.RedisService.Set(cacheKey, string(data), ttl)
		}
	}

	return profile, nil
}

func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
	token := h.getBearerToken(c)
	if token != "" {
		identityID, resolveErr := h.resolveIdentityID(c, token)
		if resolveErr == nil && identityID != "" {
			return identityID, nil
		}
		if cookie := c.Get("Cookie"); cookie != "" {
			cookieID, _, _, cookieErr := h.getKratosIdentityWithCookie(cookie)
			if cookieErr == nil && cookieID != "" {
				return cookieID, nil
			}
		}
		return "", resolveErr
	}
	cookie := c.Get("Cookie")
	if cookie == "" {
		return "", fmt.Errorf("missing authorization token")
	}
	identityID, _, _, err := h.getKratosIdentityWithCookie(cookie)
	return identityID, err
}

func (h *AuthHandler) resolveConsentSubjects(c *fiber.Ctx) ([]string, error) {
	token := h.getBearerToken(c)
	if token != "" {
		identityID, traits, _, err := h.getKratosIdentity(token)
		if err == nil && identityID != "" {
			subjects := []string{identityID}
			subjects = appendLoginIDsFromTraits(subjects, traits)
			return uniqueStrings(subjects), nil
		}
	}

	cookie := c.Get("Cookie")
	if cookie == "" {
		return nil, fmt.Errorf("missing authorization token")
	}
	identityID, traits, _, err := h.getKratosIdentityWithCookie(cookie)
	if err != nil {
		return nil, err
	}
	subjects := []string{identityID}
	subjects = appendLoginIDsFromTraits(subjects, traits)
	return uniqueStrings(subjects), nil
}

func uniqueStrings(items []string) []string {
	seen := make(map[string]struct{}, len(items))
	result := make([]string, 0, len(items))
	for _, item := range items {
		item = strings.TrimSpace(item)
		if item == "" {
			continue
		}
		if _, ok := seen[item]; ok {
			continue
		}
		seen[item] = struct{}{}
		result = append(result, item)
	}
	return result
}

func appendLoginIDsFromValues(subjects []string, email string, phone string) []string {
	if strings.TrimSpace(email) != "" {
		subjects = append(subjects, strings.TrimSpace(email))
	}
	if strings.TrimSpace(phone) != "" {
		subjects = append(subjects, normalizePhoneForLoginID(phone))
	}
	return subjects
}

func appendLoginIDsFromTraits(subjects []string, traits map[string]any) []string {
	if traits == nil {
		return subjects
	}
	if raw, ok := traits["email"]; ok {
		if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
			subjects = append(subjects, strings.TrimSpace(value))
		}
	}
	if raw, ok := traits["phone"]; ok {
		if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
			subjects = append(subjects, normalizePhoneForLoginID(value))
		}
	}
	if raw, ok := traits["phone_number"]; ok {
		if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
			subjects = append(subjects, normalizePhoneForLoginID(value))
		}
	}
	if raw, ok := traits["phoneNumber"]; ok {
		if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
			subjects = append(subjects, normalizePhoneForLoginID(value))
		}
	}
	if raw, ok := traits["mobile"]; ok {
		if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
			subjects = append(subjects, normalizePhoneForLoginID(value))
		}
	}
	if raw, ok := traits["mobile_number"]; ok {
		if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
			subjects = append(subjects, normalizePhoneForLoginID(value))
		}
	}
	return subjects
}

func isAuthEventType(eventType string) bool {
	normalized := strings.ToLower(eventType)
	return strings.Contains(normalized, " /api/v1/auth/")
}

func extractAuditPath(log domain.AuditLog) string {
	if log.Details != "" {
		if payload, err := utils.ParseAuditDetails(log.Details); err == nil {
			if path, ok := payload["path"].(string); ok && path != "" {
				return path
			}
		}
	}
	parts := strings.SplitN(log.EventType, " ", 2)
	if len(parts) == 2 {
		return strings.TrimSpace(parts[1])
	}
	return ""
}

func extractRequestBody(details map[string]any) map[string]any {
	if details == nil {
		return nil
	}
	raw, ok := details["request_body"].(string)
	if !ok || raw == "" {
		return nil
	}
	var body map[string]any
	if err := json.Unmarshal([]byte(raw), &body); err != nil {
		return nil
	}
	return body
}

func shouldSkipAuthTimeline(log domain.AuditLog) bool {
	details, _ := utils.ParseAuditDetails(log.Details)
	path := strings.ToLower(extractAuditPath(log))
	if path != "" && strings.Contains(path, "/api/v1/auth/enchanted-link/init") {
		return true
	}
	if path != "" && strings.Contains(path, "/api/v1/auth/consent/accept") {
		return true
	}
	if path != "" && (strings.Contains(path, "/api/v1/auth/magic-link/verify") ||
		strings.Contains(path, "/api/v1/auth/login/code/verify")) {
		sessionID := log.SessionID
		if sessionID == "" {
			sessionID = extractSessionIDFromAuditDetails(log.Details)
		}
		if sessionID == "" {
			return true
		}
	}
	if details != nil {
		if raw, ok := details["auth_timeline_skip"]; ok {
			switch value := raw.(type) {
			case bool:
				if value {
					return true
				}
			case string:
				if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
					return true
				}
			}
		}
	}
	requestBody := extractRequestBody(details)
	if requestBody != nil {
		if raw, ok := requestBody["verifyOnly"]; ok {
			switch value := raw.(type) {
			case bool:
				if value {
					return true
				}
			case string:
				if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
					return true
				}
			}
		}
	}
	return false
}

func loginIDKind(loginID string) string {
	normalized := strings.TrimSpace(loginID)
	if normalized == "" {
		return ""
	}
	if strings.Contains(normalized, "@") {
		return "email"
	}
	return "phone"
}

func resolveLoginMethod(rawMethod, loginID string) string {
	method := strings.ToLower(strings.TrimSpace(rawMethod))
	if method == "sms" || method == "email" {
		return method
	}
	if strings.TrimSpace(loginID) == "" {
		return ""
	}
	if strings.Contains(loginID, "@") {
		return "email"
	}
	return "sms"
}

func loginMethodLabel(method string) string {
	switch strings.ToLower(strings.TrimSpace(method)) {
	case "sms":
		return "SMS"
	case "email":
		return "Email"
	default:
		return ""
	}
}

func deriveAuthMethod(log domain.AuditLog) string {
	path := strings.ToLower(extractAuditPath(log))
	if path == "" {
		return ""
	}

	loginID := extractLoginIDFromAuditDetails(log.Details)
	kind := loginIDKind(loginID)
	details, _ := utils.ParseAuditDetails(log.Details)
	requestBody := extractRequestBody(details)
	if details != nil {
		if raw, ok := details["auth_timeline_skip"]; ok {
			switch value := raw.(type) {
			case bool:
				if value {
					return ""
				}
			case string:
				if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
					return ""
				}
			}
		}
	}
	if requestBody != nil {
		if raw, ok := requestBody["verifyOnly"]; ok {
			switch value := raw.(type) {
			case bool:
				if value {
					return ""
				}
			case string:
				if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
					return ""
				}
			}
		}
	}
	if path != "" && (strings.Contains(path, "/api/v1/auth/qr/init") ||
		strings.Contains(path, "/api/v1/auth/qr/poll") ||
		strings.Contains(path, "/api/v1/auth/qr/approve")) {
		return "QR"
	}
	if details != nil {
		rawFlow, _ := details["login_flow"].(string)
		rawMethod, _ := details["login_method"].(string)
		flow := strings.ToLower(strings.TrimSpace(rawFlow))
		methodLabel := loginMethodLabel(rawMethod)
		switch flow {
		case loginFlowCode:
			if methodLabel != "" {
				return fmt.Sprintf("코드(%s)", methodLabel)
			}
			return "코드"
		case loginFlowLink:
			if methodLabel != "" {
				return fmt.Sprintf("링크(%s)", methodLabel)
			}
			return "링크"
		}
	}

	switch {
	case strings.Contains(path, "/api/v1/auth/password/login"),
		strings.Contains(path, "/api/v1/auth/headless/password/login"):
		if kind == "email" {
			return "비밀번호(Email)"
		}
		if kind == "phone" {
			return "비밀번호(전화번호)"
		}
		return "비밀번호"
	case strings.Contains(path, "/api/v1/auth/enchanted-link/init"):
		if requestBody != nil {
			if raw, ok := requestBody["codeOnly"]; ok {
				if value, ok := raw.(bool); ok && value {
					if kind == "phone" {
						return "코드(SMS)"
					}
					if kind == "email" {
						return "코드(Email)"
					}
					return "코드"
				}
			}
		}
		if requestBody != nil {
			if raw, ok := requestBody["method"].(string); ok {
				method := strings.ToLower(strings.TrimSpace(raw))
				if method == "sms" {
					return "링크(SMS)"
				}
				if method == "email" {
					return "링크(Email)"
				}
			}
		}
		if kind == "phone" {
			return "링크(SMS)"
		}
		if kind == "email" {
			return "링크(Email)"
		}
		return "링크"
	case strings.Contains(path, "/api/v1/auth/magic-link/verify"):
		if kind == "phone" {
			return "링크(SMS)"
		}
		if kind == "email" {
			return "링크(Email)"
		}
		return "링크"
	case strings.Contains(path, "/api/v1/auth/login/code/verify"):
		if kind == "phone" {
			return "코드(SMS)"
		}
		if kind == "email" {
			return "코드(Email)"
		}
		return "코드"
	case strings.Contains(path, "/api/v1/auth/login/code/verify-short"):
		return "코드(간편)"
	case strings.Contains(path, "/api/v1/auth/verify-sms"):
		return "코드(SMS)"
	case strings.Contains(path, "/api/v1/auth/qr/approve"):
		return "QR"
	case strings.Contains(path, "/api/v1/auth/qr/init"):
		return "QR"
	case strings.Contains(path, "/api/v1/auth/qr/poll"):
		return "QR"
	case strings.Contains(path, "/api/v1/auth/oidc/login/accept"):
		return "OIDC 로그인"
	default:
		return ""
	}
}

func buildLoginCandidates(profile *domain.UserProfileResponse) map[string]struct{} {
	candidates := make(map[string]struct{})
	if profile == nil {
		return candidates
	}
	for _, raw := range []string{profile.Email, profile.Phone, normalizePhoneForLoginID(profile.Phone)} {
		if normalized := normalizeLoginIdentifier(raw); normalized != "" {
			candidates[normalized] = struct{}{}
		}
	}
	return candidates
}

func normalizeLoginIdentifier(value string) string {
	trimmed := strings.TrimSpace(value)
	if trimmed == "" {
		return ""
	}
	if strings.Contains(trimmed, "@") {
		return strings.ToLower(trimmed)
	}
	return normalizePhoneForLoginID(trimmed)
}

func matchesAuthTimelineUser(log domain.AuditLog, profile *domain.UserProfileResponse, candidates map[string]struct{}, sessionID string) bool {
	if profile == nil {
		return false
	}
	if profile.ID != "" && log.UserID == profile.ID {
		return true
	}
	loginID := extractLoginIDFromAuditDetails(log.Details)
	normalized := normalizeLoginIdentifier(loginID)
	if normalized != "" {
		if _, ok := candidates[normalized]; ok {
			return true
		}
	}
	if sessionID == "" {
		return false
	}
	if log.SessionID != "" && log.SessionID == sessionID {
		return true
	}
	if extracted := extractSessionIDFromAuditDetails(log.Details); extracted != "" && extracted == sessionID {
		return true
	}
	if approved := extractApprovedSessionIDFromAuditDetails(log.Details); approved != "" && approved == sessionID {
		return true
	}
	return false
}

func extractLoginIDFromAuditDetails(details string) string {
	if details == "" {
		return ""
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(details), &payload); err != nil {
		return ""
	}
	if raw, ok := payload["login_id"]; ok {
		if value, ok := raw.(string); ok && value != "" {
			return value
		}
	}
	if raw, ok := payload["loginId"]; ok {
		if value, ok := raw.(string); ok && value != "" {
			return value
		}
	}
	if raw, ok := payload["request_body"]; ok {
		if value, ok := raw.(string); ok && value != "" {
			var body map[string]any
			if err := json.Unmarshal([]byte(value), &body); err == nil {
				if loginID := extractLoginIDFromClaims(body); loginID != "" {
					return loginID
				}
				if target, ok := body["target"].(string); ok && target != "" {
					return target
				}
			}
		}
	}
	return ""
}

func extractLoginChallengeFromAuditDetails(details string) string {
	if details == "" {
		return ""
	}
	payload, err := utils.ParseAuditDetails(details)
	if err != nil {
		return ""
	}
	if raw, ok := payload["login_challenge"].(string); ok && raw != "" {
		return raw
	}
	if raw, ok := payload["loginChallenge"].(string); ok && raw != "" {
		return raw
	}
	body := extractRequestBody(payload)
	if body == nil {
		return ""
	}
	if raw, ok := body["login_challenge"].(string); ok && raw != "" {
		return raw
	}
	if raw, ok := body["loginChallenge"].(string); ok && raw != "" {
		return raw
	}
	return ""
}

func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string {
	if value := strings.TrimSpace(log.ClientID); value != "" {
		return value
	}
	if value := strings.TrimSpace(log.RP); value != "" {
		return value
	}
	if value := parseClientIDFromURL(log.Target); value != "" {
		return value
	}
	if value := parseClientIDFromURL(log.Path); value != "" {
		return value
	}
	return parseClientIDFromRaw(log.Raw)
}

func extractSessionIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string {
	if value := parseSessionIDFromURL(log.Target); value != "" {
		return value
	}
	if value := parseSessionIDFromURL(log.Path); value != "" {
		return value
	}
	return parseSessionIDFromRaw(log.Raw)
}

func parseClientIDFromURL(raw string) string {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return ""
	}
	parsed, err := url.Parse(raw)
	if err != nil {
		return ""
	}
	if id := strings.TrimSpace(parsed.Query().Get("client_id")); id != "" {
		return id
	}
	if id := strings.TrimSpace(parsed.Query().Get("clientId")); id != "" {
		return id
	}
	return ""
}

func parseSessionIDFromURL(raw string) string {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return ""
	}
	parsed, err := url.Parse(raw)
	if err != nil {
		return ""
	}
	for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} {
		if id := strings.TrimSpace(parsed.Query().Get(key)); id != "" {
			return id
		}
	}
	return ""
}

func parseClientIDFromRaw(raw string) string {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return ""
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(raw), &payload); err != nil {
		return ""
	}
	if id := readClientIDFromPayload(payload); id != "" {
		return id
	}
	if request, ok := payload["request"].(map[string]any); ok {
		if id := readClientIDFromPayload(request); id != "" {
			return id
		}
		if urlRaw, ok := request["url"].(string); ok {
			if id := parseClientIDFromURL(urlRaw); id != "" {
				return id
			}
		}
		if pathRaw, ok := request["path"].(string); ok {
			if id := parseClientIDFromURL(pathRaw); id != "" {
				return id
			}
		}
	}
	return ""
}

func readClientIDFromPayload(payload map[string]any) string {
	if payload == nil {
		return ""
	}
	if raw, ok := payload["client_id"].(string); ok && strings.TrimSpace(raw) != "" {
		return strings.TrimSpace(raw)
	}
	if raw, ok := payload["clientId"].(string); ok && strings.TrimSpace(raw) != "" {
		return strings.TrimSpace(raw)
	}
	return ""
}

func extractSessionIDFromAuditDetails(details string) string {
	if details == "" {
		return ""
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(details), &payload); err != nil {
		return ""
	}
	return readSessionIDFromAny(payload)
}

func extractApprovedSessionIDFromAuditDetails(details string) string {
	if details == "" {
		return ""
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(details), &payload); err != nil {
		return ""
	}
	if raw, ok := payload["approved_session_id"]; ok {
		switch value := raw.(type) {
		case string:
			return value
		default:
			return fmt.Sprint(value)
		}
	}
	if raw, ok := payload["approvedSessionId"]; ok {
		switch value := raw.(type) {
		case string:
			return value
		default:
			return fmt.Sprint(value)
		}
	}
	return ""
}

func parseSessionIDFromRaw(raw string) string {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return ""
	}
	var payload any
	if err := json.Unmarshal([]byte(raw), &payload); err != nil {
		return ""
	}
	return readSessionIDFromAny(payload)
}

func readSessionIDFromAny(payload any) string {
	switch value := payload.(type) {
	case map[string]any:
		for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} {
			if raw, ok := value[key]; ok {
				switch sid := raw.(type) {
				case string:
					if strings.TrimSpace(sid) != "" {
						return strings.TrimSpace(sid)
					}
				default:
					rendered := strings.TrimSpace(fmt.Sprint(sid))
					if rendered != "" && rendered != "<nil>" {
						return rendered
					}
				}
			}
		}
		for _, nested := range value {
			if sid := readSessionIDFromAny(nested); sid != "" {
				return sid
			}
		}
	case []any:
		for _, nested := range value {
			if sid := readSessionIDFromAny(nested); sid != "" {
				return sid
			}
		}
	}
	return ""
}

func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) {
	id, _, _, err := h.getKratosIdentity(token)
	return id, err
}

func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
	_, traits, _, err := h.getKratosIdentity(token)
	if err != nil {
		return "", err
	}
	loginID := pickLoginIDFromTraits(traits)
	if loginID == "" {
		return "", fmt.Errorf("kratos login id missing")
	}
	if !strings.Contains(loginID, "@") {
		loginID = normalizePhoneForLoginID(loginID)
	}
	return loginID, nil
}

func pickLoginIDFromTraits(traits map[string]any) string {
	if traits == nil {
		return ""
	}
	keys := []string{"email", "phone", "phone_number", "phoneNumber", "mobile", "mobile_number"}
	for _, key := range keys {
		if raw, ok := traits[key]; ok {
			if value, ok := raw.(string); ok && value != "" {
				return value
			}
		}
	}
	return ""
}

func (h *AuthHandler) resolveQrPendingRef(raw string) (string, error) {
	ref := strings.TrimSpace(raw)
	if ref == "" {
		return "", fmt.Errorf("empty ref")
	}
	if strings.HasPrefix(ref, "http") {
		if parsed, err := url.Parse(ref); err == nil {
			if value := parsed.Query().Get("ref"); value != "" {
				ref = value
			} else if len(parsed.Path) > 0 {
				segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
				if len(segments) >= 2 && segments[0] == "ql" {
					ref = segments[1]
				}
			}
		}
	}
	if ref == "" {
		return "", fmt.Errorf("invalid ref")
	}
	if mapped, _ := h.RedisService.Get(prefixQrRef + ref); mapped != "" {
		return mapped, nil
	}
	return ref, nil
}

func (h *AuthHandler) resolveQrRef(raw string) string {
	ref := strings.TrimSpace(raw)
	if ref == "" {
		return ""
	}
	if strings.HasPrefix(ref, "http") {
		if parsed, err := url.Parse(ref); err == nil {
			if value := parsed.Query().Get("ref"); value != "" {
				return value
			}
			if len(parsed.Path) > 0 {
				segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
				if len(segments) >= 2 && segments[0] == "ql" {
					return segments[1]
				}
			}
		}
	}
	return ref
}

func (h *AuthHandler) startQrCodeLogin(loginID, pendingRef string) error {
	if h.IdpProvider == nil {
		return fmt.Errorf("identity provider unavailable")
	}
	userfrontURL := os.Getenv("USERFRONT_URL")
	if userfrontURL == "" {
		userfrontURL = "http://sso.hmac.kr"
	}
	_ = h.RedisService.Set(prefixQrPending+loginID, pendingRef, loginCodeExpiration)
	init, err := h.IdpProvider.InitiateLinkLogin(loginID, userfrontURL)
	if err != nil {
		h.RedisService.Delete(prefixQrPending + loginID)
		if errors.Is(err, domain.ErrNotSupported) {
			return fmt.Errorf("login method not supported")
		}
		return err
	}
	effectiveLoginID := loginID
	if init != nil && init.LoginID != "" {
		effectiveLoginID = init.LoginID
	}
	if effectiveLoginID != loginID {
		_ = h.RedisService.Set(prefixQrPending+effectiveLoginID, pendingRef, loginCodeExpiration)
	}
	if init != nil && init.FlowID != "" {
		_ = h.RedisService.Set(prefixLoginCode+effectiveLoginID, init.FlowID, loginCodeExpiration)
	}
	return nil
}

func (h *AuthHandler) startQrCodeLoginForQr(loginID, pendingRef, rawRef string) error {
	if h.IdpProvider == nil {
		return fmt.Errorf("identity provider unavailable")
	}
	userfrontURL := os.Getenv("USERFRONT_URL")
	if userfrontURL == "" {
		userfrontURL = "http://sso.hmac.kr"
	}

	init, err := h.IdpProvider.InitiateLinkLogin(loginID, userfrontURL)
	if err != nil {
		if errors.Is(err, domain.ErrNotSupported) {
			return fmt.Errorf("login method not supported")
		}
		return err
	}

	effectiveLoginID := loginID
	if init != nil && init.LoginID != "" {
		effectiveLoginID = init.LoginID
	}
	if init == nil || init.FlowID == "" {
		return fmt.Errorf("login flow missing")
	}

	qrRef := h.resolveQrRef(rawRef)
	qrPayload, _ := json.Marshal(map[string]string{
		"pendingRef": pendingRef,
		"qrRef":      qrRef,
		"loginId":    effectiveLoginID,
		"approvedAt": time.Now().UTC().Format(time.RFC3339),
	})
	_ = h.RedisService.Set(prefixLoginCodeQr+pendingRef, string(qrPayload), loginCodeExpiration)
	_ = h.RedisService.Set(prefixLoginCodeQrPending+effectiveLoginID, pendingRef, loginCodeExpiration)
	_ = h.RedisService.Set(prefixLoginCode+effectiveLoginID, init.FlowID, loginCodeExpiration)
	return nil
}

func pickPrimaryLoginID(loginIDs []string) string {
	for _, id := range loginIDs {
		if strings.Contains(id, "@") {
			return id
		}
	}
	if len(loginIDs) > 0 {
		return loginIDs[0]
	}
	return ""
}

func extractLoginIDFromClaims(claims map[string]any) string {
	if claims == nil {
		return ""
	}

	candidateKeys := []string{"loginId", "login_id", "email", "phone_number", "phone", "phoneNumber"}
	for _, key := range candidateKeys {
		if raw, ok := claims[key]; ok {
			if value, ok := raw.(string); ok && value != "" {
				return value
			}
		}
	}

	if raw, ok := claims["loginIds"]; ok {
		switch ids := raw.(type) {
		case []string:
			return pickPrimaryLoginID(ids)
		case []any:
			casted := make([]string, 0, len(ids))
			for _, item := range ids {
				if value, ok := item.(string); ok && value != "" {
					casted = append(casted, value)
				}
			}
			return pickPrimaryLoginID(casted)
		}
	}

	return ""
}

func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]any, string, error) {
	identityID, traits, _, usedID, err := h.getKratosIdentityWithSession(sessionToken)
	return identityID, traits, usedID, err
}

func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]any, string, string, error) {
	kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
	if kratosURL == "" {
		kratosURL = "http://kratos:4433"
	}
	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
	if err != nil {
		return "", nil, "", "", err
	}
	req.Header.Set("X-Session-Token", sessionToken)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", nil, "", "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
		return "", nil, "", "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
	}

	var result struct {
		AuthenticatedAt       string `json:"authenticated_at"`
		AuthenticationMethods []struct {
			Method     string `json:"method"`
			Identifier string `json:"identifier"`
		} `json:"authentication_methods"`
		Identity struct {
			ID     string         `json:"id"`
			Traits map[string]any `json:"traits"`
		} `json:"identity"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", nil, "", "", err
	}

	usedIdentifier := ""
	for _, m := range result.AuthenticationMethods {
		if m.Identifier != "" {
			usedIdentifier = m.Identifier
			break
		}
	}

	return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, usedIdentifier, nil
}

func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
	kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
	if kratosURL == "" {
		kratosURL = "http://kratos:4433"
	}
	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("X-Session-Token", sessionToken)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
		return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
	}

	var result struct {
		ID string `json:"id"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", err
	}
	return result.ID, nil
}

func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) (string, error) {
	if identityID == "" {
		return "", fmt.Errorf("kratos identity id is empty")
	}

	kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
	if kratosAdminURL == "" {
		kratosAdminURL = "http://kratos:4434"
	}

	payload := map[string]any{
		"identity_id": identityID,
	}
	body, _ := json.Marshal(payload)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, kratosAdminURL+"/admin/sessions", bytes.NewReader(body))
	if err != nil {
		return "", err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
	if resp.StatusCode >= 300 {
		return "", fmt.Errorf("kratos admin create session failed status=%d body=%s", resp.StatusCode, string(respBody))
	}

	var parsed struct {
		SessionToken string `json:"session_token"`
	}
	if err := json.Unmarshal(respBody, &parsed); err != nil {
		return "", err
	}
	if parsed.SessionToken == "" {
		return "", fmt.Errorf("kratos admin session token missing: %s", string(respBody))
	}
	return parsed.SessionToken, nil
}

func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]any, string, error) {
	identityID, traits, _, usedID, err := h.getKratosIdentityWithCookieAndSession(cookie)
	return identityID, traits, usedID, err
}

func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]any, string, string, error) {
	kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
	if kratosURL == "" {
		kratosURL = "http://kratos:4433"
	}
	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
	if err != nil {
		return "", nil, "", "", err
	}
	req.Header.Set("Cookie", cookie)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", nil, "", "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
		return "", nil, "", "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
	}

	var result struct {
		AuthenticatedAt       string `json:"authenticated_at"`
		AuthenticationMethods []struct {
			Method     string `json:"method"`
			Identifier string `json:"identifier"`
		} `json:"authentication_methods"`
		Identity struct {
			ID     string         `json:"id"`
			Traits map[string]any `json:"traits"`
		} `json:"identity"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", nil, "", "", err
	}

	usedIdentifier := ""
	for _, m := range result.AuthenticationMethods {
		if m.Identifier != "" {
			usedIdentifier = m.Identifier
			break
		}
	}

	return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, usedIdentifier, nil
}

func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
	kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
	if kratosURL == "" {
		kratosURL = "http://kratos:4433"
	}
	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("Cookie", cookie)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
		return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
	}

	var result struct {
		ID string `json:"id"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", err
	}
	return result.ID, nil
}

func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domain.UserProfileResponse, error) {
	intro, err := h.Hydra.IntrospectToken(ctx, token)
	if err != nil {
		slog.Error("Hydra introspection failed", "error", err)
		return nil, err
	}
	if !intro.Active {
		slog.Warn("Hydra token is not active")
		return nil, errors.New("token is not active")
	}
	if err := h.validateHydraTokenSession(ctx, intro); err != nil {
		slog.Warn("Hydra token session validation failed", "error", err)
		return nil, err
	}
	if err := h.ensureUserActivityAllowed(ctx, intro.Subject); err != nil {
		return nil, err
	}

	slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID)

	// Fetch identity details from Kratos by subject (identityID)
	identity, err := h.KratosAdmin.GetIdentity(ctx, intro.Subject)
	if err != nil || identity == nil {
		slog.Warn("Kratos identity not found for Hydra subject", "subject", intro.Subject)
		// Fallback to minimal profile if Kratos identity not found
		return &domain.UserProfileResponse{
			ID:    intro.Subject,
			Email: "unknown@hydra.local",
			Name:  "Hydra User",
			Role:  domain.RoleUser,
		}, nil
	}

	return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil
}

func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]any) *domain.UserProfileResponse {
	email, _ := traits["email"].(string)
	name, _ := traits["name"].(string)
	phone, _ := traits["phone_number"].(string)
	dept, _ := traits["department"].(string)
	affType, _ := traits["affiliationType"].(string)
	role, _ := traits["role"].(string)
	tenantID, _ := traits["tenant_id"].(string)
	relyingPartyID, _ := traits["relying_party_id"].(string)

	profile := &domain.UserProfileResponse{
		ID:              identityID,
		Email:           email,
		Name:            name,
		Phone:           h.formatPhoneForDisplay(phone),
		Department:      dept,
		AffiliationType: affType,
		Role:            domain.NormalizeRole(role),
		Metadata:        make(map[string]any),
	}

	if tenantID != "" {
		profile.TenantID = &tenantID
	}
	if strings.TrimSpace(relyingPartyID) != "" {
		rpID := strings.TrimSpace(relyingPartyID)
		profile.RelyingPartyID = &rpID
	}

	coreTraits := map[string]bool{
		"email": true, "name": true, "phone_number": true,
		"grade": true, "companyCode": true, "department": true,
		"affiliationType": true, "role": true, "tenant_id": true, "relying_party_id": true,
	}
	for k, v := range traits {
		if !coreTraits[k] {
			profile.Metadata[k] = v
		}
	}
	return profile
}

func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[string]any, existing *domain.User) *domain.User {
	now := time.Now()
	localUser := &domain.User{
		ID:        identityID,
		Status:    domain.UserStatusActive,
		CreatedAt: now,
		UpdatedAt: now,
		Metadata:  make(domain.JSONMap),
	}
	if existing != nil {
		copied := *existing
		localUser = &copied
		localUser.UpdatedAt = now
		if localUser.Metadata == nil {
			localUser.Metadata = make(domain.JSONMap)
		}
	}

	if email := extractTraitString(traits, "email"); email != "" {
		localUser.Email = email
	}
	if name := extractTraitString(traits, "name"); name != "" {
		localUser.Name = name
	}
	if phone := extractTraitString(traits, "phone_number"); phone != "" {
		localUser.Phone = phone
	}
	if department := extractTraitString(traits, "department"); department != "" {
		localUser.Department = department
	}
	if grade := extractTraitString(traits, "grade"); grade != "" {
		if _, isRole := domain.NormalizeRoleAlias(grade); !isRole {
			localUser.Grade = grade
		}
	}
	if position := extractTraitString(traits, "position"); position != "" {
		localUser.Position = position
	}
	if jobTitle := extractTraitString(traits, "jobTitle"); jobTitle != "" {
		localUser.JobTitle = jobTitle
	}
	if affType := extractTraitString(traits, "affiliationType"); affType != "" {
		localUser.AffiliationType = affType
	}

	if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
		localUser.TenantID = &tenantID
	}
	if relyingPartyID := extractTraitString(traits, "relying_party_id"); relyingPartyID != "" {
		localUser.RelyingPartyID = &relyingPartyID
	}

	role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role"))
	if !ok {
		role, ok = domain.NormalizeRoleAlias(extractTraitString(traits, "grade"))
		if !ok {
			role = domain.RoleUser
		}
	}
	localUser.Role = role
	if localUser.Status == "" {
		localUser.Status = domain.UserStatusActive
	}
	if localUser.CreatedAt.IsZero() {
		localUser.CreatedAt = now
	}

	coreTraits := map[string]bool{
		"email": true, "name": true, "phone_number": true,
		"grade": true, "companyCode": true, "company_code": true,
		"companyCodes": true, "department": true,
		"position": true, "jobTitle": true,
		"affiliationType": true, "role": true,
		"tenant_id": true, "relying_party_id": true,
		"custom_login_ids": true, "id": true,
	}
	metadata := make(domain.JSONMap)
	for k, v := range traits {
		if !coreTraits[k] {
			metadata[k] = v
		}
	}
	localUser.Metadata = metadata

	return localUser
}

func (h *AuthHandler) syncUpdatedKratosUserReadModel(ctx context.Context, identityID string, traits map[string]any) error {
	if h == nil || h.UserRepo == nil {
		return nil
	}

	var existing *domain.User
	if current, err := h.UserRepo.FindByID(ctx, identityID); err == nil {
		existing = current
	} else {
		slog.Warn("[UpdateMe] Failed to load existing local user before read-model sync", "userID", identityID, "error", err)
	}

	localUser := h.mapKratosTraitsToLocalUser(identityID, traits, existing)
	return h.UserRepo.Update(ctx, localUser)
}

func (h *AuthHandler) applySessionInfoFromWhoami(profile *domain.UserProfileResponse, authenticatedAt, usedIdentifier string) *domain.UserProfileResponse {
	if profile == nil {
		return nil
	}
	profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt)
	if usedIdentifier != "" {
		if profile.Metadata == nil {
			profile.Metadata = make(map[string]any)
		}
		profile.Metadata["_used_identifier"] = usedIdentifier
	}
	return profile
}

func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
	identityID, traits, authenticatedAt, usedIdentifier, err := h.getKratosIdentityWithSession(sessionToken)
	if err != nil {
		return nil, err
	}
	if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil {
		return nil, err
	}
	return h.applySessionInfoFromWhoami(
		h.mapKratosIdentityToProfile(identityID, traits),
		authenticatedAt,
		usedIdentifier,
	), nil
}

func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
	identityID, traits, authenticatedAt, usedIdentifier, err := h.getKratosIdentityWithCookieAndSession(cookie)
	if err != nil {
		return nil, err
	}
	if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil {
		return nil, err
	}
	return h.applySessionInfoFromWhoami(
		h.mapKratosIdentityToProfile(identityID, traits),
		authenticatedAt,
		usedIdentifier,
	), nil
}

// UpdateMe - Updates current user's profile with phone verification check
func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
	token := h.getBearerToken(c)
	var req domain.UpdateUserRequest
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
	}

	var (
		identityID string
		traits     map[string]any
		err        error
	)
	if token != "" {
		identityID, traits, _, err = h.getKratosIdentity(token)
	} else {
		cookie := c.Get("Cookie")
		if cookie == "" {
			return errorJSON(c, fiber.StatusUnauthorized, "Missing authorization token")
		}
		identityID, traits, _, err = h.getKratosIdentityWithCookie(cookie)
	}
	if err != nil {
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
	}
	if err := h.ensureUserActivityAllowed(c.Context(), identityID); err != nil {
		return errorJSON(c, fiber.StatusForbidden, "This user status cannot perform Baron activity")
	}

	currentPhone, _ := traits["phone_number"].(string)
	newPhoneStorage := h.formatPhoneForStorage(req.Phone)

	slog.Info("[UpdateMe] Checking changes (Kratos)", "identityID", identityID, "oldPhone", currentPhone, "newPhone", newPhoneStorage, "newName", req.Name)

	if newPhoneStorage != "" && newPhoneStorage != currentPhone {
		verifyKey := "verify_update_phone:" + identityID + ":" + newPhoneStorage
		val, _ := h.RedisService.Get(verifyKey)
		if val != "verified" {
			slog.Warn("[UpdateMe] Phone verification missing (Kratos)", "key", verifyKey)
			return errorJSON(c, fiber.StatusForbidden, "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다.")
		}
		traits["phone_number"] = newPhoneStorage
		h.RedisService.Delete(verifyKey)
	}

	if req.Name != "" {
		traits["name"] = req.Name
	}
	if req.Department != "" {
		traits["department"] = req.Department
	}

	// Merge custom metadata into traits
	if len(req.Metadata) > 0 {
		for k, v := range req.Metadata {
			// Do not overwrite core fields
			if _, isCore := map[string]bool{"email": true, "phone_number": true, "name": true, "department": true, "grade": true, "companyCode": true, "affiliationType": true, "id": true, "role": true, "tenant_id": true}[k]; !isCore {
				// [Fix] Support merging namespaced metadata maps
				if incomingMap, ok := v.(map[string]any); ok {
					if existingMap, ok := traits[k].(map[string]any); ok {
						maps.Copy(existingMap, incomingMap)
						traits[k] = existingMap
					} else {
						traits[k] = incomingMap
					}
				} else {
					traits[k] = v
				}
			}
		}
	}

	// [LoginID Sync based on Tenant Settings]
	// Perform sync AFTER metadata merge to ensure traits contains current values
	loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, traits, req.Metadata, identityID)

	// Validate all collected LoginIDs
	userEmail := extractTraitString(traits, "email")
	userPhone := extractTraitString(traits, "phone_number")
	if collectedIDs, ok := traits["custom_login_ids"].([]string); ok {
		for _, lid := range collectedIDs {
			if err := domain.ValidateLoginID(lid, []string{userEmail}, userPhone); err != nil {
				return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error())
			}
		}
	}

	if h.KratosAdmin == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
	}
	updatedIdentity, err := h.KratosAdmin.UpdateIdentity(c.Context(), identityID, traits, "")
	if err != nil {
		slog.Error("Failed to update profile in Kratos", "error", err)
		return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.")
	}
	if updatedIdentity != nil && updatedIdentity.Traits != nil {
		traits = updatedIdentity.Traits
	}

	// [New] Local DB Sync - Sync synchronously to ensure immediate consistency
	if h.UserRepo != nil {
		ctx := context.Background()
		if err := h.syncUpdatedKratosUserReadModel(ctx, identityID, traits); err != nil {
			slog.Error("[UpdateMe] Failed to sync local user read-model", "userID", identityID, "error", err)
			markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
		}
		if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil {
			slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err)
		}
	}

	// Invalidate token-based profile cache so refreshed /user/me returns latest traits.
	if h.RedisService != nil && token != "" {
		cacheKey := "cache:profile:token:" + token
		_ = h.RedisService.Delete(cacheKey)
	}

	slog.Info("[UpdateMe] Profile update completed successfully (Kratos)", "identityID", identityID)
	return c.JSON(fiber.Map{
		"status":    "success",
		"updatedAt": time.Now().Format(time.RFC3339),
	})
}

// ChangeMyPassword - 로그인 상태에서 현재 비밀번호를 확인한 뒤 변경합니다.
func (h *AuthHandler) ChangeMyPassword(c *fiber.Ctx) error {
	var req domain.PasswordChangeRequest
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
	}

	currentPassword := strings.TrimSpace(req.CurrentPassword)
	newPassword := strings.TrimSpace(req.NewPassword)
	if currentPassword == "" || newPassword == "" {
		return errorJSON(c, fiber.StatusBadRequest, "Current password and new password are required")
	}

	policy := h.resolvePasswordPolicy()
	if err := validatePasswordWithPolicy(policy, newPassword); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, err.Error())
	}

	loginID := ""
	token := h.getBearerToken(c)
	if token != "" {
		if resolved, err := h.resolveKratosLoginID(token); err == nil {
			loginID = resolved
		}
	}

	if loginID == "" {
		cookie := c.Get("Cookie")
		if cookie == "" {
			return errorJSON(c, fiber.StatusUnauthorized, "Missing authorization token")
		}
		_, traits, _, err := h.getKratosIdentityWithCookie(cookie)
		if err != nil {
			return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
		}
		loginID = pickLoginIDFromTraits(traits)
		if loginID == "" {
			return errorJSON(c, fiber.StatusUnauthorized, "Login ID not found")
		}
		if !strings.Contains(loginID, "@") {
			loginID = normalizePhoneForLoginID(loginID)
		}
	}

	if _, err := h.IdpProvider.SignIn(loginID, currentPassword); err != nil {
		return errorJSON(c, fiber.StatusUnauthorized, "Current password is invalid")
	}

	if err := h.IdpProvider.UpdateUserPassword(loginID, newPassword, nil); err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to update password")
	}

	return c.JSON(fiber.Map{"message": "Password updated"})
}

// SendUpdateCode - Sends OTP for phone number change
func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error {
	token := h.getBearerToken(c)
	var (
		userID string
		err    error
	)
	if token != "" {
		userID, err = h.resolveIdentityID(c, token)
	} else {
		cookie := c.Get("Cookie")
		if cookie == "" {
			return errorJSON(c, fiber.StatusUnauthorized, "Unauthorized")
		}
		userID, _, _, err = h.getKratosIdentityWithCookie(cookie)
	}
	if err != nil || userID == "" {
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
	}

	var req struct {
		Phone string `json:"phone"`
	}
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid phone")
	}

	phone := h.formatPhoneForStorage(req.Phone)
	code := fmt.Sprintf("%06d", rand.Intn(1000000))

	// Store code in Redis
	key := "otp_update_phone:" + userID + ":" + phone
	h.RedisService.Set(key, code, 5*time.Minute)

	// Send SMS
	content := fmt.Sprintf("[Baron 로그인] 정보 수정 인증번호: [%s]", code)
	go h.SmsService.SendSms(phone, content)

	return c.JSON(fiber.Map{"message": "인증번호가 전송되었습니다."})
}

// VerifyUpdateCode - Verifies OTP for phone number change
func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error {
	token := h.getBearerToken(c)
	var (
		userID string
		err    error
	)
	if token != "" {
		userID, err = h.resolveIdentityID(c, token)
	} else {
		cookie := c.Get("Cookie")
		if cookie == "" {
			return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
		}
		userID, _, _, err = h.getKratosIdentityWithCookie(cookie)
	}
	if err != nil || userID == "" {
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
	}

	var req struct {
		Phone string `json:"phone"`
		Code  string `json:"code"`
	}
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "Invalid request")
	}

	phone := h.formatPhoneForStorage(req.Phone)
	key := "otp_update_phone:" + userID + ":" + phone
	storedCode, _ := h.RedisService.Get(key)

	if storedCode == "" || storedCode != req.Code {
		return errorJSON(c, fiber.StatusUnauthorized, "인증번호가 일치하지 않거나 만료되었습니다.")
	}

	// Mark as verified for 10 minutes
	verifyKey := "verify_update_phone:" + userID + ":" + phone
	h.RedisService.Set(verifyKey, "verified", 10*time.Minute)
	h.RedisService.Delete(key)

	return c.JSON(fiber.Map{"success": true})
}

func hydraClientStatus(metadata map[string]any) string {
	if metadata == nil {
		return "active"
	}
	if value, ok := metadata["status"].(string); ok {
		normalized := strings.ToLower(strings.TrimSpace(value))
		if normalized != "" {
			return normalized
		}
	}
	return "active"
}

func extractHydraClientLogo(metadata map[string]any) string {
	if metadata == nil {
		return ""
	}
	candidates := []string{
		"logo",
		"logo_url",
		"logoUrl",
		"logo_uri",
		"logoUri",
		"app_logo",
		"appLogo",
	}
	for _, key := range candidates {
		if value, ok := metadata[key]; ok {
			if logo, ok := value.(string); ok {
				logo = strings.TrimSpace(logo)
				if logo != "" {
					return logo
				}
			}
		}
	}
	return ""
}

func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string) string {
	switch strings.TrimSpace(clientID) {
	case "adminfront":
		if value := strings.TrimSpace(os.Getenv("ADMINFRONT_URL")); value != "" {
			return value
		}
	case "devfront":
		if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" {
			return value
		}
	case "orgfront":
		if value := strings.TrimSpace(os.Getenv("ORGFRONT_URL")); value != "" {
			return value
		}
	}

	clientURL := strings.TrimSpace(clientURI)
	if clientURL != "" {
		return clientURL
	}

	if len(redirectURIs) > 0 {
		if parsed, err := url.Parse(redirectURIs[0]); err == nil {
			return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
		}
	}

	return ""
}

func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]any) bool {
	if readMetadataBoolValue(metadata, domain.MetadataAutoLoginSupported) {
		return true
	}
	switch strings.TrimSpace(clientID) {
	case "adminfront", "devfront", "orgfront":
		return resolveLinkedRPAutoLoginURL(clientID, nil) != ""
	default:
		return false
	}
}

func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]any) string {
	clientID = strings.TrimSpace(clientID)
	if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" {
		if clientID == "orgfront" {
			return ensureOrgfrontAutoLoginURL(metadataURL)
		}
		return metadataURL
	}

	switch clientID {
	case "adminfront":
		if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ADMINFRONT_URL")), "/"); value != "" {
			return value + "/login?auto=1"
		}
	case "devfront":
		if value := strings.TrimRight(strings.TrimSpace(os.Getenv("DEVFRONT_URL")), "/"); value != "" {
			return value + "/login?auto=1&returnTo=%2Fclients"
		}
	case "orgfront":
		if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ORGFRONT_URL")), "/"); value != "" {
			return value + "/login?auto=1"
		}
	}

	return ""
}

func ensureOrgfrontAutoLoginURL(rawURL string) string {
	parsed, err := url.Parse(rawURL)
	if err != nil {
		return rawURL
	}
	if strings.TrimRight(parsed.Path, "/") != "/login" {
		return rawURL
	}
	query := parsed.Query()
	if query.Get("auto") != "1" {
		query.Set("auto", "1")
		parsed.RawQuery = query.Encode()
	}
	return parsed.String()
}

func resolveLinkedRPInitURL(clientID string, metadata map[string]any) string {
	if !resolveLinkedRPAutoLoginSupported(clientID, metadata) {
		return ""
	}
	return resolveLinkedRPAutoLoginURL(clientID, metadata)
}

func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs []string) string {
	hydraPublicURL := strings.TrimRight(os.Getenv("HYDRA_PUBLIC_URL"), "/")
	if hydraPublicURL == "" {
		userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
		if userfrontURL == "" {
			userfrontURL = "https://sso.hmac.kr"
		}
		hydraPublicURL = userfrontURL + "/oidc"
	}

	redirectURI := ""
	if len(redirectURIs) > 0 {
		redirectURI = strings.TrimSpace(redirectURIs[0])
	}

	mergedScopes := make([]string, 0, len(scopes)+1)
	seen := map[string]struct{}{}
	for _, scope := range append([]string{"openid"}, scopes...) {
		scope = strings.TrimSpace(scope)
		if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
			continue
		}
		if _, ok := seen[scope]; ok {
			continue
		}
		seen[scope] = struct{}{}
		mergedScopes = append(mergedScopes, scope)
	}

	params := url.Values{}
	params.Set("client_id", clientID)
	params.Set("response_type", "code")
	params.Set("scope", strings.Join(mergedScopes, " "))
	params.Set("state", GenerateSecureAlnumToken(16))
	if redirectURI != "" {
		params.Set("redirect_uri", redirectURI)
	}

	return fmt.Sprintf("%s/oauth2/auth?%s", hydraPublicURL, params.Encode())
}

func mergeScopes(current []string, next []string) []string {
	if len(next) == 0 {
		return current
	}
	seen := make(map[string]struct{}, len(current)+len(next))
	for _, scope := range current {
		scope = strings.TrimSpace(scope)
		if scope == "" {
			continue
		}
		seen[scope] = struct{}{}
	}
	for _, scope := range next {
		scope = strings.TrimSpace(scope)
		if scope == "" {
			continue
		}
		if _, ok := seen[scope]; ok {
			continue
		}
		seen[scope] = struct{}{}
		current = append(current, scope)
	}
	return current
}

type rpHistoryItem struct {
	ClientID       string     `json:"client_id"`
	ClientName     string     `json:"client_name"`
	Scopes         []string   `json:"scopes"`
	LastApprovedAt *time.Time `json:"last_approved_at"`
	LastRevokedAt  *time.Time `json:"last_revoked_at"`
	Status         string     `json:"status"`
}

type userSessionItem struct {
	SessionID       string     `json:"session_id"`
	AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
	ExpiresAt       *time.Time `json:"expires_at,omitempty"`
	IssuedAt        *time.Time `json:"issued_at,omitempty"`
	LastSeenAt      *time.Time `json:"last_seen_at,omitempty"`
	IPAddress       string     `json:"ip_address,omitempty"`
	UserAgent       string     `json:"user_agent,omitempty"`
	ClientID        string     `json:"client_id,omitempty"`
	AppName         string     `json:"app_name,omitempty"`
	IsCurrent       bool       `json:"is_current"`
	IsActive        bool       `json:"is_active"`
}

type userSessionListResponse struct {
	Items []userSessionItem `json:"items"`
}

func (h *AuthHandler) ListMySessions(c *fiber.Ctx) error {
	if h.KratosAdmin == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable")
	}

	profile, err := h.resolveCurrentProfile(c)
	if err != nil {
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
	}
	if strings.TrimSpace(profile.ID) == "" {
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
	}

	sessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID)
	if err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions")
	}

	currentSessionID := h.resolveCurrentSessionID(c)
	auditHints := h.loadSessionAuditHints(c.Context(), profile.ID)

	items := make([]userSessionItem, 0, len(sessions))
	for _, session := range sessions {
		if !session.Active {
			continue
		}
		item := userSessionItem{
			SessionID: session.ID,
			IsCurrent: session.ID != "" && session.ID == currentSessionID,
			IsActive:  session.Active,
		}
		if !session.AuthenticatedAt.IsZero() {
			ts := session.AuthenticatedAt
			item.AuthenticatedAt = &ts
			item.LastSeenAt = &ts
		}
		if !session.ExpiresAt.IsZero() {
			ts := session.ExpiresAt
			item.ExpiresAt = &ts
		}
		if !session.IssuedAt.IsZero() {
			ts := session.IssuedAt
			item.IssuedAt = &ts
			if item.AuthenticatedAt == nil {
				item.AuthenticatedAt = &ts
			}
			if item.LastSeenAt == nil {
				item.LastSeenAt = &ts
			}
		}
		if hint, ok := auditHints[session.ID]; ok {
			if item.IPAddress == "" {
				item.IPAddress = hint.IPAddress
			}
			if item.UserAgent == "" {
				item.UserAgent = hint.UserAgent
			}
			if item.ClientID == "" {
				item.ClientID = hint.ClientID
			}
			if item.AppName == "" {
				item.AppName = hint.AppName
			}
			if hint.Timestamp != nil {
				item.LastSeenAt = hint.Timestamp
			}
		}
		if item.UserAgent == "" && len(session.Devices) > 0 {
			deviceUserAgent := strings.TrimSpace(session.Devices[0].UserAgent)
			if !looksLikeInternalUserAgent(deviceUserAgent) {
				item.UserAgent = deviceUserAgent
			}
		}
		if item.IPAddress == "" && len(session.Devices) > 0 {
			item.IPAddress = strings.TrimSpace(session.Devices[0].IPAddress)
		}
		if item.IsCurrent {
			applyCurrentSessionRequestHints(c, &item)
		}
		items = append(items, item)
	}

	sort.Slice(items, func(i, j int) bool {
		if items[i].IsCurrent != items[j].IsCurrent {
			return items[i].IsCurrent
		}
		iTime := latestSessionTimestamp(items[i])
		jTime := latestSessionTimestamp(items[j])
		if iTime.Equal(jTime) {
			return items[i].SessionID < items[j].SessionID
		}
		return iTime.After(jTime)
	})

	return c.JSON(userSessionListResponse{Items: items})
}

func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error {
	if h.KratosAdmin == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable")
	}

	profile, err := h.resolveCurrentProfile(c)
	if err != nil {
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
	}
	targetSessionID := strings.TrimSpace(c.Params("id"))
	if targetSessionID == "" {
		return errorJSON(c, fiber.StatusBadRequest, "session id is required")
	}

	mySessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID)
	if err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions")
	}
	ownedSession := false
	for _, candidate := range mySessions {
		if strings.TrimSpace(candidate.ID) == targetSessionID {
			ownedSession = true
			break
		}
	}
	if !ownedSession {
		return errorJSON(c, fiber.StatusForbidden, "forbidden")
	}

	session, err := h.KratosAdmin.GetSession(c.Context(), targetSessionID)
	if err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch session")
	}
	if session == nil {
		h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, "already_missing")
		return c.JSON(fiber.Map{"status": "ok"})
	}

	result := "revoked"
	if !session.Active {
		result = "already_inactive"
	} else if err := h.KratosAdmin.DeleteSession(c.Context(), targetSessionID); err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to delete session")
	}
	if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions")
	}
	h.triggerBackchannelLogoutForSession(c.Context(), c, profile.ID, targetSessionID)

	h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result)
	return c.JSON(fiber.Map{"status": "ok"})
}

func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error {
	subject, err := h.resolveConsentSubject(c)
	if err != nil || subject == "" {
		return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
	}

	if h.AuditRepo == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
	}

	logs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), subject, []string{"consent.granted", "consent.revoked"}, 100)
	if err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch history")
	}

	historyMap := make(map[string]*rpHistoryItem)

	// Logs are DESC (newest first). Iterate in reverse (oldest first) to build state.
	for i := len(logs) - 1; i >= 0; i-- {
		log := logs[i]
		details, _ := utils.ParseAuditDetails(log.Details)
		clientID, _ := details["client_id"].(string)
		if clientID == "" {
			continue
		}

		item, ok := historyMap[clientID]
		if !ok {
			item = &rpHistoryItem{
				ClientID: clientID,
				Status:   "unknown",
			}
			historyMap[clientID] = item
		}

		if name, ok := details["client_name"].(string); ok && name != "" {
			item.ClientName = name
		}

		if log.EventType == "consent.granted" {
			item.Status = "active"
			ts := log.Timestamp
			item.LastApprovedAt = &ts

			if scopesRaw, ok := details["scopes"].([]any); ok {
				scopes := make([]string, 0, len(scopesRaw))
				for _, s := range scopesRaw {
					if str, ok := s.(string); ok {
						scopes = append(scopes, str)
					}
				}
				item.Scopes = scopes
			}
		} else if log.EventType == "consent.revoked" {
			item.Status = "revoked"
			ts := log.Timestamp
			item.LastRevokedAt = &ts
		}
	}

	items := make([]rpHistoryItem, 0, len(historyMap))
	for _, item := range historyMap {
		items = append(items, *item)
	}

	sort.Slice(items, func(i, j int) bool {
		t1 := time.Time{}
		if items[i].LastApprovedAt != nil {
			t1 = *items[i].LastApprovedAt
		}
		if items[i].LastRevokedAt != nil && items[i].LastRevokedAt.After(t1) {
			t1 = *items[i].LastRevokedAt
		}

		t2 := time.Time{}
		if items[j].LastApprovedAt != nil {
			t2 = *items[j].LastApprovedAt
		}
		if items[j].LastRevokedAt != nil && items[j].LastRevokedAt.After(t2) {
			t2 = *items[j].LastRevokedAt
		}

		return t1.After(t2)
	})

	return c.JSON(fiber.Map{"items": items})
}

type sessionAuditHint struct {
	Timestamp *time.Time
	IPAddress string
	UserAgent string
	ClientID  string
	AppName   string
}

func latestSessionTimestamp(item userSessionItem) time.Time {
	for _, candidate := range []*time.Time{item.LastSeenAt, item.AuthenticatedAt, item.IssuedAt} {
		if candidate != nil {
			return *candidate
		}
	}
	return time.Time{}
}

func (h *AuthHandler) resolveCurrentSessionID(c *fiber.Ctx) string {
	if c == nil {
		return ""
	}
	if token := h.getBearerToken(c); token != "" {
		if sessionID := extractSessionIDFromJWT(token); sessionID != "" {
			return sessionID
		}
		if sessionID, err := h.getKratosSessionID(token); err == nil {
			return sessionID
		}
	}
	if cookie := c.Get("Cookie"); cookie != "" {
		if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil {
			return sessionID
		}
	}
	return ""
}

func applyCurrentSessionRequestHints(c *fiber.Ctx, item *userSessionItem) {
	if c == nil || item == nil || !item.IsCurrent {
		return
	}

	if item.IPAddress == "" {
		item.IPAddress = strings.TrimSpace(resolveRequestClientIP(c))
	}
	if item.UserAgent == "" {
		userAgent := strings.TrimSpace(c.Get("User-Agent"))
		if !looksLikeInternalUserAgent(userAgent) {
			item.UserAgent = userAgent
		}
	}
	if strings.TrimSpace(item.ClientID) == "" {
		item.ClientID = "userfront"
	}
	if strings.TrimSpace(item.AppName) == "" {
		item.AppName = "UserFront"
	}
}

func resolveRequestClientIP(c *fiber.Ctx) string {
	if c == nil {
		return ""
	}
	return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP())
}

func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) map[string]sessionAuditHint {
	hints := make(map[string]sessionAuditHint)
	if h.AuditRepo == nil || strings.TrimSpace(userID) == "" {
		return hints
	}

	logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{
		"login_success",
		"qr_login_success",
		"link_login_success",
		"code_login_success",
		"password_login_success",
		"consent.granted",
		"POST /api/v1/auth/oidc/login/accept",
		"POST /api/v1/auth/password/login",
		"POST /api/v1/auth/headless/password/login",
		"POST /api/v1/auth/magic-link/verify",
		"POST /api/v1/auth/login/code/verify",
		"POST /api/v1/auth/qr/approve",
		"session.revoked",
	}, 200)
	if err != nil {
		return hints
	}

	for _, log := range logs {
		sessionID := strings.TrimSpace(log.SessionID)
		if sessionID == "" {
			sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details))
		}
		if sessionID == "" {
			sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details))
		}
		if sessionID == "" {
			continue
		}

		ts := log.Timestamp
		ipAddress := strings.TrimSpace(log.IPAddress)
		userAgent := strings.TrimSpace(log.UserAgent)
		clientID, appName := deriveSessionClientInfo(log)
		if details, err := parseAuditDetails(log.Details); err == nil {
			if approvedIP, ok := details["approved_ip"].(string); ok && strings.TrimSpace(approvedIP) != "" {
				ipAddress = strings.TrimSpace(approvedIP)
			}
			if approvedUserAgent, ok := details["approved_user_agent"].(string); ok && strings.TrimSpace(approvedUserAgent) != "" {
				userAgent = strings.TrimSpace(approvedUserAgent)
			}
		}
		if looksLikeInternalUserAgent(userAgent) {
			userAgent = ""
		}
		hints[sessionID] = mergeSessionAuditHint(hints[sessionID], sessionAuditHint{
			Timestamp: &ts,
			IPAddress: ipAddress,
			UserAgent: userAgent,
			ClientID:  clientID,
			AppName:   appName,
		})
	}
	return hints
}

func mergeSessionAuditHint(existing sessionAuditHint, candidate sessionAuditHint) sessionAuditHint {
	if candidate.Timestamp != nil &&
		(existing.Timestamp == nil || candidate.Timestamp.After(*existing.Timestamp)) {
		existing.Timestamp = candidate.Timestamp
	}
	if shouldReplaceSessionIP(existing.IPAddress, candidate.IPAddress) {
		existing.IPAddress = candidate.IPAddress
	}
	if existing.UserAgent == "" && candidate.UserAgent != "" {
		existing.UserAgent = candidate.UserAgent
	}
	if existing.ClientID == "" && candidate.ClientID != "" {
		existing.ClientID = candidate.ClientID
	}
	if existing.AppName == "" && candidate.AppName != "" {
		existing.AppName = candidate.AppName
	}
	return existing
}

func shouldReplaceSessionIP(existing string, candidate string) bool {
	existing = strings.TrimSpace(existing)
	candidate = strings.TrimSpace(candidate)
	if candidate == "" {
		return false
	}
	if existing == "" {
		return true
	}
	if isPrivateIPAddress(existing) && !isPrivateIPAddress(candidate) {
		return true
	}
	return false
}

func isPrivateIPAddress(raw string) bool {
	return utils.IsPrivateOrReservedIP(raw)
}

func parseAuditDetails(details string) (map[string]any, error) {
	return utils.ParseAuditDetails(details)
}

func deriveSessionClientInfo(log domain.AuditLog) (string, string) {
	details, _ := parseAuditDetails(log.Details)
	clientID := ""
	appName := ""
	if details != nil {
		if value, ok := details["client_id"].(string); ok {
			clientID = strings.TrimSpace(value)
		}
		if value, ok := details["client_name"].(string); ok {
			appName = strings.TrimSpace(value)
		}
	}
	path := strings.ToLower(extractAuditPath(log))
	if appName == "" {
		switch {
		case strings.Contains(path, "/api/v1/auth/oidc/login/accept"):
			appName = "OIDC 로그인"
		case strings.Contains(path, "/api/v1/auth/qr/approve"):
			appName = "QR 로그인"
		case strings.Contains(path, "/api/v1/auth/login/code/verify"):
			appName = "코드 로그인"
		case strings.Contains(path, "/api/v1/auth/magic-link/verify"):
			appName = "링크 로그인"
		case strings.Contains(path, "/api/v1/auth/password/login"),
			strings.Contains(path, "/api/v1/auth/headless/password/login"):
			appName = "비밀번호 로그인"
		}
	}
	if appName == "" && clientID != "" {
		appName = clientID
	}
	return clientID, appName
}

func extractStringLikeValue(raw any) string {
	switch value := raw.(type) {
	case string:
		return strings.TrimSpace(value)
	default:
		text := strings.TrimSpace(fmt.Sprint(value))
		if text == "" || text == "<nil>" {
			return ""
		}
		return text
	}
}

func extractHydraSessionID(ext map[string]any) string {
	if len(ext) == 0 {
		return ""
	}
	for _, key := range []string{"session_id", "sid", "sessionId"} {
		if value := extractStringLikeValue(ext[key]); value != "" {
			return value
		}
	}
	return ""
}

func (h *AuthHandler) validateHydraTokenSession(ctx context.Context, intro *service.HydraIntrospectionResponse) error {
	if h == nil || h.KratosAdmin == nil || intro == nil {
		return nil
	}

	sessionID := extractHydraSessionID(intro.Ext)
	if sessionID == "" {
		return nil
	}

	session, err := h.KratosAdmin.GetSession(ctx, sessionID)
	if err != nil {
		return fmt.Errorf("kratos session lookup failed: %w", err)
	}
	if session == nil {
		return errors.New("linked session not found")
	}
	if !session.Active {
		return errors.New("linked session is inactive")
	}
	if identityID := strings.TrimSpace(session.Identity.ID); identityID != "" && strings.TrimSpace(intro.Subject) != "" && identityID != strings.TrimSpace(intro.Subject) {
		return errors.New("linked session subject mismatch")
	}
	return nil
}

func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID string) map[string][]string {
	bindings := make(map[string][]string)
	if h == nil || h.AuditRepo == nil || strings.TrimSpace(userID) == "" {
		return bindings
	}

	logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{
		"consent.granted",
		"POST /api/v1/auth/oidc/login/accept",
		"POST /api/v1/auth/password/login",
		"POST /api/v1/auth/headless/password/login",
		"password_login_success",
		"login_success",
	}, 200)
	if err != nil {
		return bindings
	}

	for _, log := range logs {
		sessionID := strings.TrimSpace(log.SessionID)
		if sessionID == "" {
			sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details))
		}
		if sessionID == "" {
			sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details))
		}
		if sessionID == "" {
			continue
		}

		clientID, _ := deriveSessionClientInfo(log)
		clientID = strings.TrimSpace(clientID)
		if clientID == "" {
			continue
		}

		existing := bindings[sessionID]
		seen := slices.Contains(existing, clientID)
		if !seen {
			bindings[sessionID] = append(existing, clientID)
		}
	}

	return bindings
}

func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID string, sessionID string) error {
	if h == nil || h.Hydra == nil {
		return nil
	}

	clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)]
	if len(clientIDs) == 0 {
		return nil
	}
	for _, clientID := range clientIDs {
		if err := h.Hydra.RevokeConsentSessions(ctx, userID, clientID); err != nil {
			return err
		}
	}
	return nil
}

func (h *AuthHandler) triggerBackchannelLogoutForSession(ctx context.Context, c *fiber.Ctx, userID string, sessionID string) {
	if h == nil || h.Hydra == nil {
		return
	}

	clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)]
	for _, clientID := range clientIDs {
		h.triggerBackchannelLogoutForClient(ctx, c, userID, clientID, sessionID)
	}
}

func (h *AuthHandler) triggerBackchannelLogoutForClient(ctx context.Context, c *fiber.Ctx, userID string, clientID string, sessionID string) {
	if h == nil || h.Hydra == nil || h.BackchannelLogout == nil {
		return
	}

	clientID = strings.TrimSpace(clientID)
	userID = strings.TrimSpace(userID)
	sessionID = strings.TrimSpace(sessionID)
	if clientID == "" || userID == "" {
		return
	}

	client, err := h.Hydra.GetClient(ctx, clientID)
	if err != nil {
		h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, "", 0, "client_lookup_failed")
		return
	}
	if client == nil {
		h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "client_not_found")
		return
	}

	endpoint := client.BackchannelLogoutURI()
	if endpoint == "" {
		h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "uri_not_configured")
		return
	}
	if client.BackchannelLogoutSessionRequiredValue() && sessionID == "" {
		h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, endpoint, 0, "sid_required")
		return
	}

	logoutToken, err := h.BackchannelLogout.BuildLogoutToken(clientID, userID, sessionID)
	if err != nil {
		h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, 0, "token_build_failed")
		return
	}

	statusCode, err := h.BackchannelLogout.SendLogoutToken(ctx, endpoint, logoutToken)
	if err != nil {
		h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, statusCode, "request_failed")
		return
	}

	h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.sent", userID, clientID, sessionID, endpoint, statusCode, "")
}

func (h *AuthHandler) writeBackchannelLogoutAuditLog(c *fiber.Ctx, eventType string, userID string, clientID string, sessionID string, endpoint string, statusCode int, reason string) {
	if h == nil || h.AuditRepo == nil {
		return
	}

	endpointHost := ""
	if endpoint != "" {
		if parsed, err := url.Parse(endpoint); err == nil {
			endpointHost = parsed.Host
		}
	}

	details := map[string]any{
		"client_id":     strings.TrimSpace(clientID),
		"session_id":    strings.TrimSpace(sessionID),
		"endpoint_host": strings.TrimSpace(endpointHost),
		"status_code":   statusCode,
		"retry_count":   0,
		"logout_issuer": h.BackchannelLogout.Issuer(),
	}
	if reason != "" {
		details["reason"] = reason
	}

	raw, err := json.Marshal(details)
	if err != nil {
		return
	}

	status := "success"
	if strings.HasSuffix(eventType, ".failed") {
		status = "failure"
	} else if strings.HasSuffix(eventType, ".skipped") {
		status = "skipped"
	}

	ipAddress := ""
	userAgent := ""
	if c != nil {
		ipAddress = extractClientIPFromHeaders(c)
		userAgent = strings.TrimSpace(c.Get("User-Agent"))
	}

	_ = h.AuditRepo.Create(&domain.AuditLog{
		EventID:   fmt.Sprintf("backchannel-logout-%d", time.Now().UnixNano()),
		Timestamp: time.Now().UTC(),
		UserID:    strings.TrimSpace(userID),
		SessionID: strings.TrimSpace(sessionID),
		EventType: eventType,
		Status:    status,
		IPAddress: ipAddress,
		UserAgent: userAgent,
		Details:   string(raw),
	})
}

func (h *AuthHandler) GetBackchannelLogoutJWKS(c *fiber.Ctx) error {
	if h == nil || h.BackchannelLogout == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "backchannel logout jwks unavailable")
	}
	c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8)
	c.Set(fiber.HeaderCacheControl, "no-store")
	return c.JSON(h.BackchannelLogout.PublicJWKS())
}

func looksLikeInternalUserAgent(userAgent string) bool {
	normalized := strings.ToLower(strings.TrimSpace(userAgent))
	if normalized == "" {
		return false
	}
	return strings.HasPrefix(normalized, "go-http-client/") ||
		strings.HasPrefix(normalized, "fasthttp") ||
		strings.HasPrefix(normalized, "fiber")
}

func (h *AuthHandler) writeSessionRevokedAuditLog(c *fiber.Ctx, actorIdentityID string, actorSessionID string, targetSessionID string, result string) {
	if h.AuditRepo == nil {
		return
	}

	details := map[string]any{
		"target_session_id": strings.TrimSpace(targetSessionID),
		"revoke_result":     strings.TrimSpace(result),
	}
	if strings.TrimSpace(actorSessionID) != "" {
		details["actor_session_id"] = strings.TrimSpace(actorSessionID)
	}
	raw, err := json.Marshal(details)
	if err != nil {
		return
	}

	_ = h.AuditRepo.Create(&domain.AuditLog{
		EventID:   fmt.Sprintf("session-revoked-%d", time.Now().UnixNano()),
		Timestamp: time.Now().UTC(),
		UserID:    strings.TrimSpace(actorIdentityID),
		SessionID: strings.TrimSpace(actorSessionID),
		EventType: "session.revoked",
		Status:    "success",
		IPAddress: extractClientIPFromHeaders(c),
		UserAgent: strings.TrimSpace(c.Get("User-Agent")),
		Details:   string(raw),
	})
}
