package handler

import (
	"baron-sso-backend/internal/domain"
	"baron-sso-backend/internal/pagination"
	"baron-sso-backend/internal/repository"
	"baron-sso-backend/internal/service"
	"baron-sso-backend/internal/utils"
	"context"
	"encoding/csv"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"maps"
	"net/http"
	"net/mail"
	"os"
	"reflect"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/gofiber/fiber/v2"
	"github.com/google/uuid"
)

// OryProviderAPI defines the subset of Ory Provider used by UserHandler
type OryProviderAPI interface {
	CreateUser(user *domain.BrokerUser, password string) (string, error)
	UpdateUserPassword(loginID, newPassword string, r *http.Request) error
	GetPasswordPolicy() (*domain.PasswordPolicy, error)
}

type UserHandler struct {
	KratosAdmin        service.KratosAdminService
	OryProvider        OryProviderAPI
	TenantService      service.TenantService
	KetoService        service.KetoService
	KetoOutboxRepo     repository.KetoOutboxRepository
	UserRepo           repository.UserRepository
	UserProjectionRepo repository.UserProjectionRepository
	UserGroupRepo      repository.UserGroupRepository
	AuditRepo          domain.AuditRepository
	Worksmobile        service.WorksmobileSyncer
}

func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository, auditRepo domain.AuditRepository) *UserHandler {
	return &UserHandler{
		KratosAdmin:    kratosAdmin,
		OryProvider:    oryProvider,
		TenantService:  tenantService,
		KetoService:    ketoService,
		KetoOutboxRepo: ketoOutboxRepo,
		UserRepo:       userRepo,
		UserGroupRepo:  userGroupRepo,
		AuditRepo:      auditRepo,
	}
}

func (h *UserHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
	h.Worksmobile = syncer
}

func mergeUserAppointmentMetadata(metadata map[string]any, appointments []map[string]any, primaryTenantID string, primaryTenantName string, primaryTenantIsOwner *bool) map[string]any {
	if metadata == nil {
		metadata = map[string]any{}
	}
	if len(appointments) > 0 {
		values := make([]any, 0, len(appointments))
		for _, appointment := range appointments {
			values = append(values, appointment)
		}
		metadata["additionalAppointments"] = values
	}
	if strings.TrimSpace(primaryTenantID) != "" {
		metadata["primaryTenantId"] = strings.TrimSpace(primaryTenantID)
	}
	if strings.TrimSpace(primaryTenantName) != "" {
		metadata["primaryTenantName"] = strings.TrimSpace(primaryTenantName)
	}
	if primaryTenantIsOwner != nil {
		metadata["primaryTenantIsOwner"] = *primaryTenantIsOwner
	}
	return metadata
}

func sanitizeUserMetadata(metadata map[string]any) map[string]any {
	if metadata == nil {
		return nil
	}
	sanitized := make(map[string]any, len(metadata))
	for key, value := range metadata {
		if key == "hanmacFamily" || key == "userType" {
			continue
		}
		sanitized[key] = value
	}
	return sanitized
}

func userAppointmentSliceFromRaw(raw any) []any {
	switch values := raw.(type) {
	case []any:
		return append([]any(nil), values...)
	case []map[string]any:
		appointments := make([]any, 0, len(values))
		for _, value := range values {
			appointments = append(appointments, value)
		}
		return appointments
	default:
		return nil
	}
}

func userAppointmentTenantKey(raw any) string {
	appointment, ok := raw.(map[string]any)
	if !ok {
		return ""
	}
	if value := normalizeMetadataString(appointment["tenantId"]); value != "" {
		return "id:" + strings.ToLower(value)
	}
	if value := normalizeMetadataString(appointment["tenantSlug"]); value != "" {
		return "slug:" + strings.ToLower(value)
	}
	if value := normalizeMetadataString(appointment["slug"]); value != "" {
		return "slug:" + strings.ToLower(value)
	}
	return ""
}

func mergeUserAddTenantAppointment(traits map[string]any, metadata map[string]any, tenant *domain.Tenant) map[string]any {
	if tenant == nil {
		return metadata
	}
	if metadata == nil {
		metadata = map[string]any{}
	}

	appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
	if len(appointments) == 0 {
		if legacyMetadata, ok := traits["metadata"].(map[string]any); ok {
			appointments = userAppointmentSliceFromRaw(legacyMetadata["additionalAppointments"])
		}
	}
	if incoming := userAppointmentSliceFromRaw(metadata["additionalAppointments"]); len(incoming) > 0 {
		appointments = incoming
	}

	seen := make(map[string]bool, len(appointments)+1)
	for _, appointment := range appointments {
		if key := userAppointmentTenantKey(appointment); key != "" {
			seen[key] = true
		}
	}
	tenantIDKey := "id:" + strings.ToLower(strings.TrimSpace(tenant.ID))
	tenantSlugKey := "slug:" + strings.ToLower(strings.TrimSpace(tenant.Slug))
	if !seen[tenantIDKey] && !seen[tenantSlugKey] {
		appointments = append(appointments, map[string]any{
			"tenantId":   tenant.ID,
			"tenantSlug": tenant.Slug,
			"tenantName": tenant.Name,
			"isPrimary":  false,
		})
	}
	metadata["additionalAppointments"] = appointments
	return metadata
}

func removeUserTenantAppointment(traits map[string]any, metadata map[string]any, tenant *domain.Tenant) map[string]any {
	if tenant == nil {
		return metadata
	}
	if metadata == nil {
		metadata = map[string]any{}
	}

	targetID := strings.ToLower(strings.TrimSpace(tenant.ID))
	targetSlug := strings.ToLower(strings.TrimSpace(tenant.Slug))
	matchesTarget := func(raw any) bool {
		appointment, ok := raw.(map[string]any)
		if !ok {
			return false
		}
		tenantID := strings.ToLower(normalizeMetadataString(appointment["tenantId"]))
		tenantSlug := strings.ToLower(normalizeMetadataString(appointment["tenantSlug"]))
		if tenantSlug == "" {
			tenantSlug = strings.ToLower(normalizeMetadataString(appointment["slug"]))
		}
		return (targetID != "" && tenantID == targetID) ||
			(targetSlug != "" && tenantSlug == targetSlug)
	}

	appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
	if len(appointments) == 0 {
		if legacyMetadata, ok := traits["metadata"].(map[string]any); ok {
			appointments = userAppointmentSliceFromRaw(legacyMetadata["additionalAppointments"])
		}
	}
	if incoming := userAppointmentSliceFromRaw(metadata["additionalAppointments"]); len(incoming) > 0 {
		appointments = incoming
	}

	filtered := make([]any, 0, len(appointments))
	removedPrimary := false
	for _, appointment := range appointments {
		if matchesTarget(appointment) {
			if value, ok := metadataBoolFromMap(appointment.(map[string]any), "isPrimary", "primary", "representative", "isRepresentative"); ok && value {
				removedPrimary = true
			}
			continue
		}
		filtered = append(filtered, appointment)
	}
	if len(filtered) > 0 {
		traits["additionalAppointments"] = filtered
		metadata["additionalAppointments"] = filtered
	} else {
		delete(traits, "additionalAppointments")
		delete(metadata, "additionalAppointments")
	}

	delete(traits, tenant.ID)
	delete(metadata, tenant.ID)
	if primaryTenantID := strings.ToLower(normalizeMetadataString(traits["primaryTenantId"])); primaryTenantID == targetID && targetID != "" {
		removedPrimary = true
	}
	if primaryTenantSlug := strings.ToLower(normalizeMetadataString(traits["primaryTenantSlug"])); primaryTenantSlug == targetSlug && targetSlug != "" {
		removedPrimary = true
	}
	if removedPrimary {
		delete(traits, "primaryTenantId")
		delete(traits, "primaryTenantSlug")
		delete(traits, "primaryTenantName")
		delete(traits, "primaryTenantIsOwner")
		delete(metadata, "primaryTenantId")
		delete(metadata, "primaryTenantSlug")
		delete(metadata, "primaryTenantName")
		delete(metadata, "primaryTenantIsOwner")
	}

	return metadata
}

func userMetadataRecordFromAny(value any) map[string]any {
	switch typed := value.(type) {
	case map[string]any:
		return typed
	case domain.JSONMap:
		return map[string]any(typed)
	default:
		return nil
	}
}

func enforceGlobalCustomClaimWritePermissions(traits map[string]any, metadata map[string]any, isAdmin bool) error {
	if isAdmin || metadata == nil {
		return nil
	}

	incomingClaims := userMetadataRecordFromAny(metadata["global_custom_claims"])
	if incomingClaims == nil {
		return nil
	}

	existingClaims := userMetadataRecordFromAny(traits["global_custom_claims"])
	existingPermissions := userMetadataRecordFromAny(traits["global_custom_claim_permissions"])
	existingTypes := userMetadataRecordFromAny(traits["global_custom_claim_types"])

	claimKeys := map[string]bool{}
	for key := range incomingClaims {
		claimKeys[strings.TrimSpace(key)] = true
	}
	for key := range existingClaims {
		claimKeys[strings.TrimSpace(key)] = true
	}

	for key := range claimKeys {
		if key == "" {
			continue
		}
		incomingValue, incomingExists := incomingClaims[key]
		existingValue, existingExists := existingClaims[key]
		if incomingExists && existingExists && reflect.DeepEqual(incomingValue, existingValue) {
			continue
		}
		if !incomingExists && !existingExists {
			continue
		}

		permission := "admin_only"
		if rawPermission := userMetadataRecordFromAny(existingPermissions[key]); rawPermission != nil {
			permission = normalizeCustomClaimPermission(rawPermission["writePermission"])
		}
		if permission != "user_and_admin" {
			return fmt.Errorf("global custom claim %s is admin only", key)
		}
	}

	if len(existingPermissions) > 0 {
		metadata["global_custom_claim_permissions"] = existingPermissions
	}
	if len(existingTypes) > 0 {
		metadata["global_custom_claim_types"] = existingTypes
	}
	return nil
}

func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService service.TenantService, metadata map[string]any, appointments []map[string]any) (bool, error) {
	if tenantService == nil || metadata == nil {
		return false, nil
	}

	cleared := false
	clearMetadataPrimary := func() {
		delete(metadata, "primaryTenantId")
		delete(metadata, "primaryTenantSlug")
		delete(metadata, "primaryTenantName")
		delete(metadata, "primaryTenantIsOwner")
		cleared = true
	}
	if isNonPublicRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
		clearMetadataPrimary()
	}

	clearAppointment := func(appointment map[string]any) {
		if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary", "representative", "isRepresentative"); !ok || !isPrimary {
			return
		}
		tenantID := normalizeMetadataString(appointment["tenantId"])
		tenantSlug := normalizeMetadataString(appointment["tenantSlug"])
		if tenantSlug == "" {
			tenantSlug = normalizeMetadataString(appointment["slug"])
		}
		if !isNonPublicRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
			return
		}
		appointment["isPrimary"] = false
		appointment["primary"] = false
		appointment["representative"] = false
		appointment["isRepresentative"] = false
		clearMetadataPrimary()
	}

	for _, appointment := range appointments {
		clearAppointment(appointment)
	}
	if rawAppointments, ok := metadata["additionalAppointments"].([]any); ok {
		for _, rawAppointment := range rawAppointments {
			if appointment, ok := rawAppointment.(map[string]any); ok {
				clearAppointment(appointment)
			}
		}
	}
	return cleared, nil
}

func isNonPublicRepresentativeTenant(ctx context.Context, tenantService service.TenantService, tenantID string, tenantSlug string) bool {
	var tenant *domain.Tenant
	var err error
	if strings.TrimSpace(tenantID) != "" {
		tenant, err = tenantService.GetTenant(ctx, strings.TrimSpace(tenantID))
	} else if strings.TrimSpace(tenantSlug) != "" {
		tenant, err = tenantService.GetTenantBySlug(ctx, strings.TrimSpace(tenantSlug))
	}
	if err != nil || tenant == nil {
		return false
	}
	visibility := tenantVisibility(tenant.Config)
	return visibility == "internal" || visibility == "private"
}

func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
	if value := strings.TrimSpace(primaryTenantID); value != "" {
		return value
	}
	if value := normalizeMetadataString(metadata["primaryTenantId"]); value != "" {
		return value
	}
	for _, appointment := range appointments {
		if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary"); ok && isPrimary {
			if value := normalizeMetadataString(appointment["tenantId"]); value != "" {
				return value
			}
		}
	}
	if len(appointments) > 0 {
		return normalizeMetadataString(appointments[0]["tenantId"])
	}
	if raw, ok := metadata["additionalAppointments"].([]any); ok {
		for _, item := range raw {
			appointment, ok := item.(map[string]any)
			if !ok {
				continue
			}
			if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary"); ok && isPrimary {
				if value := normalizeMetadataString(appointment["tenantId"]); value != "" {
					return value
				}
			}
		}
		if len(raw) > 0 {
			if appointment, ok := raw[0].(map[string]any); ok {
				return normalizeMetadataString(appointment["tenantId"])
			}
		}
	}
	return ""
}

func bulkUserEmailDomainCandidates(emailDomain string, email string) []string {
	values := make([]string, 0, 2)
	seen := map[string]bool{}
	add := func(value string) {
		normalized := strings.ToLower(strings.TrimSpace(value))
		if normalized == "" || seen[normalized] {
			return
		}
		seen[normalized] = true
		values = append(values, normalized)
	}
	for _, value := range strings.FieldsFunc(emailDomain, func(r rune) bool {
		return r == ',' || r == ';' || r == '\n' || r == '\r'
	}) {
		add(value)
	}
	if _, domainPart, err := domain.SplitEmailDomain(email); err == nil {
		add(domainPart)
	}
	return values
}

func bulkUserAssignmentContainsTenant(appointments []any, primaryTenantID string, tenantID string) bool {
	if strings.TrimSpace(tenantID) == "" {
		return true
	}
	if primaryTenantID != "" && primaryTenantID == tenantID {
		return true
	}
	for _, item := range appointments {
		appointment, ok := item.(map[string]any)
		if !ok {
			continue
		}
		if normalizeMetadataString(appointment["tenantId"]) == tenantID {
			return true
		}
	}
	return false
}

func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) {
	for _, key := range keys {
		value, ok := metadata[key]
		if !ok {
			continue
		}
		switch v := value.(type) {
		case bool:
			return v, true
		case string:
			normalized := strings.ToLower(strings.TrimSpace(v))
			if normalized == "true" || normalized == "1" || normalized == "yes" {
				return true, true
			}
			if normalized == "false" || normalized == "0" || normalized == "no" {
				return false, true
			}
		case float64:
			return v != 0, true
		case int:
			return v != 0, true
		}
	}
	return false, false
}

func roleFromTraits(traits map[string]any) string {
	if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role")); ok {
		return role
	}
	if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "grade")); ok {
		return role
	}
	return domain.RoleUser
}

func normalizeAssignableSystemRole(value string) (string, bool) {
	role, ok := domain.NormalizeRoleAlias(value)
	if !ok {
		return "", false
	}
	return role, role == domain.RoleSuperAdmin || role == domain.RoleUser
}

func gradeFromTraits(traits map[string]any) string {
	value := strings.TrimSpace(extractTraitString(traits, "grade"))
	if value == "" {
		return ""
	}
	if _, ok := domain.NormalizeRoleAlias(value); ok {
		return ""
	}
	return value
}

func rejectLegacyCompanyCode(value string) error {
	if strings.TrimSpace(value) != "" {
		return errors.New("companyCode is deprecated; use tenantSlug")
	}
	return nil
}

func rejectLegacyCompanyCodePointer(value *string) error {
	if value == nil {
		return nil
	}
	return rejectLegacyCompanyCode(*value)
}

func tenantSlugFromRequest(tenantSlug string, legacyCompanyCode string) (string, error) {
	if err := rejectLegacyCompanyCode(legacyCompanyCode); err != nil {
		return "", err
	}
	if value := strings.TrimSpace(tenantSlug); value != "" {
		return value, nil
	}
	return "", nil
}

func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string) (*string, error) {
	if err := rejectLegacyCompanyCodePointer(legacyCompanyCode); err != nil {
		return nil, err
	}
	if tenantSlug != nil {
		value := strings.TrimSpace(*tenantSlug)
		return &value, nil
	}
	return nil, nil
}

func identityTenantAccessKeys(traits map[string]any) []string {
	keys := make([]string, 0, 2)
	if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
		keys = append(keys, tenantID)
	}
	return keys
}

func anyTenantKeyAllowed(keys []string, allowed map[string]bool) bool {
	for _, key := range keys {
		if allowed[key] {
			return true
		}
	}
	return false
}

func profileTenantAccessKeys(profile *domain.UserProfileResponse) map[string]bool {
	allowed := make(map[string]bool)
	if profile == nil {
		return allowed
	}
	if profile.TenantID != nil {
		if id := strings.ToLower(strings.TrimSpace(*profile.TenantID)); id != "" {
			allowed[id] = true
		}
	}
	for _, tenant := range profile.ManageableTenants {
		if id := strings.ToLower(strings.TrimSpace(tenant.ID)); id != "" {
			allowed[id] = true
		}
		if slug := strings.ToLower(strings.TrimSpace(tenant.Slug)); slug != "" {
			allowed[slug] = true
		}
	}
	for _, tenant := range profile.JoinedTenants {
		if id := strings.ToLower(strings.TrimSpace(tenant.ID)); id != "" {
			allowed[id] = true
		}
		if slug := strings.ToLower(strings.TrimSpace(tenant.Slug)); slug != "" {
			allowed[slug] = true
		}
	}
	return allowed
}

func profileCanAccessTenant(profile *domain.UserProfileResponse, tenantID, tenantSlug string) bool {
	allowed := profileTenantAccessKeys(profile)
	if id := strings.ToLower(strings.TrimSpace(tenantID)); id != "" && allowed[id] {
		return true
	}
	if slug := strings.ToLower(strings.TrimSpace(tenantSlug)); slug != "" && allowed[slug] {
		return true
	}
	return false
}

func userTenantID(user domain.User) string {
	if user.TenantID == nil {
		return ""
	}
	return strings.TrimSpace(*user.TenantID)
}

func userTenantSlug(user domain.User) string {
	if user.Tenant != nil {
		return strings.TrimSpace(user.Tenant.Slug)
	}
	return ""
}

type userSummary struct {
	ID              string          `json:"id"`
	Email           string          `json:"email"`
	LoginID         string          `json:"loginId,omitempty"`
	CustomLoginIDs  []string        `json:"customLoginIds,omitempty"` // [New] 다중 로그인 ID 목록
	Name            string          `json:"name"`
	Phone           string          `json:"phone"`
	Role            string          `json:"role"`
	Status          string          `json:"status"`
	TenantSlug      string          `json:"tenantSlug,omitempty"`
	CompanyCode     string          `json:"companyCode"`
	Metadata        domain.JSONMap  `json:"metadata,omitempty"`
	Tenant          *domain.Tenant  `json:"tenant,omitempty"`
	JoinedTenants   []domain.Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
	Department      string          `json:"department"`
	Grade           string          `json:"grade"`
	Position        string          `json:"position"`
	JobTitle        string          `json:"jobTitle"`
	CreatedAt       string          `json:"createdAt"`
	UpdatedAt       string          `json:"updatedAt"`
	InitialPassword string          `json:"initialPassword,omitempty"`
}

type userListResponse struct {
	Items      []userSummary `json:"items"`
	Limit      int           `json:"limit"`
	Offset     int           `json:"offset"`
	Total      int64         `json:"total"`
	Cursor     string        `json:"cursor,omitempty"`
	NextCursor string        `json:"nextCursor,omitempty"`
}

func kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string) {
	timestamp := identity.CreatedAt
	if timestamp.IsZero() {
		timestamp = time.Unix(0, 0).UTC()
	}
	return timestamp, identity.ID
}

func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
	// [New] Get requester profile from middleware
	var requesterRole string
	if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
		requesterRole = domain.NormalizeRole(profile.Role)
	}

	limit := c.QueryInt("limit", 50)
	offset := c.QueryInt("offset", 0)
	search := strings.TrimSpace(c.Query("search"))
	tenantSlug := strings.TrimSpace(c.Query("tenantSlug"))
	cursorRaw := strings.TrimSpace(c.Query("cursor"))

	if limit <= 0 {
		limit = 50
	}
	if offset < 0 {
		offset = 0
	}

	// [New] Manageable Tenants Map for efficient lookup
	manageableSlugs := make(map[string]bool)
	if requesterRole != domain.RoleSuperAdmin {
		profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
		if profile != nil {
			var baseTenantIDs []string
			for _, t := range profile.ManageableTenants {
				manageableSlugs[strings.ToLower(t.Slug)] = true
				manageableSlugs[strings.ToLower(t.ID)] = true
				baseTenantIDs = append(baseTenantIDs, t.ID)
			}
			for _, t := range profile.JoinedTenants {
				manageableSlugs[strings.ToLower(t.Slug)] = true
				manageableSlugs[strings.ToLower(t.ID)] = true
				baseTenantIDs = append(baseTenantIDs, t.ID)
			}
			if profile.TenantID != nil {
				manageableSlugs[strings.ToLower(*profile.TenantID)] = true
				baseTenantIDs = append(baseTenantIDs, *profile.TenantID)
			}

			// Expand manageableSlugs to the entire tenant tree (root + all descendants)
			if h.TenantService != nil && len(baseTenantIDs) > 0 {
				allTenants, _, err := h.TenantService.ListTenants(c.Context(), 10000, 0, "", "")
				if err == nil {
					parentMap := make(map[string]string)
					for _, t := range allTenants {
						if t.ParentID != nil {
							parentMap[t.ID] = *t.ParentID
						}
					}

					// Function to find the root of any given tenant
					findRoot := func(id string) string {
						curr := id
						for {
							p, exists := parentMap[curr]
							if !exists || p == "" {
								break
							}
							curr = p
						}
						return curr
					}

					// Collect root IDs for all base tenants
					roots := make(map[string]bool)
					for _, id := range baseTenantIDs {
						roots[findRoot(id)] = true
					}

					// If a tenant shares a root with any base tenant, it's in the same tree family
					for _, t := range allTenants {
						if roots[findRoot(t.ID)] {
							manageableSlugs[strings.ToLower(t.Slug)] = true
							manageableSlugs[strings.ToLower(t.ID)] = true
						}
					}
				}
			}
		}
	}

	var targetTenantID string
	if tenantSlug != "" && h.TenantService != nil {
		t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
		if err == nil && t != nil {
			targetTenantID = strings.ToLower(t.ID)
		}
	}

	if h.UserRepo != nil {
		var tenantIDs []string
		if tenantSlug != "" {
			if targetTenantID == "" {
				return c.JSON(userListResponse{
					Items:  []userSummary{},
					Limit:  limit,
					Offset: offset,
					Total:  0,
					Cursor: cursorRaw,
				})
			}
			if requesterRole != domain.RoleSuperAdmin && !manageableSlugs[targetTenantID] && !manageableSlugs[strings.ToLower(tenantSlug)] {
				return c.JSON(userListResponse{
					Items:  []userSummary{},
					Limit:  limit,
					Offset: offset,
					Total:  0,
					Cursor: cursorRaw,
				})
			}
			tenantIDs = append(tenantIDs, targetTenantID)
		} else if requesterRole != domain.RoleSuperAdmin {
			for key := range manageableSlugs {
				if _, err := uuid.Parse(key); err == nil {
					tenantIDs = append(tenantIDs, key)
				}
			}
			if len(tenantIDs) == 0 {
				return c.JSON(userListResponse{
					Items:  []userSummary{},
					Limit:  limit,
					Offset: offset,
					Total:  0,
					Cursor: cursorRaw,
				})
			}
		}

		users, total, nextCursor, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantIDs, cursorRaw)
		if err != nil {
			return errorJSON(c, fiber.StatusInternalServerError, "failed to list users")
		}
		items := make([]userSummary, 0, len(users))
		for _, user := range users {
			items = append(items, h.mapLocalUserSummary(c.Context(), user))
		}
		if cursorRaw != "" {
			offset = 0
		}
		return c.JSON(userListResponse{
			Items:      items,
			Limit:      limit,
			Offset:     offset,
			Total:      total,
			Cursor:     cursorRaw,
			NextCursor: nextCursor,
		})
	}

	if h.KratosAdmin == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
	}

	identities, err := h.KratosAdmin.ListIdentities(c.Context())
	if err == nil {
		filtered := make([]service.KratosIdentity, 0, len(identities))
		searchLower := strings.ToLower(search)

		for _, identity := range identities {
			email := strings.ToLower(extractTraitString(identity.Traits, "email"))
			name := strings.ToLower(extractTraitString(identity.Traits, "name"))
			tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))

			// Tenant Admin & Member filtering
			if requesterRole != domain.RoleSuperAdmin {
				hasAccess := manageableSlugs[tID]
				if !hasAccess {
					continue
				}
			}

			// Dedicated tenantSlug filter
			if tenantSlug != "" {
				matches := tID == targetTenantID
				if !matches {
					continue
				}
			}

			// Search filtering
			if search != "" {
				matchesSearch := strings.Contains(email, searchLower) ||
					strings.Contains(name, searchLower)

				if !matchesSearch {
					continue
				}
			}
			filtered = append(filtered, identity)
		}

		pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
		total := int64(len(filtered))
		nextCursor := ""
		var pageIdentities []service.KratosIdentity
		if cursorRaw != "" {
			pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
			if err != nil {
				return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
			}
			offset = 0
		} else {
			if offset > len(filtered) {
				offset = len(filtered)
			}
			end := min(offset+limit, len(filtered))
			pageIdentities = filtered[offset:end]
			if total > int64(end) && len(pageIdentities) > 0 {
				lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
				nextCursor = pagination.Encode(lastTimestamp, lastID)
			}
		}

		items := make([]userSummary, 0, len(pageIdentities))
		for _, identity := range pageIdentities {
			summary := h.mapIdentitySummary(c.Context(), identity)
			items = append(items, summary)
		}

		// [Lazy Sync] Asynchronously update local DB with fresh data from Kratos
		// This ensures that member counts (which use local DB) eventually match reality
		if h.UserRepo != nil {
			go func(ids []service.KratosIdentity) {
				ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
				defer cancel()
				for _, identity := range ids {
					localUser := h.mapToLocalUser(identity)
					_ = h.UserRepo.Update(ctx, localUser)
				}
			}(filtered)
		}

		return c.JSON(userListResponse{
			Items:      items,
			Limit:      limit,
			Offset:     offset,
			Total:      total,
			Cursor:     cursorRaw,
			NextCursor: nextCursor,
		})
	}

	slog.Warn("Kratos unavailable for user list", "error", err)
	return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider unavailable")
}

func (h *UserHandler) GetUser(c *fiber.Ctx) error {
	if h.KratosAdmin == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
	}

	userID := strings.TrimSpace(c.Params("id"))
	if userID == "" {
		return errorJSON(c, fiber.StatusBadRequest, "user id is required")
	}

	identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
	if err != nil || identity == nil {
		// [FIX] Support fixed UUID lookup fallback
		id, searchErr := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userID)
		if searchErr == nil && id != "" {
			identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
		}

		if err != nil || identity == nil {
			// Second Fallback: By Email from local DB
			if h.UserRepo != nil {
				local, _ := h.UserRepo.FindByID(c.Context(), userID)
				if local != nil && local.Email != "" {
					id, _ = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), local.Email)
					if id != "" {
						identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
					}
				}
			}
		}

		if err != nil || identity == nil {
			if identity == nil {
				return errorJSON(c, fiber.StatusNotFound, "user not found")
			}
			return errorJSON(c, fiber.StatusInternalServerError, err.Error())
		}
	}

	// [New] Check access scope
	requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
	if requester != nil && requester.Role != domain.RoleSuperAdmin {
		allowedKeys := profileTenantAccessKeys(requester)
		if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowedKeys) {
			return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied")
		}
	}

	return c.JSON(h.mapIdentitySummary(c.Context(), *identity))
}

func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
	if h.OryProvider == nil || h.KratosAdmin == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
	}

	var req struct {
		Email                  string           `json:"email"`
		LoginID                string           `json:"loginId"`
		Password               string           `json:"password"`
		Name                   string           `json:"name"`
		Phone                  string           `json:"phone"`
		Role                   string           `json:"role"`
		TenantSlug             string           `json:"tenantSlug"`
		CompanyCode            string           `json:"companyCode"`
		Department             string           `json:"department"`
		Grade                  string           `json:"grade"`
		Position               string           `json:"position"`
		JobTitle               string           `json:"jobTitle"`
		PrimaryTenantID        string           `json:"primaryTenantId"`
		PrimaryTenantName      string           `json:"primaryTenantName"`
		PrimaryTenantIsOwner   *bool            `json:"primaryTenantIsOwner"`
		AdditionalAppointments []map[string]any `json:"additionalAppointments"`
		Metadata               map[string]any   `json:"metadata"`
	}
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
	}
	tenantSlug, err := tenantSlugFromRequest(req.TenantSlug, req.CompanyCode)
	if err != nil {
		return errorJSON(c, fiber.StatusBadRequest, err.Error())
	}
	req.CompanyCode = tenantSlug
	req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
	representativeCleared := false
	if h.TenantService != nil {
		cleared, err := sanitizeUserRepresentativeTenants(c.Context(), h.TenantService, req.Metadata, req.AdditionalAppointments)
		if err != nil {
			return errorJSON(c, fiber.StatusBadRequest, err.Error())
		}
		representativeCleared = cleared
		if cleared {
			req.PrimaryTenantID = ""
			req.PrimaryTenantName = ""
			req.PrimaryTenantIsOwner = nil
			req.CompanyCode = ""
		}
	}

	email := strings.TrimSpace(req.Email)
	if email == "" {
		return errorJSON(c, fiber.StatusBadRequest, "email is required")
	}
	if !strings.Contains(email, "@") || !strings.Contains(email, ".") {
		return errorJSON(c, fiber.StatusBadRequest, "invalid email format")
	}
	name := strings.TrimSpace(req.Name)
	if name == "" {
		return errorJSON(c, fiber.StatusBadRequest, "name is required")
	}

	password := strings.TrimSpace(req.Password)
	policy, err := h.OryProvider.GetPasswordPolicy()
	if err != nil || policy == nil {
		policy = &domain.PasswordPolicy{
			MinLength:         12,
			Lowercase:         true,
			Uppercase:         false,
			Number:            true,
			NonAlphanumeric:   true,
			MinCharacterTypes: 0,
		}
	}

	generatedPassword := ""
	if password == "" {
		generated, genErr := utils.GeneratePasswordWithPolicy(policy)
		if genErr != nil {
			return errorJSON(c, fiber.StatusInternalServerError, "failed to generate password")
		}
		password = generated
		generatedPassword = generated
	} else {
		if err := utils.ValidatePasswordWithPolicy(policy, password); err != nil {
			return errorJSON(c, fiber.StatusBadRequest, err.Error())
		}
	}

	role := domain.RoleUser
	if strings.TrimSpace(req.Role) != "" {
		normalizedRole, ok := normalizeAssignableSystemRole(req.Role)
		if !ok {
			return errorJSON(c, fiber.StatusBadRequest, "invalid role")
		}
		if normalizedRole == domain.RoleSuperAdmin {
			requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
			if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
				return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can assign super admin role")
			}
		}
		role = normalizedRole
	}

	attributes := map[string]any{
		"department":      req.Department,
		"grade":           strings.TrimSpace(req.Grade),
		"position":        req.Position,
		"jobTitle":        req.JobTitle,
		"affiliationType": "internal",
	}

	// [Override with explicit LoginID if provided]
	if req.LoginID != "" {
		if ids, ok := attributes["custom_login_ids"].([]string); ok {
			attributes["custom_login_ids"] = append(ids, req.LoginID)
		} else {
			attributes["custom_login_ids"] = []string{req.LoginID}
		}
	}

	// [Resolve TenantID and Custom Login IDs before Kratos creation]
	var tenantID string
	primaryAppointments := req.AdditionalAppointments
	if representativeCleared {
		primaryAppointments = nil
	}
	requestedPrimaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, primaryAppointments)
	if req.CompanyCode == "" && h.TenantService != nil {
		if requestedPrimaryTenantID != "" {
			if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil {
				tenantID = tenant.ID
				req.CompanyCode = tenant.Slug
			}
		}
	}
	if req.CompanyCode != "" && h.TenantService != nil {
		if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
			tenantID = tenant.ID
		}
	}
	if tenantID == "" {
		if req.CompanyCode != "" || requestedPrimaryTenantID != "" {
			return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
		}
		tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email)
		if err != nil {
			return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
		}
		tenantID = tenant.ID
		req.CompanyCode = tenant.Slug
	}

	attributes["role"] = role
	if tenantID != "" {
		attributes["tenant_id"] = tenantID
	}

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

	if h.UserRepo != nil {
		if err := h.ensureHanmacCreateEmailAllowed(c.Context(), email, req.CompanyCode, tenantID); err != nil {
			if strings.Contains(err.Error(), "한맥가족") {
				return errorJSON(c, fiber.StatusConflict, err.Error())
			}
			return errorJSON(c, fiber.StatusBadRequest, err.Error())
		}
	}

	// Merge custom metadata into attributes
	for k, v := range req.Metadata {
		// Don't overwrite core fields
		if _, exists := attributes[k]; !exists {
			attributes[k] = v
		}
	}

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

	brokerUser := &domain.BrokerUser{
		Email:       email,
		Name:        name,
		PhoneNumber: normalizePhoneNumber(req.Phone),
		Attributes:  attributes,
	}

	// [Validation] Based on Tenant Schema
	if tenantID != "" && h.TenantService != nil {
		tenant, err := h.TenantService.GetTenant(c.Context(), tenantID)
		if err == nil && tenant != nil {
			if schema, ok := tenant.Config["userSchema"].([]any); ok {
				if err := h.validateMetadata(req.Metadata, schema, true); err != nil {
					return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
				}
			}
		}
	}

	identityID, err := h.OryProvider.CreateUser(brokerUser, password)
	if err != nil {
		if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") {
			return errorJSON(c, fiber.StatusConflict, "이미 사용 중인 식별자(이메일/전화번호/사번 등)입니다.")
		}
		return errorJSON(c, fiber.StatusInternalServerError, err.Error())
	}

	// Fetch the newly created identity to ensure we have all traits
	identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
	if err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, err.Error())
	}
	if identity == nil {
		return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
	}

	// [New] Local DB Sync - Ensure user exists in read-model
	if h.UserRepo != nil {
		localUser := h.mapToLocalUser(*identity)

		// Sync to local DB (Synchronous for immediate consistency)
		if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
			slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err)
			markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
		}
		if h.Worksmobile != nil {
			if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
				slog.Warn("[UserHandler] Failed to enqueue Worksmobile user sync", "userID", localUser.ID, "error", err)
			}
		}

		// Update User Login IDs in local DB
		for i := range loginIDRecords {
			loginIDRecords[i].UserID = localUser.ID
		}
		if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
			slog.Error("[UserHandler] Failed to update user login IDs", "userID", localUser.ID, "error", err)
			markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
		}

		// [Keto] Sync relations via Outbox (Synchronous for accurate counting)
		if h.KetoOutboxRepo != nil {
			// 1. Role based relations
			h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID)

			// 2. Direct membership to the Tenant (for accurate counting)
			if localUser.TenantID != nil && *localUser.TenantID != "" {
				_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
					Namespace: "Tenant",
					Object:    *localUser.TenantID,
					Relation:  "members",
					Subject:   "User:" + localUser.ID,
					Action:    domain.KetoOutboxActionCreate,
				})
			}
		}
	}

	response := h.mapIdentitySummary(c.Context(), *identity)
	if generatedPassword != "" {
		response.InitialPassword = generatedPassword
	}
	return c.Status(fiber.StatusCreated).JSON(response)
}

type bulkUserItem struct {
	UserID                 string           `json:"userId"`
	ID                     string           `json:"id"`
	UUID                   string           `json:"uuid"`
	Email                  string           `json:"email"`
	LoginID                string           `json:"loginId"`
	Name                   string           `json:"name"`
	Phone                  string           `json:"phone"`
	Role                   string           `json:"role"`
	TenantID               string           `json:"tenantId"`
	TenantSlug             string           `json:"tenantSlug"`
	CompanyCode            string           `json:"companyCode"`
	EmailDomain            string           `json:"emailDomain"`
	Department             string           `json:"department"`
	Grade                  string           `json:"grade"`
	Position               string           `json:"position"`
	JobTitle               string           `json:"jobTitle"`
	AdditionalAppointments []map[string]any `json:"additionalAppointments"`
	Metadata               map[string]any   `json:"metadata"`
}

type bulkUserResult struct {
	Email          string   `json:"email"`
	OriginalEmail  string   `json:"originalEmail,omitempty"`
	SuggestedEmail string   `json:"suggestedEmail,omitempty"`
	Status         string   `json:"status,omitempty"`
	Warnings       []string `json:"warnings,omitempty"`
	Success        bool     `json:"success"`
	Message        string   `json:"message,omitempty"`
	UserID         string   `json:"userId,omitempty"`
	ModifiedFields []string `json:"modifiedFields,omitempty"`
}

func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
	if h.OryProvider == nil || h.KratosAdmin == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
	}

	var req struct {
		Users []bulkUserItem `json:"users"`
	}
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
	}

	if len(req.Users) == 0 {
		return errorJSON(c, fiber.StatusBadRequest, "no users provided")
	}

	policy, err := h.OryProvider.GetPasswordPolicy()
	if err != nil || policy == nil {
		policy = &domain.PasswordPolicy{
			MinLength: 12, Number: true, NonAlphanumeric: true,
		}
	}

	requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
	results := make([]bulkUserResult, 0, len(req.Users))
	var hanmacScope *hanmacEmailScope
	var hanmacLocalParts map[string]bool
	hanmacScopeLoaded := false
	bulkEmailErrors := validateBulkUserEmailUniqueness(req.Users)

	// Pre-fetch tenant data to avoid redundant DB calls
	type tenantCacheItem struct {
		ID           string
		Slug         string
		Name         string
		ParentID     *string
		Schema       []any
		Groups       []domain.UserGroup
		LoginIDField string
	}
	tenantCache := make(map[string]tenantCacheItem)
	tenantCacheByID := make(map[string]tenantCacheItem)
	tenantCacheByDomain := make(map[string]tenantCacheItem)

	buildTenantCacheItem := func(tenant *domain.Tenant) tenantCacheItem {
		tItem := tenantCacheItem{
			ID:       tenant.ID,
			Slug:     tenant.Slug,
			Name:     tenant.Name,
			ParentID: tenant.ParentID,
		}
		if s, ok := tenant.Config["userSchema"].([]any); ok {
			tItem.Schema = s
		}
		if lf, ok := tenant.Config["loginIdField"].(string); ok {
			tItem.LoginIDField = lf
		}
		if h.UserGroupRepo != nil {
			if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil {
				tItem.Groups = groups
			}
		}
		return tItem
	}

	cacheTenantItem := func(tItem tenantCacheItem) tenantCacheItem {
		if tItem.Slug != "" {
			tenantCache[strings.ToLower(strings.TrimSpace(tItem.Slug))] = tItem
		}
		if tItem.ID != "" {
			tenantCacheByID[tItem.ID] = tItem
		}
		return tItem
	}

	resolveTenantBySlug := func(slug string) (tenantCacheItem, error) {
		normalizedSlug := strings.ToLower(strings.TrimSpace(slug))
		if normalizedSlug == "" {
			return tenantCacheItem{}, errors.New("tenantSlug is required")
		}
		if tItem, exists := tenantCache[normalizedSlug]; exists {
			return tItem, nil
		}
		if h.TenantService == nil {
			return tenantCacheItem{}, errors.New("tenant service unavailable")
		}
		tenant, err := h.TenantService.GetTenantBySlug(c.Context(), normalizedSlug)
		if err != nil || tenant == nil {
			return tenantCacheItem{}, errors.New("invalid tenantSlug: tenant not found")
		}
		return cacheTenantItem(buildTenantCacheItem(tenant)), nil
	}

	resolveTenantByID := func(tenantID string) (tenantCacheItem, error) {
		normalizedID := strings.TrimSpace(tenantID)
		if normalizedID == "" {
			return tenantCacheItem{}, errors.New("tenantId is required")
		}
		if tItem, exists := tenantCacheByID[normalizedID]; exists {
			return tItem, nil
		}
		if h.TenantService == nil {
			return tenantCacheItem{}, errors.New("tenant service unavailable")
		}
		tenant, err := h.TenantService.GetTenant(c.Context(), normalizedID)
		if err != nil || tenant == nil {
			return tenantCacheItem{}, errors.New("invalid tenantId: tenant not found")
		}
		return cacheTenantItem(buildTenantCacheItem(tenant)), nil
	}

	resolveTenantByDomain := func(domainName string) (tenantCacheItem, bool) {
		normalizedDomain := strings.ToLower(strings.TrimSpace(domainName))
		if normalizedDomain == "" || h.TenantService == nil {
			return tenantCacheItem{}, false
		}
		if tItem, exists := tenantCacheByDomain[normalizedDomain]; exists {
			return tItem, true
		}
		tenant, err := h.TenantService.GetTenantByDomain(c.Context(), normalizedDomain)
		if err != nil || tenant == nil {
			return tenantCacheItem{}, false
		}
		tItem := cacheTenantItem(buildTenantCacheItem(tenant))
		tenantCacheByDomain[normalizedDomain] = tItem
		return tItem, true
	}

	createPersonalTenantItem := func(email string) (tenantCacheItem, error) {
		tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email)
		if err != nil {
			return tenantCacheItem{}, err
		}
		return cacheTenantItem(buildTenantCacheItem(tenant)), nil
	}

	for index, item := range req.Users {
		var identityID string
		email := strings.TrimSpace(item.Email)
		name := strings.TrimSpace(item.Name)
		tenantID := strings.TrimSpace(item.TenantID)
		tenantSlug, tenantSlugErr := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
		dept := strings.TrimSpace(item.Department)

		if email == "" || name == "" {
			results = append(results, bulkUserResult{Email: email, Success: false, Message: "email and name are required"})
			continue
		}
		if strings.TrimSpace(item.ID) != "" || strings.TrimSpace(item.UUID) != "" || strings.TrimSpace(item.UserID) != "" {
			results = append(results, bulkUserResult{
				Email:   email,
				Success: false,
				Status:  "blockingError",
				Message: "사용자 UUID 가져오기는 지원하지 않습니다. UUID 보존 복구는 백업/복구 기능을 사용하세요.",
			})
			continue
		}
		if tenantSlugErr != nil {
			results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
			continue
		}
		if message, exists := bulkEmailErrors[index]; exists {
			results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: message})
			continue
		}

		var tItem tenantCacheItem
		var err error
		if tenantID != "" {
			tItem, err = resolveTenantByID(tenantID)
			if err != nil {
				results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
				continue
			}
			if tenantSlug != "" && !strings.EqualFold(tenantSlug, tItem.Slug) {
				results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantId and tenantSlug do not match"})
				continue
			}
			tenantSlug = tItem.Slug
		} else if tenantSlug != "" {
			tItem, err = resolveTenantBySlug(tenantSlug)
			if err != nil {
				results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
				continue
			}
			tenantSlug = tItem.Slug
		} else {
			for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, email) {
				if domainTenant, ok := resolveTenantByDomain(domainName); ok {
					tItem = domainTenant
					tenantSlug = domainTenant.Slug
					break
				}
			}
			if tenantSlug == "" {
				tItem, err = createPersonalTenantItem(email)
				if err != nil {
					results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to create personal tenant"})
					continue
				}
				tenantSlug = tItem.Slug
			}
		}

		// Role-based access check
		if requester != nil && requester.Role != domain.RoleSuperAdmin {
			if !profileCanAccessTenant(requester, tItem.ID, tenantSlug) {
				results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
				continue
			}
		}

		resolvedAppointments := make([]any, 0, len(item.AdditionalAppointments)+2)
		if len(item.AdditionalAppointments) > 0 {
			appointmentFailed := false
			for _, rawAppointment := range item.AdditionalAppointments {
				appointmentTenantSlug := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantSlug"]))
				if appointmentTenantSlug == "" {
					continue
				}

				appointmentTenant, exists := tenantCache[strings.ToLower(appointmentTenantSlug)]
				if !exists {
					appointmentTenant, err = resolveTenantBySlug(appointmentTenantSlug)
					if err != nil {
						results = append(results, bulkUserResult{Email: email, Success: false, Message: strings.Replace(err.Error(), "tenantSlug", "additional tenantSlug", 1)})
						appointmentFailed = true
						break
					}
				}
				if requester != nil && requester.Role == "tenant_admin" && !profileCanAccessTenant(requester, appointmentTenant.ID, appointmentTenant.Slug) {
					results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
					appointmentFailed = true
					break
				}

				appointment := make(map[string]any, len(rawAppointment)+3)
				for key, value := range rawAppointment {
					if key == "tenantSlug" || key == "tenantId" || key == "tenantName" {
						continue
					}
					appointment[key] = value
				}
				appointment["tenantId"] = appointmentTenant.ID
				appointment["tenantSlug"] = appointmentTenant.Slug
				if name := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantName"])); name != "" {
					appointment["tenantName"] = name
				} else {
					appointment["tenantName"] = appointmentTenant.Name
				}
				resolvedAppointments = append(resolvedAppointments, appointment)
			}
			if appointmentFailed {
				continue
			}
		}

		// Validation based on schema
		if tItem.Schema != nil {
			if err := h.validateMetadata(item.Metadata, tItem.Schema, true); err != nil {
				results = append(results, bulkUserResult{Email: email, Success: false, Message: "validation failed: " + err.Error()})
				continue
			}
		}

		if h.UserRepo != nil && !hanmacScopeLoaded {
			hanmacScopeLoaded = true
			var err error
			hanmacScope, err = h.resolveHanmacEmailScope(c.Context())
			if err != nil {
				results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to resolve Hanmac family tenant scope"})
				continue
			}
			if hanmacScope != nil {
				hanmacLocalParts, err = h.loadHanmacLocalParts(c.Context(), hanmacScope)
				if err != nil {
					results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to validate Hanmac family email policy"})
					continue
				}
			}
		}

		userEmail := email
		var emailEvaluation hanmacEmailEvaluation
		if h.UserRepo != nil && hanmacScope != nil && hanmacScope.ContainsTenant(tItem.ID, tenantSlug) {
			emailEvaluation = h.evaluateHanmacImportEmail(c.Context(), item, hanmacScope, hanmacLocalParts)
			if emailEvaluation.Blocking {
				results = append(results, bulkUserResult{
					Email:         emailEvaluation.Email,
					OriginalEmail: emailEvaluation.OriginalEmail,
					Status:        emailEvaluation.Status,
					Warnings:      emailEvaluation.Warnings,
					Success:       false,
					Message:       emailEvaluation.Message,
				})
				continue
			}
			userEmail = emailEvaluation.Email
			if emailEvaluation.LocalPart != "" {
				hanmacLocalParts[emailEvaluation.LocalPart] = true
			}
		} else {
			if _, _, err := domain.SplitEmailDomain(email); err != nil {
				results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: "invalid email format"})
				continue
			}
			if localPart, err := domain.ExtractNormalizedEmailLocalPart(email); err != nil || localPart == "" {
				results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: "invalid email format"})
				continue
			}
		}

		for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, userEmail) {
			domainTenant, ok := resolveTenantByDomain(domainName)
			if !ok || bulkUserAssignmentContainsTenant(resolvedAppointments, tItem.ID, domainTenant.ID) {
				continue
			}
			resolvedAppointments = append(resolvedAppointments, map[string]any{
				"tenantId":         domainTenant.ID,
				"tenantSlug":       domainTenant.Slug,
				"tenantName":       domainTenant.Name,
				"assignmentSource": "email_domain",
				"sourceDomain":     strings.ToLower(strings.TrimSpace(domainName)),
			})
		}
		if len(resolvedAppointments) > 0 {
			if item.Metadata == nil {
				item.Metadata = map[string]any{}
			}
			item.Metadata["additionalAppointments"] = resolvedAppointments
		}
		normalizeBulkUserAliasMetadata(item.Metadata)
		item.Metadata = sanitizeUserMetadata(item.Metadata)

		password, _ := utils.GeneratePasswordWithPolicy(policy)
		role := item.Role
		if role == "" {
			role = "user"
		}

		attributes := map[string]any{
			"department":      dept,
			"grade":           strings.TrimSpace(item.Grade),
			"position":        strings.TrimSpace(item.Position),
			"jobTitle":        strings.TrimSpace(item.JobTitle),
			"affiliationType": "internal",
			"tenant_id":       tItem.ID,
			"role":            role,
		}

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

		// Merge metadata
		for k, v := range item.Metadata {
			if _, exists := attributes[k]; !exists {
				attributes[k] = v
			}
		}

		userPhone := normalizePhoneNumber(item.Phone)

		// Validate all collected LoginIDs
		if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
			valid := true
			// Collect all emails
			allEmails := []string{userEmail}
			if secondaryRaw, exists := item.Metadata["sub_email"]; exists {
				if secondaryEmails, ok := secondaryRaw.([]any); ok {
					for _, se := range secondaryEmails {
						if seStr, ok := se.(string); ok {
							allEmails = append(allEmails, seStr)
						}
					}
				} else if secondaryEmails, ok := secondaryRaw.([]string); ok {
					allEmails = append(allEmails, secondaryEmails...)
				}
			}

			for _, lid := range collectedIDs {
				if err := domain.ValidateLoginID(lid, allEmails, userPhone); err != nil {
					results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: "Invalid LoginID (" + lid + "): " + err.Error()})
					valid = false
					break
				}
			}
			if !valid {
				continue
			}
		}

		resultStatus := "created"
		if identityID == "" {
			var err error
			identityID, err = h.OryProvider.CreateUser(&domain.BrokerUser{
				Email:       userEmail,
				Name:        item.Name,
				PhoneNumber: userPhone,
				Attributes:  attributes,
			}, password)
			if err != nil {
				// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
				if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") {
					identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userEmail)
					if err != nil || identityID == "" {
						results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: "blockingError", Warnings: emailEvaluation.Warnings, Success: false, Message: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."})
						continue
					}
					resultStatus = "updated"
					slog.Info("BulkCreate: User already exists by identifier, reusing identity", "email", userEmail, "identityID", identityID)
				} else {
					results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: err.Error()})
					continue
				}
			} else {
				resultStatus = "created"
				slog.Info("BulkCreate: New identity created", "email", userEmail, "identityID", identityID)
			}
		}

		var modifiedFields []string
		isRestoration := false
		if resultStatus == "updated" && identityID != "" {
			existing, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
			if err == nil && existing != nil {
				// 1. Check for Restoration (Soft-deleted in local DB)
				if h.UserRepo != nil {
					var existingLocal domain.User
					err := h.UserRepo.DB().Unscoped().Where("id = ?", identityID).First(&existingLocal).Error
					if err == nil {
						// Check if it was soft-deleted
						if existingLocal.DeletedAt.Valid {
							isRestoration = true
							modifiedFields = append(modifiedFields, "Status")
						}
					}
				}

				// 2. Compare Traits
				if name != "" && !strings.EqualFold(extractTraitString(existing.Traits, "name"), name) {
					modifiedFields = append(modifiedFields, "Name")
				}
				if item.Phone != "" && normalizePhoneNumber(extractTraitString(existing.Traits, "phone_number")) != normalizePhoneNumber(item.Phone) {
					modifiedFields = append(modifiedFields, "Phone")
				}
				if dept != "" && !strings.EqualFold(extractTraitString(existing.Traits, "department"), dept) {
					modifiedFields = append(modifiedFields, "Department")
				}
				if item.Grade != "" && !strings.EqualFold(gradeFromTraits(existing.Traits), strings.TrimSpace(item.Grade)) {
					modifiedFields = append(modifiedFields, "Grade")
				}
				if item.Position != "" && !strings.EqualFold(extractTraitString(existing.Traits, "position"), strings.TrimSpace(item.Position)) {
					modifiedFields = append(modifiedFields, "Position")
				}
				if item.JobTitle != "" && !strings.EqualFold(extractTraitString(existing.Traits, "jobTitle"), strings.TrimSpace(item.JobTitle)) {
					modifiedFields = append(modifiedFields, "JobTitle")
				}
				if tItem.ID != "" && extractTraitString(existing.Traits, "tenant_id") != tItem.ID {
					modifiedFields = append(modifiedFields, "Tenant")
				}
				if role != "" && !strings.EqualFold(roleFromTraits(existing.Traits), role) {
					modifiedFields = append(modifiedFields, "Role")
				}

				// 3. Finalize Status: If no fields actually changed, it's "unchanged"
				if len(modifiedFields) == 0 && !isRestoration {
					resultStatus = "unchanged"
				}
			}
		}

		targetLocalID := identityID

		if h.UserRepo != nil {
			localUser := &domain.User{
				ID:              targetLocalID,
				Email:           userEmail,
				Name:            name,
				Phone:           normalizePhoneNumber(item.Phone),
				Role:            role,
				Status:          "active",
				Department:      dept,
				Grade:           strings.TrimSpace(item.Grade),
				AffiliationType: "internal",
				CreatedAt:       time.Now(),
				UpdatedAt:       time.Now(),
			}

			if tItem.ID != "" {
				localUser.TenantID = &tItem.ID
			}

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

			if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
				slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
				markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
			}
			if h.Worksmobile != nil {
				if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
					slog.Warn("Failed to enqueue Worksmobile bulk user sync", "userID", localUser.ID, "error", err)
				}
			}

			// Update User Login IDs in local DB
			for i := range loginIDRecords {
				loginIDRecords[i].UserID = localUser.ID
			}

			// [FIX] Pre-check and cleanup colliding identifiers from OTHER users
			for _, lid := range loginIDRecords {
				var existingID domain.UserLoginID
				// Search in DB (including soft-deleted) for any record with the same login_id but DIFFERENT user_id
				if err := h.UserRepo.DB().Unscoped().Where("login_id = ? AND user_id != ?", lid.LoginID, localUser.ID).First(&existingID).Error; err == nil {
					slog.Info("BulkCreate: Cleaning up colliding identifier from another user", "loginID", lid.LoginID, "oldUserID", existingID.UserID)
					_ = h.UserRepo.DB().Unscoped().Where("login_id = ?", lid.LoginID).Delete(&domain.UserLoginID{}).Error
				}
			}

			if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
				slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err)
				markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
				results = append(results, bulkUserResult{
					Email:          userEmail,
					OriginalEmail:  emailEvaluation.OriginalEmail,
					SuggestedEmail: emailEvaluation.SuggestedEmail,
					Status:         "blockingError",
					Warnings:       emailEvaluation.Warnings,
					Success:        false,
					Message:        "LoginID sync failed: " + err.Error(),
				})
				continue
			}

			if h.KetoOutboxRepo != nil {
				// 1. Sync Role based relationship
				h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID)

				// 2. Sync direct membership to the Tenant (for count)
				if localUser.TenantID != nil && *localUser.TenantID != "" {
					_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
						Namespace: "Tenant",
						Object:    *localUser.TenantID,
						Relation:  "members",
						Subject:   "User:" + localUser.ID,
						Action:    domain.KetoOutboxActionCreate,
					})
				}

				// 3. Sync membership to UserGroup if department matches
				if dept != "" {
					for _, g := range tItem.Groups {
						if strings.EqualFold(strings.TrimSpace(g.Name), dept) {
							_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
								Namespace: "Tenant",
								Object:    g.ID,
								Relation:  "members",
								Subject:   "User:" + localUser.ID,
								Action:    domain.KetoOutboxActionCreate,
							})
							break
						}
					}
				}
			}
		}

		results = append(results, bulkUserResult{
			Email:          userEmail,
			OriginalEmail:  emailEvaluation.OriginalEmail,
			SuggestedEmail: emailEvaluation.SuggestedEmail,
			Status:         resultStatus,
			Warnings:       emailEvaluation.Warnings,
			Success:        true,
			UserID:         targetLocalID,
			ModifiedFields: modifiedFields,
		})
	}

	return c.Status(fiber.StatusOK).JSON(fiber.Map{
		"results": results,
	})
}

func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
	search := strings.TrimSpace(c.Query("search"))
	tenantSlug, err := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode"))
	if err != nil {
		return errorJSON(c, fiber.StatusBadRequest, err.Error())
	}

	var requesterRole string
	var profile *domain.UserProfileResponse

	// [New] Manual profile resolution to support query-param role mocking
	// This is needed because browsers cannot send custom headers for direct downloads
	mockRole := c.Query("x-test-role")
	appEnv := strings.ToLower(os.Getenv("APP_ENV"))
	isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""

	if isDev && mockRole != "" {
		slog.Info("🔑 [AUTH] Using mock role from query for export", "role", mockRole)
		requesterRole = mockRole
		// In dev mocking, we might not have a full profile, but we need to know the manageable tenants if it's a tenant_admin
		if requesterRole == "tenant_admin" {
			// Try to get actual profile if possible to get manageableTenants
			p, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
			if p != nil {
				profile = p
			}
		}
	} else {
		// Use real profile from middleware
		p, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
		if !ok || p == nil {
			return errorJSON(c, fiber.StatusUnauthorized, "invalid session (trace:export_auth)")
		}
		profile = p
		requesterRole = profile.Role
	}

	// [New] Access Control: only super_admin can export
	if requesterRole != domain.RoleSuperAdmin {
		return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for export")
	}

	// 1. Fetch Users using Repo for efficiency
	var exportTenantIDs []string
	if tenantSlug != "" {
		if h.TenantService == nil {
			return errorJSON(c, fiber.StatusServiceUnavailable, "tenant service unavailable for scoped export")
		}
		t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
		if err != nil {
			return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve tenant for export")
		}
		if t == nil || strings.TrimSpace(t.ID) == "" {
			return errorJSON(c, fiber.StatusNotFound, "tenant not found for export")
		}
		exportTenantIDs = []string{t.ID}
	}
	users, _, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, exportTenantIDs, "")
	if err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
	}

	// 2. Data rows
	filtered := users

	// 3. Set CSV Headers
	c.Set("Content-Type", "text/csv; charset=utf-8")
	c.Set("Content-Disposition", "attachment; filename=users_export_"+time.Now().Format("20060102")+".csv")

	// [New] Write UTF-8 BOM for Excel compatibility
	_, _ = c.Write([]byte{0xEF, 0xBB, 0xBF})

	writer := csv.NewWriter(c)
	defer writer.Flush()

	// Header row
	includeIDs := includeCSVIds(c)
	header := []string{"Email", "Name", "Phone", "Status", "tenant_slug", "Grade", "Position", "JobTitle", "CreatedAt"}
	if includeIDs {
		header = []string{"user_id", "Email", "Name", "Phone", "Status", "tenant_id", "tenant_slug", "Grade", "Position", "JobTitle", "CreatedAt"}
	}

	// Collect all possible metadata keys for dynamic columns
	metaKeysMap := make(map[string]bool)
	for _, u := range filtered {
		for k := range u.Metadata {
			if !includeIDs && csvMetadataKeyIsID(k) {
				continue
			}
			metaKeysMap[k] = true
		}
	}
	var metaKeys []string
	for k := range metaKeysMap {
		metaKeys = append(metaKeys, k)
		header = append(header, "Meta:"+k)
	}

	if err := writer.Write(header); err != nil {
		return err
	}

	// Data rows
	for _, u := range filtered {
		tenantID := ""
		if u.TenantID != nil {
			tenantID = *u.TenantID
		}
		row := []string{
			u.Email,
			u.Name,
			u.Phone,
			u.Status,
			userTenantSlug(u),
			u.Grade,
			u.Position,
			u.JobTitle,
			u.CreatedAt.Format(time.RFC3339),
		}
		if includeIDs {
			row = []string{
				u.ID,
				u.Email,
				u.Name,
				u.Phone,
				u.Status,
				tenantID,
				userTenantSlug(u),
				u.Grade,
				u.Position,
				u.JobTitle,
				u.CreatedAt.Format(time.RFC3339),
			}
		}
		// Append metadata values in order
		for _, k := range metaKeys {
			val := ""
			if v, ok := u.Metadata[k]; ok {
				val = fmt.Sprintf("%v", v)
			}
			row = append(row, val)
		}
		if err := writer.Write(row); err != nil {
			return err
		}
	}

	return nil
}

func csvMetadataKeyIsID(key string) bool {
	normalized := strings.ToLower(strings.TrimSpace(key))
	return normalized == "id" || normalized == "user_id" || normalized == "tenant_id" || normalized == "tenantid"
}

func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
	var req struct {
		UserIDs     []string `json:"userIds"`
		Status      *string  `json:"status"`
		Role        *string  `json:"role"`
		TenantSlug  *string  `json:"tenantSlug"`
		CompanyCode *string  `json:"companyCode"`
		IsAddTenant bool     `json:"isAddTenant"`
		Department  *string  `json:"department"`
		Grade       *string  `json:"grade"`
		Position    *string  `json:"position"`
		JobTitle    *string  `json:"jobTitle"`
	}
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
	}
	tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
	if err != nil {
		return errorJSON(c, fiber.StatusBadRequest, err.Error())
	}
	req.CompanyCode = tenantSlug

	if len(req.UserIDs) == 0 {
		return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
	}

	requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
	if requester == nil {
		return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
	}
	if req.Role != nil {
		if domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
			return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
		}
		role, ok := normalizeAssignableSystemRole(*req.Role)
		if !ok {
			return errorJSON(c, fiber.StatusBadRequest, "invalid role")
		}
		*req.Role = role
	}

	// Pre-fetch tenant cache if tenantSlug is being changed.
	type tenantCacheItem struct {
		ID     string
		Schema []any
	}
	tenantCache := make(map[string]tenantCacheItem)

	manageableSlugs := make(map[string]bool)
	if requester.Role != domain.RoleSuperAdmin {
		manageableSlugs = profileTenantAccessKeys(requester)
	}

	results := make([]map[string]any, 0, len(req.UserIDs))

	for _, id := range req.UserIDs {
		// [Safety] Cannot delete yourself
		if id == requester.ID {
			results = append(results, map[string]any{
				"id":      id,
				"success": false,
				"message": "cannot delete your own account for safety",
			})
			continue
		}

		identity, err := h.KratosAdmin.GetIdentity(c.Context(), id)
		if err != nil {
			results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"})
			continue
		}

		// Authorization check
		if requester.Role != domain.RoleSuperAdmin {
			if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
				results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
				continue
			}
			if req.CompanyCode != nil {
				targetAllowed := manageableSlugs[strings.ToLower(*req.CompanyCode)]
				if !targetAllowed && h.TenantService != nil {
					if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
						targetAllowed = manageableSlugs[strings.ToLower(tenant.ID)]
					}
				}
				if !targetAllowed {
					results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: target tenant not manageable"})
					continue
				}
			}
		}

		// Prepare updates
		traits := identity.Traits
		oldRoleForSync := roleFromTraits(traits)
		oldTenantIDForSync := extractTraitString(traits, "tenant_id")
		if req.Role != nil {
			traits["role"] = *req.Role
		}
		if req.CompanyCode != nil {
			delete(traits, "companyCode")
			delete(traits, "companyCodes")

			if req.IsAddTenant {
				if h.TenantService == nil {
					results = append(results, map[string]any{"id": id, "success": false, "message": "tenant service not available"})
					continue
				}
				tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
				if err != nil || tenant == nil {
					results = append(results, map[string]any{"id": id, "success": false, "message": "invalid tenant assignment"})
					continue
				}
				metadata := mergeUserAddTenantAppointment(traits, nil, tenant)
				if appointments, ok := metadata["additionalAppointments"]; ok {
					traits["additionalAppointments"] = appointments
				}
				if h.KetoOutboxRepo != nil {
					_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
						Namespace: "Tenant",
						Object:    tenant.ID,
						Relation:  "members",
						Subject:   "User:" + id,
						Action:    domain.KetoOutboxActionCreate,
					})
				}
			} else if tItem, exists := tenantCache[*req.CompanyCode]; exists {
				traits["tenant_id"] = tItem.ID
			} else if h.TenantService != nil {
				tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
				if err == nil && tenant != nil {
					tItem.ID = tenant.ID
					tenantCache[*req.CompanyCode] = tItem
					traits["tenant_id"] = tenant.ID
				}
			}
		}
		if req.Department != nil {
			traits["department"] = *req.Department
		}
		if req.Grade != nil {
			traits["grade"] = *req.Grade
		}
		if req.Position != nil {
			traits["position"] = *req.Position
		}
		if req.JobTitle != nil {
			traits["jobTitle"] = *req.JobTitle
		}

		state := identity.State
		if req.Status != nil {
			state = normalizeKratosState(req.Status)
			if state == "" {
				state = identity.State
			}
		}

		updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), id, traits, state)
		if err != nil {
			results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
			continue
		}

		// Sync to local DB
		if h.UserRepo != nil {
			localUser := h.mapToLocalUser(*updated)

			if req.Role != nil {
				localUser.Role = *req.Role
			}
			if req.Status != nil {
				localUser.Status = normalizeStatus(*req.Status)
			}
			if req.Department != nil {
				localUser.Department = *req.Department
			}
			if req.Position != nil {
				localUser.Position = *req.Position
			}
			if req.JobTitle != nil {
				localUser.JobTitle = *req.JobTitle
			}

			// Resolve TenantID if changing tenantSlug.
			if req.CompanyCode != nil && h.TenantService != nil {
				if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
					localUser.TenantID = &tenant.ID
				}
			}

			_ = h.UserRepo.Update(c.Context(), localUser)
			if h.Worksmobile != nil {
				if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
					slog.Warn("Failed to enqueue Worksmobile bulk user update sync", "userID", localUser.ID, "error", err)
				}
			}

			// [Keto Sync]
			if h.KetoOutboxRepo != nil {
				h.syncKetoRole(c.Context(), localUser.ID,
					localUser.Role, oldRoleForSync, oldTenantIDForSync, localUser.TenantID)
			}
		}

		results = append(results, map[string]any{"id": id, "success": true})
	}

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

func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
	var req struct {
		UserIDs []string `json:"userIds"`
	}
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
	}

	if len(req.UserIDs) == 0 {
		return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
	}

	requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
	if requester == nil {
		return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
	}

	manageableSlugs := make(map[string]bool)
	if requester.Role != domain.RoleSuperAdmin {
		manageableSlugs = profileTenantAccessKeys(requester)
	}

	results := make([]map[string]any, 0, len(req.UserIDs))

	for _, id := range req.UserIDs {
		// [Safety] Cannot delete yourself
		if id == requester.ID {
			results = append(results, map[string]any{
				"id":      id,
				"success": false,
				"message": "cannot delete your own account for safety",
			})
			continue
		}

		identity, err := h.KratosAdmin.GetIdentity(c.Context(), id)
		if err != nil {
			results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"})
			continue
		}

		// Authorization check
		if requester.Role != domain.RoleSuperAdmin {
			if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
				results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"})
				continue
			}
		}

		if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, id); err != nil {
			results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
			continue
		}

		err = h.KratosAdmin.DeleteIdentity(c.Context(), id)
		if err != nil {
			results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
			continue
		}
		if h.Worksmobile != nil {
			localUser := h.mapToLocalUser(*identity)
			if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
				slog.Warn("Failed to enqueue Worksmobile bulk user delete", "userID", id, "error", err)
			}
		}

		// Local DB Sync
		if h.UserRepo != nil {
			_ = h.UserRepo.Delete(c.Context(), id)
		}

		results = append(results, map[string]any{"id": id, "success": true})
	}

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

func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
	if h.KratosAdmin == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
	}

	userID := strings.TrimSpace(c.Params("id"))
	if userID == "" {
		return errorJSON(c, fiber.StatusBadRequest, "user id is required")
	}

	identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
	if err != nil {
		return errorJSON(c, fiber.StatusInternalServerError, err.Error())
	}
	if identity == nil {
		return errorJSON(c, fiber.StatusNotFound, "user not found")
	}

	// Capture current local state for transition comparison
	var oldRole string
	var oldTenantID string
	var oldDepartment string
	if h.UserRepo != nil {
		if local, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && local != nil {
			oldRole = local.Role
			oldDepartment = local.Department
			if local.TenantID != nil {
				oldTenantID = *local.TenantID
			}
		}
	}

	// [New] Check access scope
	requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
	if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
		allowed := map[string]bool{}
		if requester.TenantID != nil {
			allowed[strings.ToLower(*requester.TenantID)] = true
		}
		for _, tenant := range requester.ManageableTenants {
			allowed[strings.ToLower(tenant.ID)] = true
		}
		if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) {
			return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot update user in another tenant")
		}
	}

	var req struct {
		Email                  *string          `json:"email"`
		LoginID                *string          `json:"loginId"`
		Password               *string          `json:"password"`
		Name                   *string          `json:"name"`
		Phone                  *string          `json:"phone"`
		Role                   *string          `json:"role"`
		Status                 *string          `json:"status"`
		TenantSlug             *string          `json:"tenantSlug"`
		CompanyCode            *string          `json:"companyCode"`
		IsAddTenant            bool             `json:"isAddTenant"`
		IsRemoveTenant         bool             `json:"isRemoveTenant"`
		Department             *string          `json:"department"`
		Grade                  *string          `json:"grade"`
		Position               *string          `json:"position"`
		JobTitle               *string          `json:"jobTitle"`
		PrimaryTenantID        string           `json:"primaryTenantId"`
		PrimaryTenantName      string           `json:"primaryTenantName"`
		PrimaryTenantIsOwner   *bool            `json:"primaryTenantIsOwner"`
		AdditionalAppointments []map[string]any `json:"additionalAppointments"`
		Metadata               map[string]any   `json:"metadata"`
	}
	if err := c.BodyParser(&req); err != nil {
		return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
	}
	tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
	if err != nil {
		return errorJSON(c, fiber.StatusBadRequest, err.Error())
	}
	req.CompanyCode = tenantSlug
	req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
	if h.TenantService != nil {
		cleared, err := sanitizeUserRepresentativeTenants(c.Context(), h.TenantService, req.Metadata, req.AdditionalAppointments)
		if err != nil {
			return errorJSON(c, fiber.StatusBadRequest, err.Error())
		}
		if cleared {
			req.PrimaryTenantID = ""
			req.PrimaryTenantName = ""
			req.PrimaryTenantIsOwner = nil
			req.CompanyCode = nil
		}
	}
	if req.Role != nil {
		if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
			return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
		}
		role, ok := normalizeAssignableSystemRole(*req.Role)
		if !ok {
			return errorJSON(c, fiber.StatusBadRequest, "invalid role")
		}
		*req.Role = role
	}

	// All non-superadmins can only move users within tenants they can manage.
	if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
		if !req.IsRemoveTenant && req.CompanyCode != nil {
			targetSlug := strings.TrimSpace(*req.CompanyCode)
			targetAllowed := profileCanAccessTenant(requester, "", targetSlug)
			if !targetAllowed && h.TenantService != nil && targetSlug != "" {
				if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), targetSlug); err == nil && tenant != nil {
					targetAllowed = profileCanAccessTenant(requester, tenant.ID, tenant.Slug)
				}
			}
			if !targetAllowed {
				return errorJSON(c, fiber.StatusForbidden, "forbidden: non-superadmins cannot assign user's tenant to an unmanageable one")
			}
		}
	}

	// [Validation] Based on Tenant Schema (Multi-tenant aware)
	isAdmin := requester != nil && requester.Role == domain.RoleSuperAdmin
	if err := enforceGlobalCustomClaimWritePermissions(identity.Traits, req.Metadata, isAdmin); err != nil {
		return errorJSON(c, fiber.StatusForbidden, "forbidden: "+err.Error())
	}

	// If metadata is namespaced (key is tenant ID), validate each namespace
	// If it's flat, validate using schemaCompCode
	for key, val := range req.Metadata {
		// Basic check if key looks like a UUID (tenant ID)
		if len(key) >= 32 {
			// Namespaced metadata
			if h.TenantService != nil {
				tenant, err := h.TenantService.GetTenant(c.Context(), key)
				if err == nil && tenant != nil {
					if schema, ok := tenant.Config["userSchema"].([]any); ok {
						if subMeta, ok := val.(map[string]any); ok {
							if err := h.validateMetadataWithAuth(subMeta, schema, isAdmin, false); err != nil {
								return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed for tenant "+tenant.Name+": "+err.Error())
							}
						}
					}
				}
			}
		} else {
			schemaTenantSlug := ""
			if req.CompanyCode != nil {
				schemaTenantSlug = *req.CompanyCode
			}
			var tenant *domain.Tenant
			if schemaTenantSlug != "" && h.TenantService != nil {
				tenant, _ = h.TenantService.GetTenantBySlug(c.Context(), schemaTenantSlug)
			} else if tenantID := extractTraitString(identity.Traits, "tenant_id"); tenantID != "" && h.TenantService != nil {
				tenant, _ = h.TenantService.GetTenant(c.Context(), tenantID)
			}
			if tenant != nil {
				if schema, ok := tenant.Config["userSchema"].([]any); ok {
					if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
						return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
					}
				}
			}
			break // Only need to check flat metadata once
		}
	}

	traits := identity.Traits
	if traits == nil {
		traits = map[string]any{}
	}
	if req.Email != nil {
		currentEmail := strings.TrimSpace(extractTraitString(traits, "email"))
		nextEmail := strings.ToLower(strings.TrimSpace(*req.Email))
		if nextEmail == "" {
			return errorJSON(c, fiber.StatusBadRequest, "email is required")
		}
		parsed, parseErr := mail.ParseAddress(nextEmail)
		if parseErr != nil || !strings.EqualFold(parsed.Address, nextEmail) {
			return errorJSON(c, fiber.StatusBadRequest, "invalid email")
		}
		if !strings.EqualFold(currentEmail, nextEmail) {
			if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
				return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user email")
			}
			if h.UserRepo != nil {
				if existing, err := h.UserRepo.FindByEmail(c.Context(), nextEmail); err == nil && existing != nil && existing.ID != userID {
					return errorJSON(c, fiber.StatusConflict, "email is already used by another user")
				}
				if taken, err := h.UserRepo.IsLoginIDTaken(c.Context(), nextEmail); err == nil && taken {
					return errorJSON(c, fiber.StatusConflict, "email is already used as a login ID")
				}
			}
			traits["email"] = nextEmail
		}
	}
	delete(traits, "hanmacFamily")
	delete(traits, "userType")

	if req.Name != nil {
		traits["name"] = strings.TrimSpace(*req.Name)
	}
	if req.Phone != nil {
		phone := normalizePhoneNumber(strings.TrimSpace(*req.Phone))
		if phone == "" {
			delete(traits, "phone_number")
		} else {
			traits["phone_number"] = phone
		}
	}
	if req.CompanyCode != nil {
		code := strings.TrimSpace(*req.CompanyCode)

		if req.IsRemoveTenant {
			if h.TenantService != nil && code != "" {
				if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
					currentTenantID := extractTraitString(traits, "tenant_id")
					if currentTenantID == tenant.ID {
						traits["tenant_id"] = ""
					}
					req.Metadata = removeUserTenantAppointment(traits, req.Metadata, tenant)
					if h.KetoOutboxRepo != nil {
						_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
							Namespace: "Tenant",
							Object:    tenant.ID,
							Relation:  "members",
							Subject:   "User:" + userID,
							Action:    domain.KetoOutboxActionDelete,
						})
					}
				}
			}
		} else if !req.IsAddTenant {
			if h.TenantService != nil && code != "" {
				if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
					traits["tenant_id"] = tenant.ID
				} else {
					traits["tenant_id"] = ""
				}
			}
		} else if h.TenantService != nil && code != "" {
			tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code)
			if err != nil || tenant == nil {
				return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
			}
			req.Metadata = mergeUserAddTenantAppointment(traits, req.Metadata, tenant)
			if h.KetoOutboxRepo != nil {
				_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
					Namespace: "Tenant",
					Object:    tenant.ID,
					Relation:  "members",
					Subject:   "User:" + userID,
					Action:    domain.KetoOutboxActionCreate,
				})
			}
		}
	}
	delete(traits, "companyCode")
	delete(traits, "companyCodes")

	if req.Department != nil {
		traits["department"] = strings.TrimSpace(*req.Department)
	}
	if req.Grade != nil {
		traits["grade"] = strings.TrimSpace(*req.Grade)
	}
	if req.Position != nil {
		traits["position"] = strings.TrimSpace(*req.Position)
	}
	if req.JobTitle != nil {
		traits["jobTitle"] = strings.TrimSpace(*req.JobTitle)
	}
	if req.Role != nil {
		role := domain.NormalizeRole(*req.Role)
		if role == "" {
			role = domain.RoleUser
		}
		traits["role"] = role
	}

	// [Namespaced Metadata Sync]
	coreTraits := map[string]bool{
		"email": true, "name": true, "phone_number": true,
		"grade": true, "companyCode": true, "department": true,
		"position": true, "jobTitle": true,
		"affiliationType": true, "role": true, "tenant_id": true,
		"custom_login_ids": true, "id": true,
	}

	// For namespaced metadata, we don't delete everything, we merge.
	for k, v := range req.Metadata {
		if !coreTraits[k] {
			// Ensure we are merging maps (tenant namespaces) correctly, not overwriting with slices
			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 // New namespace
				}
			} else {
				traits[k] = v // Fallback for flat metadata
			}
		}
	}
	if subEmailRaw, exists := req.Metadata["sub_email"]; exists {
		subEmails := normalizeUserSubEmailValues(subEmailRaw)
		traits["sub_email"] = subEmails
		traits["aliasEmails"] = subEmails
		traits["secondary_emails"] = subEmails
		traits["worksmobileAliasEmails"] = subEmails
	}

	// [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, userID)

	// Validate all collected LoginIDs
	userEmail := extractTraitString(traits, "email")
	userPhone := extractTraitString(traits, "phone_number")

	allEmails := []string{userEmail}
	if secondaryRaw, exists := traits["sub_email"]; exists {
		if secondaryEmails, ok := secondaryRaw.([]any); ok {
			for _, se := range secondaryEmails {
				if seStr, ok := se.(string); ok {
					allEmails = append(allEmails, seStr)
				}
			}
		} else if secondaryEmails, ok := secondaryRaw.([]string); ok {
			allEmails = append(allEmails, secondaryEmails...)
		}
	}

	if collectedIDs, ok := traits["custom_login_ids"].([]string); ok {
		for _, lid := range collectedIDs {
			if err := domain.ValidateLoginID(lid, allEmails, userPhone); err != nil {
				return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error())
			}
		}
	}

	state := normalizeKratosState(req.Status)

	slog.Info("[UpdateUser] Calling Kratos UpdateIdentity", "userID", userID, "traits", traits, "state", state)

	updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
	if err != nil {
		// [Exception Handling] Check for 409 Conflict (Duplicate Identifier)
		if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "exists already") {
			return errorJSON(c, fiber.StatusConflict, "이미 다른 사용자가 사용 중인 식별자(이메일/전화번호/사번 등)가 포함되어 있습니다.")
		}
		return errorJSON(c, fiber.StatusInternalServerError, err.Error())
	}

	// [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller
	if h.UserRepo != nil {
		updatedLocalUser := h.mapToLocalUser(*updated)
		if req.Status != nil {
			updatedLocalUser.Status = normalizeStatus(*req.Status)
		}

		ctx := context.Background() // Use request context if appropriate, but sync must finish
		if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
			slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
			markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
		}
		if h.Worksmobile != nil {
			if err := h.Worksmobile.EnqueueUserUpsertIfInScope(ctx, *updatedLocalUser); err != nil {
				slog.Warn("[UserHandler] Failed to enqueue Worksmobile updated user sync", "userID", updatedLocalUser.ID, "error", err)
			}
		}

		// Update User Login IDs in local DB
		if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil {
			slog.Error("[UserHandler] Failed to update user login IDs", "userID", updatedLocalUser.ID, "error", err)
			markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
		}

		// [Keto Sync] asynchronously as it's less critical for immediate UI count
		go func() {
			bgCtx := context.Background()
			h.syncKetoRole(bgCtx, updatedLocalUser.ID,
				roleFromTraits(updated.Traits), oldRole, oldTenantID, updatedLocalUser.TenantID)

			// Try to automatically sync UserGroup membership based on Department
			if h.UserGroupRepo != nil && h.KetoOutboxRepo != nil {
				// 1. Remove from old group if department or tenant changed
				if oldTenantID != "" && oldDepartment != "" && (oldTenantID != extractTraitString(updated.Traits, "tenant_id") || oldDepartment != updatedLocalUser.Department) {
					if oldGroups, err := h.UserGroupRepo.ListByTenantID(bgCtx, oldTenantID); err == nil {
						for _, g := range oldGroups {
							if strings.EqualFold(g.Name, oldDepartment) {
								_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
									Namespace: "Tenant",
									Object:    g.ID,
									Relation:  "members",
									Subject:   "User:" + updatedLocalUser.ID,
									Action:    domain.KetoOutboxActionDelete,
								})
								break
							}
						}
					}
				}

				// 2. Add to new group
				if updatedLocalUser.TenantID != nil && updatedLocalUser.Department != "" {
					if groups, err := h.UserGroupRepo.ListByTenantID(bgCtx, *updatedLocalUser.TenantID); err == nil {
						for _, g := range groups {
							if strings.EqualFold(g.Name, updatedLocalUser.Department) {
								_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
									Namespace: "Tenant",
									Object:    g.ID,
									Relation:  "members",
									Subject:   "User:" + updatedLocalUser.ID,
									Action:    domain.KetoOutboxActionCreate,
								})
								break
							}
						}
					}
				}
			}

			if h.KetoOutboxRepo != nil && h.TenantService != nil {
				if updatedLocalUser.TenantID != nil {
					_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
						Namespace: "Tenant",
						Object:    *updatedLocalUser.TenantID,
						Relation:  "members",
						Subject:   "User:" + updatedLocalUser.ID,
						Action:    domain.KetoOutboxActionCreate,
					})
				}
			}
		}()
	}

	if req.Password != nil && *req.Password != "" {
		if h.OryProvider == nil {
			return errorJSON(c, fiber.StatusServiceUnavailable, "password provider not available")
		}
		// [New] Resolve a representative LoginID for the password update call
		updateLoginID := resolvePasswordLoginID(updated.Traits)
		if err := h.OryProvider.UpdateUserPassword(updateLoginID, *req.Password, nil); err != nil {
			return errorJSON(c, fiber.StatusInternalServerError, err.Error())
		}
	}

	return c.JSON(h.mapIdentitySummary(c.Context(), *updated))
}

func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
	if h.KratosAdmin == nil {
		return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
	}

	userID := strings.TrimSpace(c.Params("id"))
	if userID == "" {
		return errorJSON(c, fiber.StatusBadRequest, "user id is required")
	}

	slog.Info("[UserHandler] Attempting to delete user", "userID", userID)

	// [New] Check access scope before deletion
	requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)

	// [Safety] Cannot delete yourself
	if requester != nil && userID == requester.ID {
		return errorJSON(c, fiber.StatusForbidden, "cannot delete your own account for safety")
	}

	var identity *service.KratosIdentity
	if (requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin) || h.Worksmobile != nil {
		found, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
		if err == nil {
			identity = found
		}
	}
	if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
		if identity != nil {
			allowed := map[string]bool{}
			if requester.TenantID != nil {
				allowed[strings.ToLower(*requester.TenantID)] = true
			}
			for _, tenant := range requester.ManageableTenants {
				allowed[strings.ToLower(tenant.ID)] = true
			}
			if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) {
				return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot delete user in another tenant")
			}
		}
	}

	actualKratosID := userID
	if h.KratosAdmin != nil {
		id, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userID)
		if err == nil && id != "" {
			actualKratosID = id
			slog.Info("[UserHandler] Mapped userID to Kratos identity ID", "userID", userID, "actualKratosID", actualKratosID)
		} else {
			if h.UserRepo != nil {
				local, err := h.UserRepo.FindByID(c.Context(), userID)
				if err == nil && local != nil && local.Email != "" {
					id, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), local.Email)
					if err == nil && id != "" {
						actualKratosID = id
						slog.Info("[UserHandler] Mapped userID to Kratos identity ID via email", "userID", userID, "email", local.Email, "actualKratosID", actualKratosID)
					}
				}
			}
		}
	}

	if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, userID); err != nil {
		slog.Error("[UserHandler] Failed to enqueue RP cleanup", "userID", userID, "error", err)
		return errorJSON(c, fiber.StatusInternalServerError, err.Error())
	}

	if err := h.KratosAdmin.DeleteIdentity(c.Context(), actualKratosID); err != nil {
		slog.Error("[UserHandler] Failed to delete Kratos identity", "userID", userID, "actualKratosID", actualKratosID, "error", err)
		// If Kratos says 404, it might already be gone, so we can proceed to cleanup local DB
		if !strings.Contains(err.Error(), "404") {
			return errorJSON(c, fiber.StatusInternalServerError, err.Error())
		}
	}
	slog.Info("[UserHandler] Successfully deleted Kratos identity", "userID", userID, "actualKratosID", actualKratosID)

	if h.Worksmobile != nil && identity != nil {
		localUser := h.mapToLocalUser(*identity)
		if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
			slog.Warn("[UserHandler] Failed to enqueue Worksmobile user delete", "userID", userID, "error", err)
		}
	}

	// [Keto] Cleanup relations via Outbox
	if h.KetoOutboxRepo != nil {
		ctx := context.Background()
		_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
			Namespace: "System",
			Object:    "global",
			Relation:  "super_admins",
			Subject:   "User:" + userID,
			Action:    domain.KetoOutboxActionDelete,
		})
		// Additional cleanup for tenants could be added here if we keep track of user's current tenants
	}

	if h.UserRepo != nil {
		if err := h.UserRepo.Delete(context.Background(), userID); err != nil {
			slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err)
			markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err)
		} else {
			slog.Info("[UserHandler] Successfully deleted local user read-model", "userID", userID)
		}
	}

	return c.SendStatus(fiber.StatusNoContent)
}

func (h *UserHandler) enqueueDeletedUserRelyingPartyCleanup(ctx context.Context, requester *domain.UserProfileResponse, userID string) error {
	if h.KetoService == nil || h.KetoOutboxRepo == nil {
		return nil
	}

	actorID := ""
	tenantID := ""
	if requester != nil {
		actorID = strings.TrimSpace(requester.ID)
		if requester.TenantID != nil {
			tenantID = strings.TrimSpace(*requester.TenantID)
		}
	}

	subject := "User:" + strings.TrimSpace(userID)
	tuples, err := h.listDeletedUserRelyingPartyRelations(ctx, subject)
	if err != nil {
		return fmt.Errorf("failed to list relying party relations for user %s: %w", userID, err)
	}

	if len(tuples) == 0 {
		slog.Info("[UserHandler] No relying party relations found for deleted user cleanup", "userID", userID)
		return nil
	}

	seen := make(map[string]struct{}, len(tuples))
	for _, tuple := range tuples {
		if strings.TrimSpace(tuple.Object) == "" || strings.TrimSpace(tuple.Relation) == "" {
			continue
		}

		relSubject := strings.TrimSpace(tuple.SubjectID)
		if relSubject == "" {
			relSubject = subject
		}

		key := tuple.Namespace + "\x00" + tuple.Object + "\x00" + tuple.Relation + "\x00" + relSubject
		if _, exists := seen[key]; exists {
			continue
		}
		seen[key] = struct{}{}

		namespace := strings.TrimSpace(tuple.Namespace)
		if namespace == "" {
			namespace = "RelyingParty"
		}

		if err := h.KetoService.DeleteRelation(ctx, namespace, tuple.Object, tuple.Relation, relSubject); err != nil {
			slog.Warn("[UserHandler] Failed to delete RelyingParty relation immediately", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
		}

		if err := h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
			Namespace: namespace,
			Object:    tuple.Object,
			Relation:  tuple.Relation,
			Subject:   relSubject,
			Action:    domain.KetoOutboxActionDelete,
		}); err != nil {
			slog.Warn("[UserHandler] Failed to enqueue RelyingParty relation cleanup", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
			continue
		}

		if err := h.recordDeletedUserRelyingPartyCleanupAudit(actorID, tenantID, userID, tuple, relSubject); err != nil {
			slog.Warn("[UserHandler] Failed to record RelyingParty cleanup audit", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
		}
	}

	return nil
}

func (h *UserHandler) recordDeletedUserRelyingPartyCleanupAudit(
	actorID string,
	tenantID string,
	deletedUserID string,
	tuple service.RelationTuple,
	relSubject string,
) error {
	if h.AuditRepo == nil {
		return nil
	}

	details := map[string]any{
		"action":           "REMOVE_RELATION",
		"target_id":        strings.TrimSpace(tuple.Object),
		"source":           "user_delete",
		"deleted_user_id":  strings.TrimSpace(deletedUserID),
		"cascade_cleanup":  true,
		"relation_subject": strings.TrimSpace(relSubject),
		"before": map[string]any{
			"relation": strings.TrimSpace(tuple.Relation),
			"subject":  strings.TrimSpace(relSubject),
		},
	}
	if strings.TrimSpace(tenantID) != "" {
		details["tenant_id"] = strings.TrimSpace(tenantID)
	}

	raw, err := json.Marshal(details)
	if err != nil {
		return err
	}

	eventType := fmt.Sprintf("DELETE /api/v1/dev/clients/%s/relations", strings.TrimSpace(tuple.Object))
	if strings.TrimSpace(tuple.Relation) != "" {
		eventType = fmt.Sprintf("%s/%s", eventType, strings.TrimSpace(tuple.Relation))
	}

	return h.AuditRepo.Create(&domain.AuditLog{
		EventID:   fmt.Sprintf("user-delete-rp-cleanup-%d", time.Now().UnixNano()),
		Timestamp: time.Now().UTC(),
		UserID:    strings.TrimSpace(actorID),
		TenantID:  strings.TrimSpace(tenantID),
		EventType: eventType,
		Status:    "success",
		Details:   string(raw),
	})
}

func (h *UserHandler) listDeletedUserRelyingPartyRelations(ctx context.Context, subject string) ([]service.RelationTuple, error) {
	var tuples []service.RelationTuple
	var err error

	for attempt := range 3 {
		tuples, err = h.KetoService.ListRelations(ctx, "RelyingParty", "", "", subject)
		if err != nil {
			return nil, err
		}
		if len(tuples) > 0 {
			return tuples, nil
		}
		if attempt == 2 {
			break
		}
		time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
	}

	fallbackEntries, err := h.KetoOutboxRepo.ListCurrentBySubject(ctx, "RelyingParty", subject)
	if err != nil {
		return nil, err
	}
	if len(fallbackEntries) == 0 {
		return nil, nil
	}

	tuples = make([]service.RelationTuple, 0, len(fallbackEntries))
	for _, entry := range fallbackEntries {
		tuples = append(tuples, service.RelationTuple{
			Namespace: entry.Namespace,
			Object:    entry.Object,
			Relation:  entry.Relation,
			SubjectID: entry.Subject,
		})
	}

	slog.Warn("[UserHandler] Falling back to keto_outbox history for deleted user RP cleanup", "subject", subject, "tuples", len(tuples))
	return tuples, nil
}

func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
	traits := identity.Traits
	role := roleFromTraits(traits)

	// [FIX] Prioritize Local DB ID (the fixed UUID from user)
	finalID := identity.ID
	email := extractTraitString(traits, "email")
	if h.UserRepo != nil && email != "" {
		// 1. Try finding by email first as it's a strong identifier
		if local, err := h.UserRepo.FindByEmail(ctx, email); err == nil && local != nil {
			finalID = local.ID
		}
	}

	tenantID := extractTraitString(traits, "tenant_id")
	tenantSlug := ""
	var tenantSummary *domain.Tenant
	if tenantID != "" && h.TenantService != nil {
		if tenant, err := h.TenantService.GetTenant(ctx, tenantID); err == nil && tenant != nil {
			tenantSlug = tenant.Slug
			tenantSummary = tenant
		}
	}
	slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "tenantID", tenantID, "tenantSlug", tenantSlug)

	var customLoginIDs []string
	if raw, ok := traits["custom_login_ids"]; ok {
		if ids, ok := raw.([]any); ok {
			for _, id := range ids {
				if s, ok := id.(string); ok {
					customLoginIDs = append(customLoginIDs, s)
				}
			}
		} else if ids, ok := raw.([]string); ok {
			customLoginIDs = ids
		}
	}

	summary := userSummary{
		ID:             finalID,
		Email:          extractTraitString(traits, "email"),
		LoginID:        resolvePasswordLoginID(traits),
		CustomLoginIDs: customLoginIDs,
		Name:           extractTraitString(traits, "name"),
		Phone:          extractTraitString(traits, "phone_number"),
		Role:           role,
		Status:         normalizeStatus(identity.State),
		TenantSlug:     tenantSlug,
		CompanyCode:    tenantSlug,
		Department:     extractTraitString(traits, "department"),
		Grade:          gradeFromTraits(traits),
		Position:       extractTraitString(traits, "position"),
		JobTitle:       extractTraitString(traits, "jobTitle"),
		Metadata:       make(domain.JSONMap),
		CreatedAt:      formatTime(identity.CreatedAt),
		UpdatedAt:      formatTime(identity.UpdatedAt),
	}

	// [New] Fetch all joined tenants (for Multi-tenancy support)
	if h.TenantService != nil {
		if joined, err := h.TenantService.ListJoinedTenants(ctx, identity.ID); err == nil {
			summary.JoinedTenants = joined
		}
	}

	// [Namespaced Metadata] Handling
	// We assume core traits are at the top level.
	// For other keys, if they are UUIDs (tenant IDs), we treat them as namespaced metadata.
	// Otherwise, we put them in a "legacy" or "flat" bucket if needed, but for now let's keep them in summary.Metadata
	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,
		"custom_login_ids": true, "id": true,
	}

	for k, v := range traits {
		if coreTraits[k] {
			continue
		}
		// If the key is a tenant ID (uuid-like), it's namespaced metadata
		// If not, it's flat metadata (for backward compatibility)
		summary.Metadata[k] = v
	}

	summary.Tenant = tenantSummary

	return summary
}

func (h *UserHandler) mapLocalUserSummary(ctx context.Context, user domain.User) userSummary {
	tenantSlug := userTenantSlug(user)
	customLoginIDs := make([]string, 0, len(user.UserLoginIDs))
	for _, loginID := range user.UserLoginIDs {
		if strings.TrimSpace(loginID.LoginID) != "" {
			customLoginIDs = append(customLoginIDs, strings.TrimSpace(loginID.LoginID))
		}
	}

	summary := userSummary{
		ID:             user.ID,
		Email:          user.Email,
		LoginID:        user.Email,
		CustomLoginIDs: customLoginIDs,
		Name:           user.Name,
		Phone:          user.Phone,
		Role:           domain.NormalizeRole(user.Role),
		Status:         normalizeStatus(user.Status),
		TenantSlug:     tenantSlug,
		CompanyCode:    tenantSlug,
		Department:     user.Department,
		Grade:          user.Grade,
		Position:       user.Position,
		JobTitle:       user.JobTitle,
		Metadata:       user.Metadata,
		Tenant:         user.Tenant,
		CreatedAt:      formatTime(user.CreatedAt),
		UpdatedAt:      formatTime(user.UpdatedAt),
	}

	if h.TenantService != nil {
		if joined, err := h.TenantService.ListJoinedTenants(ctx, user.ID); err == nil {
			summary.JoinedTenants = joined
		}
	}

	return summary
}

func (h *UserHandler) normalizePhoneNumber(phone string) string {
	return normalizePhoneNumber(phone)
}

func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
	traits := identity.Traits
	role := roleFromTraits(traits)

	finalID := identity.ID

	user := &domain.User{
		ID:              finalID,
		Email:           extractTraitString(traits, "email"),
		Name:            extractTraitString(traits, "name"),
		Phone:           extractTraitString(traits, "phone_number"),
		Role:            role,
		Status:          normalizeStatus(identity.State),
		Department:      extractTraitString(traits, "department"),
		Grade:           gradeFromTraits(traits),
		Position:        extractTraitString(traits, "position"),
		JobTitle:        extractTraitString(traits, "jobTitle"),
		AffiliationType: extractTraitString(traits, "affiliationType"),
		CreatedAt:       identity.CreatedAt,
		UpdatedAt:       identity.UpdatedAt,
	}

	// 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable)
	tID := extractTraitString(traits, "tenant_id")
	if tID != "" {
		user.TenantID = &tID
	}

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

	return user
}

func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole, oldTenantID string, newTenantID *string) {
	newRole = domain.NormalizeRole(newRole)
	oldRole = domain.NormalizeRole(oldRole)

	newTID := ""
	if newTenantID != nil {
		newTID = *newTenantID
	}

	if h.KetoOutboxRepo == nil {
		return
	}

	if oldRole == newRole && oldTenantID == newTID {
		return // Nothing changed
	}

	// 1. Handle Role Changes (Super Admin Only)
	if oldRole == domain.RoleSuperAdmin {
		// Only remove super_admin if the role actually changed
		if oldRole != newRole {
			_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
				Namespace: "System",
				Object:    "global",
				Relation:  "super_admins",
				Subject:   "User:" + userID,
				Action:    domain.KetoOutboxActionDelete,
			})
		}
	}

	// Add new roles
	if newRole == domain.RoleSuperAdmin {
		if oldRole != newRole {
			_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
				Namespace: "System",
				Object:    "global",
				Relation:  "super_admins",
				Subject:   "User:" + userID,
				Action:    domain.KetoOutboxActionCreate,
			})
		}
	}

	// 2. Handle Tenant Membership (for count)
	if oldTenantID != newTID {
		// Remove from old tenant
		if oldTenantID != "" {
			_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
				Namespace: "Tenant",
				Object:    oldTenantID,
				Relation:  "members",
				Subject:   "User:" + userID,
				Action:    domain.KetoOutboxActionDelete,
			})
		}
		// Add to new tenant
		if newTID != "" {
			_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
				Namespace: "Tenant",
				Object:    newTID,
				Relation:  "members",
				Subject:   "User:" + userID,
				Action:    domain.KetoOutboxActionCreate,
			})
		}
	}
}

func extractTraitString(traits map[string]any, key string) string {
	if traits == nil {
		return ""
	}
	if raw, ok := traits[key]; ok {
		if value, ok := raw.(string); ok {
			return value
		}
	}
	return ""
}

func extractTraitStringArray(traits map[string]any, key string) []string {
	if traits == nil {
		return nil
	}
	if raw, ok := traits[key]; ok {
		if slice, ok := raw.([]any); ok {
			var result []string
			for _, v := range slice {
				if s, ok := v.(string); ok {
					result = append(result, s)
				}
			}
			return result
		}
		if slice, ok := raw.([]string); ok {
			return slice
		}
	}
	return nil
}

func resolvePasswordLoginID(traits map[string]any) string {
	// First check custom_login_ids (array)
	if raw, ok := traits["custom_login_ids"]; ok {
		if ids, ok := raw.([]any); ok && len(ids) > 0 {
			if first, ok := ids[0].(string); ok {
				return first
			}
		} else if ids, ok := raw.([]string); ok && len(ids) > 0 {
			return ids[0]
		}
	}
	// Fallback to legacy id (if still exists in some old identities)
	if loginID := strings.TrimSpace(extractTraitString(traits, "id")); loginID != "" {
		return loginID
	}
	if email := strings.TrimSpace(extractTraitString(traits, "email")); email != "" {
		return email
	}
	return strings.TrimSpace(extractTraitString(traits, "phone_number"))
}

// syncCustomLoginIDs collects all fields marked as isLoginId: true from tenant schemas
// and populates traits["custom_login_ids"] and returns domain.UserLoginID records for DB.
func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService, traits map[string]any, metadata map[string]any, userID string) []domain.UserLoginID {
	if tenantService == nil {
		return nil
	}

	var loginIDRecords []domain.UserLoginID
	var allCustomIDs []string
	idSet := make(map[string]bool)

	normalizeCustomLoginIDsTrait(traits)

	// Collect tenant IDs to check schemas for
	tenantIDsToCheck := make(map[string]bool)
	primaryTenantID := extractTraitString(traits, "tenant_id")
	for k, v := range metadata {
		if isTenantMetadataNamespace(k, v, primaryTenantID) {
			tenantIDsToCheck[k] = true
		}
	}
	// Also check primary tenant if available
	if tid := primaryTenantID; tid != "" && (len(metadata) > 0 || isMetadataMap(traits[tid])) {
		tenantIDsToCheck[tid] = true
	}
	if len(tenantIDsToCheck) == 0 {
		return nil
	}

	for tid := range tenantIDsToCheck {
		tenant, err := tenantService.GetTenant(ctx, tid)
		if err != nil || tenant == nil {
			continue
		}

		schema, ok := tenant.Config["userSchema"].([]any)
		if !ok {
			continue
		}

		for _, fieldRaw := range schema {
			field, ok := fieldRaw.(map[string]any)
			if !ok {
				continue
			}

			isLoginId, _ := field["isLoginId"].(bool)
			if !isLoginId {
				continue
			}

			fieldKey, ok := field["key"].(string)
			if !ok {
				continue
			}

			// Try to find value in namespaced metadata first, then flat metadata, then existing traits
			var val string
			if namespaced, ok := metadata[tid].(map[string]any); ok {
				val, _ = namespaced[fieldKey].(string)
			} else if namespaced, ok := metadata[tid].(map[string]any); ok {
				val, _ = namespaced[fieldKey].(string)
			}

			if val == "" {
				val, _ = metadata[fieldKey].(string)
			}

			if val == "" {
				// Check existing trait (namespaced)
				if namespaced, ok := traits[tid].(map[string]any); ok {
					val, _ = namespaced[fieldKey].(string)
				} else if namespaced, ok := traits[tid].(map[string]any); ok {
					val, _ = namespaced[fieldKey].(string)
				}
			}

			if val == "" {
				// Fallback: Check flat traits
				val = extractTraitString(traits, fieldKey)
			}

			if val != "" {
				if !idSet[val] {
					idSet[val] = true
					allCustomIDs = append(allCustomIDs, val)
				}
				loginIDRecords = append(loginIDRecords, domain.UserLoginID{
					UserID:   userID,
					TenantID: tid,
					FieldKey: fieldKey,
					LoginID:  val,
				})
			}
		}
	}

	if len(allCustomIDs) > 0 {
		traits["custom_login_ids"] = allCustomIDs
	} else {
		delete(traits, "custom_login_ids")
	}

	// Always remove legacy "id" trait to avoid confusion
	delete(traits, "id")

	return loginIDRecords
}

func isTenantMetadataNamespace(key string, value any, primaryTenantID string) bool {
	return isTenantMetadataNamespaceKey(key, primaryTenantID) && isMetadataMap(value)
}

func isTenantMetadataNamespaceKey(key string, primaryTenantID string) bool {
	if key == "" {
		return false
	}
	if primaryTenantID != "" && key == primaryTenantID {
		return true
	}
	if len(key) != 36 {
		return false
	}
	for index, char := range key {
		switch index {
		case 8, 13, 18, 23:
			if char != '-' {
				return false
			}
		default:
			if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
				return false
			}
		}
	}
	return true
}

func isMetadataMap(value any) bool {
	if _, ok := value.(map[string]any); ok {
		return true
	}
	if _, ok := value.(map[string]any); ok {
		return true
	}
	return false
}

func normalizeCustomLoginIDsTrait(traits map[string]any) {
	raw, exists := traits["custom_login_ids"]
	if !exists {
		return
	}
	switch values := raw.(type) {
	case []string:
		return
	case []any:
		normalized := make([]string, 0, len(values))
		for _, value := range values {
			if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
				normalized = append(normalized, text)
			}
		}
		if len(normalized) > 0 {
			traits["custom_login_ids"] = normalized
		}
	}
}

func normalizeUserSubEmailValues(raw any) []interface{} {
	values := make([]string, 0)
	switch typed := raw.(type) {
	case []string:
		values = append(values, typed...)
	case []interface{}:
		for _, item := range typed {
			values = append(values, fmt.Sprint(item))
		}
	case string:
		values = append(values, typed)
	default:
		if raw != nil {
			values = append(values, fmt.Sprint(raw))
		}
	}

	seen := map[string]bool{}
	result := make([]interface{}, 0, len(values))
	for _, value := range values {
		for _, part := range strings.Split(value, ",") {
			normalized := strings.ToLower(strings.TrimSpace(part))
			if normalized == "" || seen[normalized] {
				continue
			}
			seen[normalized] = true
			result = append(result, normalized)
		}
	}
	return result
}

func validateBulkUserEmailUniqueness(users []bulkUserItem) map[int]string {
	owners := map[string]map[int]bool{}
	errorsByIndex := map[int]string{}

	for index, user := range users {
		primaryEmail := normalizeBulkUserEmail(user.Email)
		aliases := bulkUserAliasEmailSet(user.Metadata)
		rowEmails := map[string]bool{}
		if primaryEmail != "" {
			rowEmails[primaryEmail] = true
		}
		for alias := range aliases {
			if primaryEmail != "" && alias == primaryEmail {
				errorsByIndex[index] = "duplicate email in bulk request: " + alias
			}
			rowEmails[alias] = true
		}
		for email := range rowEmails {
			if owners[email] == nil {
				owners[email] = map[int]bool{}
			}
			owners[email][index] = true
		}
	}

	for email, indexes := range owners {
		if len(indexes) < 2 {
			continue
		}
		for index := range indexes {
			errorsByIndex[index] = "duplicate email in bulk request: " + email
		}
	}

	return errorsByIndex
}

func normalizeBulkUserAliasMetadata(metadata map[string]any) {
	if metadata == nil {
		return
	}
	aliases := bulkUserAliasEmailSet(metadata)
	hasAliasField := false
	for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
		if _, exists := metadata[key]; exists {
			hasAliasField = true
			break
		}
	}
	if !hasAliasField {
		return
	}
	values := make([]interface{}, 0, len(aliases))
	for alias := range aliases {
		values = append(values, alias)
	}
	sort.Slice(values, func(i, j int) bool {
		return fmt.Sprint(values[i]) < fmt.Sprint(values[j])
	})
	metadata["sub_email"] = values
	metadata["aliasEmails"] = values
	metadata["secondary_emails"] = values
	metadata["worksmobileAliasEmails"] = values
}

func bulkUserAliasEmailSet(metadata map[string]any) map[string]bool {
	aliases := map[string]bool{}
	for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} {
		for _, value := range bulkUserEmailValues(metadata[key]) {
			aliases[value] = true
		}
	}
	return aliases
}

func bulkUserEmailValues(raw any) []string {
	values := make([]string, 0)
	switch typed := raw.(type) {
	case []string:
		values = append(values, typed...)
	case []interface{}:
		for _, item := range typed {
			values = append(values, fmt.Sprint(item))
		}
	case string:
		values = append(values, typed)
	default:
		if raw != nil {
			values = append(values, fmt.Sprint(raw))
		}
	}

	result := make([]string, 0, len(values))
	for _, value := range values {
		for _, token := range strings.FieldsFunc(value, func(r rune) bool {
			return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t'
		}) {
			email := normalizeBulkUserEmail(token)
			if email != "" {
				result = append(result, email)
			}
		}
	}
	return result
}

func normalizeBulkUserEmail(value string) string {
	normalized := strings.ToLower(strings.TrimSpace(value))
	if normalized == "" {
		return ""
	}
	parsed, err := mail.ParseAddress(normalized)
	if err != nil {
		return normalized
	}
	return strings.ToLower(strings.TrimSpace(parsed.Address))
}

func formatTime(value time.Time) string {
	if value.IsZero() {
		return ""
	}
	return value.Format(time.RFC3339)
}

func normalizeStatus(state string) string {
	return domain.NormalizeUserStatus(state)
}

func normalizeKratosState(status *string) string {
	if status == nil {
		return ""
	}
	value := strings.ToLower(strings.TrimSpace(*status))
	if value == "blocked" {
		return "inactive"
	}
	if value == domain.UserStatusActive {
		return domain.UserStatusActive
	}
	normalized := domain.NormalizeUserStatus(value)
	if normalized == domain.UserStatusPreboarding ||
		normalized == domain.UserStatusSuspended ||
		normalized == domain.UserStatusTemporaryLeave ||
		normalized == domain.UserStatusBaronGuest ||
		normalized == domain.UserStatusExtendedLeave ||
		normalized == domain.UserStatusArchived {
		return "inactive"
	}
	return ""
}

func normalizePhoneNumber(phone string) string {
	return domain.NormalizePhoneNumber(phone)
}

func (h *UserHandler) validateMetadata(metadata map[string]any, schema []any, checkRequired bool) error {
	return h.validateMetadataWithAuth(metadata, schema, true, checkRequired)
}

func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema []any, isAdmin bool, checkRequired bool) error {
	schemaMap := make(map[string]map[string]any)
	for _, s := range schema {
		if m, ok := s.(map[string]any); ok {
			if key, ok := m["key"].(string); ok {
				schemaMap[key] = m
			}
		}
	}

	// 1. Check required fields
	if checkRequired {
		for key, config := range schemaMap {
			required, _ := config["required"].(bool)
			val, exists := metadata[key]
			if required && (!exists || val == nil || val == "") {
				return errors.New("field " + key + " is required")
			}
		}
	}

	// 2. Check each field in metadata
	for key, val := range metadata {
		config, exists := schemaMap[key]
		if !exists {
			continue // Ignore fields not in schema or allow? Let's allow for now
		}

		// Admin Only check
		adminOnly, _ := config["adminOnly"].(bool)
		if adminOnly && !isAdmin {
			return errors.New("field " + key + " is admin only")
		}

		// Type validation
		if expectedType, ok := config["type"].(string); ok && expectedType != "" && val != nil && val != "" {
			switch expectedType {
			case "number":
				var numVal float64
				switch v := val.(type) {
				case float64:
					numVal = v
				case int:
					numVal = float64(v)
				case string:
					parsed, err := strconv.ParseFloat(v, 64)
					if err != nil {
						return errors.New("field " + key + " must be a number")
					}
					numVal = parsed
				default:
					return errors.New("field " + key + " must be a number")
				}
				if float64(int(numVal)) != numVal {
					return errors.New("field " + key + " must be an integer")
				}
				if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 {
					return errors.New("field " + key + " must be an unsigned integer")
				}
			case "float":
				var numVal float64
				switch v := val.(type) {
				case float64:
					numVal = v
				case int:
					numVal = float64(v)
				case string:
					parsed, err := strconv.ParseFloat(v, 64)
					if err != nil {
						return errors.New("field " + key + " must be a float")
					}
					numVal = parsed
				default:
					return errors.New("field " + key + " must be a float")
				}
				if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 {
					return errors.New("field " + key + " must be an unsigned float")
				}
			case "boolean":
				switch v := val.(type) {
				case bool:
					// ok
				case string:
					if v != "true" && v != "false" {
						return errors.New("field " + key + " must be a boolean")
					}
				default:
					return errors.New("field " + key + " must be a boolean")
				}
			case "date":
				if strVal, ok := val.(string); ok {
					if _, err := time.Parse("2006-01-02", strVal); err != nil {
						return errors.New("field " + key + " must be a valid date (YYYY-MM-DD)")
					}
				} else {
					return errors.New("field " + key + " must be a date string")
				}
			case "datetime":
				if strVal, ok := val.(string); ok {
					_, err1 := time.Parse(time.RFC3339, strVal)
					_, err2 := time.Parse("2006-01-02T15:04", strVal)
					_, err3 := time.Parse("2006-01-02T15:04:05", strVal)
					if err1 != nil && err2 != nil && err3 != nil {
						return errors.New("field " + key + " must be a valid datetime")
					}
				} else {
					return errors.New("field " + key + " must be a datetime string")
				}
			case "text":
				if _, ok := val.(string); !ok {
					return errors.New("field " + key + " must be a string")
				}
			}
		}

		// Regex validation
		if regexStr, ok := config["validation"].(string); ok && regexStr != "" {
			strVal := ""
			switch v := val.(type) {
			case string:
				strVal = v
			case float64:
				strVal = fmt.Sprintf("%v", v)
			case int:
				strVal = fmt.Sprintf("%v", v)
			}

			if strVal != "" {
				matched, err := regexp.MatchString(regexStr, strVal)
				if err != nil {
					return errors.New("invalid regex pattern for field " + key)
				}
				if !matched {
					return errors.New("field " + key + " does not match validation pattern")
				}
			}
		}
	}

	return nil
}

func (h *UserHandler) GetUserRpHistory(c *fiber.Ctx) error {
	userId := c.Params("id")
	if userId == "" {
		return errorJSON(c, fiber.StatusBadRequest, "user id is required")
	}

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

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

	type rpHistoryItem struct {
		ClientID    string `json:"client_id"`
		ClientName  string `json:"client_name"`
		LastLoginAt string `json:"lastLoginAt"`
		Status      string `json:"status"`
	}

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

	// Logs are DESC (newest first).
	for _, log := range logs {
		details, _ := utils.ParseAuditDetails(log.Details)
		cid, _ := details["client_id"].(string)
		if cid == "" {
			continue
		}

		if _, exists := historyMap[cid]; !exists {
			cname, _ := details["client_name"].(string)
			if cname == "" {
				cname = cid
			}

			historyMap[cid] = &rpHistoryItem{
				ClientID:    cid,
				ClientName:  cname,
				LastLoginAt: log.Timestamp.Format(time.RFC3339),
				Status:      "active", // Default based on latest grant
			}
			if log.EventType == "consent.revoked" {
				historyMap[cid].Status = "revoked"
			}
		}
	}

	result := make([]*rpHistoryItem, 0, len(historyMap))
	for _, item := range historyMap {
		result = append(result, item)
	}

	return c.JSON(result)
}
