package handlers

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/url"
	"strconv"
	"strings"

	"github.com/gofiber/fiber/v2"

	"wucher/internal/domain/auth"
	"wucher/internal/domain/contact"
	"wucher/internal/service"
	sharedconst "wucher/internal/shared/const"
	"wucher/internal/shared/pkg/apperrorsx"
	tzpkg "wucher/internal/shared/pkg/timezone"
	"wucher/internal/shared/pkg/uuidv7"
	"wucher/internal/transport/http/dto"
	"wucher/internal/transport/http/jsonapi"
	"wucher/internal/transport/http/response"
	"wucher/internal/transport/http/validators"
)

type AuthHandler struct {
	svc        *service.AuthService
	contactSvc contact.Service
	storage    fileManagerDownloadURLStorage
	validate   *validators.Validator
}

const (
	pinVerificationHeader = "X-PIN-Verification-Token"
	deviceTypeHeader      = "X-Device-Type"
)

func NewAuthHandler(svc *service.AuthService) *AuthHandler {
	return &AuthHandler{
		svc:      svc,
		validate: validators.New(),
	}
}

func (h *AuthHandler) WithFileStorage(storage fileManagerDownloadURLStorage) *AuthHandler {
	h.storage = storage
	return h
}

func (h *AuthHandler) WithContactService(contactSvc contact.Service) *AuthHandler {
	h.contactSvc = contactSvc
	return h
}

// Register godoc
// @Summary Register user with email & password
// @Description Creates user (public role: from AUTH_DEFAULT_ROLE, fallback: user) and sends verification link to email without issuing an auth session.
// @Description Next: open verify link -> then login.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.RegisterRequest true "JSON:API Register request"
// @Success 201 {object} dto.UserResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/register [post]
func (h *AuthHandler) Register(c *fiber.Ctx) error {
	if h.svc.RegisterDisabled() {
		return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
			Status: "403",
			Title:  "Registration disabled",
			Detail: "registration is disabled in this environment",
		}})
	}

	var req dto.RegisterRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}

	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	if strings.TrimSpace(req.Data.Attributes.Username) == "" {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "username is required",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/username"},
		}})
	}

	defaultRoleName := strings.TrimSpace(h.svc.DefaultRoleName())
	if defaultRoleName == "" {
		defaultRoleName = "user"
	}

	roleID, err := h.svc.GetRoleIDByName(c.UserContext(), defaultRoleName)
	if err != nil {
		return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Register.GetRoleIDByName", err)
	}

	user, err := h.svc.RegisterWithEmail(
		c.UserContext(),
		req.Data.Attributes.Email,
		req.Data.Attributes.Username,
		req.Data.Attributes.FirstName,
		req.Data.Attributes.LastName,
		strings.TrimSpace(req.Data.Attributes.MobilePhone),
		req.Data.Attributes.Password,
		roleID,
	)
	if err != nil {
		return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Register", err)
	}

	id, _ := uuidv7.BytesToString(user.ID)
	roleIDStr, _ := uuidv7.BytesToString(roleID)
	resp := jsonapi.Resource{
		Type: "user",
		ID:   id,
		Attributes: map[string]any{
			"email":        user.Email,
			"username":     derefString(user.Username),
			"first_name":   user.FirstName,
			"last_name":    user.LastName,
			"role_id":      roleIDStr,
			"role_ids":     []string{roleIDStr},
			"mobile_phone": user.MobilePhone,
			"timezone":     tzpkg.WithDefault(user.Timezone),
			"registered":   true,
		},
	}
	return response.Write(c, fiber.StatusCreated, resp)
}

// Invite godoc
// @Summary Invite user
// @Description Creates user without password and sends set-password link to email.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.InviteRequest true "JSON:API Invite request"
// @Success 201 {object} dto.UserResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/invite [post]
func (h *AuthHandler) Invite(c *fiber.Ctx) error {
	var req dto.InviteRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	if strings.TrimSpace(req.Data.Attributes.Username) == "" {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "username is required",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/username"},
		}})
	}

	roleID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.RoleID))
	if err != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "role_id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/role_id"},
		}})
	}
	roleIDs, roleErrs := parseRoleIDs(req.Data.Attributes.RoleIDs, "/data/attributes/role_ids")
	if len(roleErrs) > 0 {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, roleErrs)
	}
	roleIDs = normalizeRoleIDs(roleID, roleIDs)

	user, err := h.svc.InviteUserWithRoles(
		c.UserContext(),
		req.Data.Attributes.Email,
		req.Data.Attributes.Username,
		req.Data.Attributes.FirstName,
		req.Data.Attributes.LastName,
		strings.TrimSpace(req.Data.Attributes.MobilePhone),
		roleID,
		roleIDs,
	)
	if err != nil {
		return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Invite", err)
	}

	id, _ := uuidv7.BytesToString(user.ID)
	_, roles := authRoleResponseData(h.svc, c, user)
	roleIDStr, roleIDList := roleResponseIDFields(roles)
	resp := jsonapi.Resource{
		Type: "user",
		ID:   id,
		Attributes: map[string]any{
			"email":        user.Email,
			"username":     derefString(user.Username),
			"first_name":   user.FirstName,
			"last_name":    user.LastName,
			"mobile_phone": user.MobilePhone,
			"role_id":      roleIDStr,
			"role_ids":     roleIDList,
			"timezone":     tzpkg.WithDefault(user.Timezone),
		},
	}
	return response.Write(c, fiber.StatusCreated, resp)
}

// SetPassword godoc
// @Summary Set password from invite token
// @Description Sets first password for invited user and marks email as verified.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.SetPasswordRequest true "JSON:API Set password request"
// @Success 200 {object} dto.WebAuthnResetSetupResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/set-password [post]
func (h *AuthHandler) SetPassword(c *fiber.Ctx) error {
	var req dto.SetPasswordRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	if err := h.svc.SetPasswordWithInviteToken(c.UserContext(), req.Data.Attributes.Token, req.Data.Attributes.Password); err != nil {
		return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.SetPassword", err)
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_set_password",
		Attributes: map[string]any{
			"success": true,
		},
	})
}

// ResendSetPasswordInvite godoc
// @Summary Resend set-password invite
// @Description Triggers a new set-password invite email for an invited user by user_id. Response is generic for security.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.ResendSetPasswordInviteRequest true "JSON:API resend set-password invite request"
// @Success 200 {object} dto.AuthSessionsResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/invite/resend-set-password [post]
func (h *AuthHandler) ResendSetPasswordInvite(c *fiber.Ctx) error {
	var req dto.ResendSetPasswordInviteRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	userID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.UserID))
	if err != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "user_id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
		}})
	}

	h.svc.ResendInviteSetPassword(c.UserContext(), userID)

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_resend_set_password",
		Attributes: map[string]any{
			"message": "If allowed, a set-password invite email has been sent.",
		},
	})
}

// ForgotPassword godoc
// @Summary Forgot password
// @Description Always returns a generic success response.
// @Tags Auth - Forgot Password
// @Accept json
// @Produce json
// @Param request body dto.ForgotPasswordRequest true "JSON:API Forgot password request"
// @Success 200 {object} dto.AuthSessionsResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/forgot-password [post]
func (h *AuthHandler) ForgotPassword(c *fiber.Ctx) error {
	var req dto.ForgotPasswordRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	h.svc.ForgotPassword(
		c.UserContext(),
		req.Data.Attributes.Email,
		c.IP(),
		c.Get(fiber.HeaderUserAgent),
	)

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_forgot_password",
		Attributes: map[string]any{
			"message": "If the account exists, a password reset link has been sent.",
		},
	})
}

// ResetPassword godoc
// @Summary Reset password
// @Description Resets password using a single-use reset token.
// @Tags Auth - Forgot Password
// @Accept json
// @Produce json
// @Param request body dto.ResetPasswordRequest true "JSON:API Reset password request"
// @Success 200 {object} dto.AuthSessionDeleteResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/reset-password [post]
func (h *AuthHandler) ResetPassword(c *fiber.Ctx) error {
	var req dto.ResetPasswordRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	if req.Data.Attributes.NewPassword != req.Data.Attributes.ConfirmPassword {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "confirm_password must match new_password",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_password"},
		}})
	}

	if err := h.svc.ResetPassword(c.UserContext(), req.Data.Attributes.Token, req.Data.Attributes.NewPassword); err != nil {
		if errors.Is(err, service.ErrInvalidResetLink) || errors.Is(err, service.ErrInvalidToken) {
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "Invalid reset link",
				Detail: "This reset link is invalid or expired.",
			}})
		}
		return writeAuthErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
			Status: "500",
			Title:  "Reset password failed",
			Detail: "unable to reset password at this time",
		}})
	}

	clearAuthCookies(c, h.svc)
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_reset_password",
		Attributes: map[string]any{
			"message": "Password has been reset successfully. Please log in again.",
		},
	})
}

// SetupSecurityPIN godoc
// @Summary Setup security PIN
// @Description Creates a 6-digit security PIN for sensitive actions.
// @Tags Auth - PIN
// @Accept json
// @Produce json
// @Param request body dto.SecurityPINSetupRequest true "JSON:API security PIN setup request"
// @Success 200 {object} dto.SecurityPINSetupResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Router /api/v1/auth/pin/setup [post]
func (h *AuthHandler) SetupSecurityPIN(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
		e.Message = "missing auth context"
		return apperrorsx.Write(c, e)
	}

	var req dto.SecurityPINSetupRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	if req.Data.Attributes.PIN != req.Data.Attributes.ConfirmPIN {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "confirm_pin must match pin",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_pin"},
		}})
	}

	if err := h.svc.SetSecurityPIN(c.UserContext(), userID, req.Data.Attributes.PIN); err != nil {
		switch {
		case errors.Is(err, service.ErrSecurityPINAlreadySet):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotSet))
		case errors.Is(err, service.ErrInvalidSecurityPIN):
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "pin must be exactly 6 digits",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/pin"},
			}})
		default:
			return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.SetupSecurityPIN", err)
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_pin_setup",
		Attributes: map[string]any{
			"configured": true,
		},
	})
}

func derefString(v *string) string {
	if v == nil {
		return ""
	}
	return strings.TrimSpace(*v)
}

// VerifySecurityPIN godoc
// @Summary Verify security PIN for sensitive action
// @Description Verifies PIN and returns a short-lived one-time action token.
// @Tags Auth - PIN
// @Accept json
// @Produce json
// @Param request body dto.SecurityPINVerifyRequest true "JSON:API security PIN verify request"
// @Success 200 {object} dto.SecurityPINVerifyResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Failure 429 {object} dto.ErrorResponse
// @Router /api/v1/auth/pin/verify [post]
func (h *AuthHandler) VerifySecurityPIN(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
		e.Message = "missing auth context"
		return apperrorsx.Write(c, e)
	}

	var req dto.SecurityPINVerifyRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	sessionID := ""
	if accessToken := readValidAccessToken(c, h.svc); accessToken != "" {
		sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
		if err != nil {
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid))
		}
		sessionID = sid
	}

	token, err := h.svc.VerifySecurityPINForActionInSession(c.UserContext(), userID, req.Data.Attributes.PIN, req.Data.Attributes.Action, sessionID)
	if err != nil {
		switch {
		case errors.Is(err, service.ErrSecurityPINNotSet):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotSet))
		case errors.Is(err, service.ErrSecurityPINLocked):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
		case errors.Is(err, service.ErrSecurityPINAttemptLimit):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
		case errors.Is(err, service.ErrSecurityPINResetRequired):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
		case errors.Is(err, service.ErrInvalidSecurityPIN):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINIncorrect))
		default:
			return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.VerifySecurityPIN", err)
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_pin_verify",
		Attributes: map[string]any{
			"verified":            true,
			"action_token":        token,
			"action_token_ttl_ms": h.svc.SecurityPINTokenTTL().Milliseconds(),
		},
	})
}

// ChangeSecurityPIN godoc
// @Summary Change security PIN
// @Tags Auth - PIN
// @Accept json
// @Produce json
// @Param request body dto.SecurityPINChangeRequest true "JSON:API security PIN change request"
// @Success 200 {object} dto.AuthSessionsResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 429 {object} dto.ErrorResponse
// @Router /api/v1/auth/pin/change [post]
func (h *AuthHandler) ChangeSecurityPIN(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
		e.Message = "missing auth context"
		return apperrorsx.Write(c, e)
	}

	var req dto.SecurityPINChangeRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	if req.Data.Attributes.NewPIN != req.Data.Attributes.ConfirmPIN {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "confirm_pin must match new_pin",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_pin"},
		}})
	}

	if err := h.svc.ChangeSecurityPIN(c.UserContext(), userID, req.Data.Attributes.CurrentPIN, req.Data.Attributes.NewPIN); err != nil {
		switch {
		case errors.Is(err, service.ErrSecurityPINNotSet):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotSet))
		case errors.Is(err, service.ErrSecurityPINLocked):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
		case errors.Is(err, service.ErrSecurityPINAttemptLimit):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
		case errors.Is(err, service.ErrSecurityPINResetRequired):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINBlocked))
		case errors.Is(err, service.ErrInvalidSecurityPIN):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINIncorrect))
		default:
			return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.ChangeSecurityPIN", err)
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_pin_change",
		Attributes: map[string]any{
			"updated": true,
		},
	})
}

// ForgotSecurityPIN godoc
// @Summary Forgot security PIN
// @Description Always returns a generic success response.
// @Tags Auth - PIN
// @Accept json
// @Produce json
// @Param request body dto.ForgotSecurityPINRequest true "JSON:API forgot security PIN request"
// @Success 200 {object} dto.AuthSessionDeleteResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/pin/forgot [post]
func (h *AuthHandler) ForgotSecurityPIN(c *fiber.Ctx) error {
	var req dto.ForgotSecurityPINRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	h.svc.ForgotSecurityPIN(
		c.UserContext(),
		req.Data.Attributes.Email,
		c.IP(),
		c.Get(fiber.HeaderUserAgent),
	)

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_forgot_pin",
		Attributes: map[string]any{
			"message": "If the account exists, a security PIN reset link has been sent.",
		},
	})
}

// RequestSecurityPINReset godoc
// @Summary Request security PIN reset email for blocked PIN
// @Description Verifies current account password, then sends PIN reset email to currently logged-in user when PIN is blocked.
// @Tags Auth - PIN
// @Accept json
// @Produce json
// @Param request body dto.RequestSecurityPINRequest true "JSON:API request security PIN reset payload"
// @Success 200 {object} dto.RequestSecurityPINResetResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Failure 429 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /api/v1/auth/pin/request-reset [post]
func (h *AuthHandler) RequestSecurityPINReset(c *fiber.Ctx) error {
	var req dto.RequestSecurityPINRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		e := apperrorsx.New(apperrorsx.ErrUserUnauthorized)
		e.Message = "missing auth context"
		return apperrorsx.Write(c, e)
	}

	method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method))
	if method == "" {
		method = "password"
	}
	switch method {
	case "password":
		if strings.TrimSpace(req.Data.Attributes.Password) == "" {
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "password is required when method is password",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"},
			}})
		}
	case "microsoft":
		if strings.TrimSpace(req.Data.Attributes.ReauthToken) == "" {
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "reauth_token is required when method is microsoft",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/reauth_token"},
			}})
		}
	default:
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "method must be one of: password, microsoft",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
		}})
	}

	if err := h.svc.RequestSecurityPINReset(c.UserContext(), userID, method, req.Data.Attributes.Password, req.Data.Attributes.ReauthToken, c.IP(), c.Get(fiber.HeaderUserAgent)); err != nil {
		switch {
		case errors.Is(err, service.ErrInvalidPINResetPassword):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrUserUnauthorized))
		case errors.Is(err, service.ErrInvalidSSOReauth):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid))
		case errors.Is(err, service.ErrInvalidReauthMethod):
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "method must be one of: password, microsoft",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
			}})
		case errors.Is(err, service.ErrSecurityPINNotBlocked):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINNotBlocked))
		case errors.Is(err, service.ErrSecurityPINResetRatelimit):
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrPINAttemptLimitReached))
		default:
			return writeAuthErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
				Status: "500",
				Title:  "Reset request failed",
				Detail: "unable to send reset email at this time",
			}})
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_pin_request_reset",
		Attributes: map[string]any{
			"message": "If allowed, a security PIN reset link has been sent to your email.",
		},
	})
}

// ResetSecurityPIN godoc
// @Summary Reset security PIN
// @Description Resets security PIN using a re-auth method. For method=password, send the single-use reset token from the email link plus password. For method=microsoft, send only reauth_token from Microsoft re-auth.
// @Tags Auth - PIN
// @Accept json
// @Produce json
// @Param request body dto.ResetSecurityPINRequest true "JSON:API reset security PIN request"
// @Success 200 {object} dto.AuthSessionsResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/pin/reset [post]
func (h *AuthHandler) ResetSecurityPIN(c *fiber.Ctx) error {
	var req dto.ResetSecurityPINRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	if req.Data.Attributes.NewPIN != req.Data.Attributes.ConfirmPIN {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "confirm_pin must match new_pin",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/confirm_pin"},
		}})
	}

	method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method))
	if method == "" {
		method = "password"
	}
	switch method {
	case "password":
		if strings.TrimSpace(req.Data.Attributes.Token) == "" {
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "token is required when method is password",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/token"},
			}})
		}
		if strings.TrimSpace(req.Data.Attributes.Password) == "" {
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "password is required when method is password",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"},
			}})
		}
	case "microsoft":
		if strings.TrimSpace(req.Data.Attributes.ReauthToken) == "" {
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "reauth_token is required when method is microsoft",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/reauth_token"},
			}})
		}
	default:
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "method must be one of: password, microsoft",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
		}})
	}

	if err := h.svc.ResetSecurityPIN(c.UserContext(), req.Data.Attributes.Token, method, req.Data.Attributes.Password, req.Data.Attributes.ReauthToken, req.Data.Attributes.NewPIN); err != nil {
		if errors.Is(err, service.ErrInvalidResetLink) {
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrInvalidRequest))
		}
		if errors.Is(err, service.ErrInvalidPINResetPassword) {
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrUserUnauthorized))
		}
		if errors.Is(err, service.ErrInvalidSSOReauth) {
			return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTokenInvalid))
		}
		if errors.Is(err, service.ErrInvalidReauthMethod) {
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "method must be one of: password, microsoft",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
			}})
		}
		if errors.Is(err, service.ErrInvalidSecurityPIN) {
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "new_pin must be exactly 6 digits",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/new_pin"},
			}})
		}
		return writeAuthErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
			Status: "500",
			Title:  "Reset security PIN failed",
			Detail: "unable to reset security PIN at this time",
		}})
	}

	attrs := map[string]any{
		"message":       "Security PIN has been reset successfully.",
		"reset_success": true,
	}
	if redirectURL := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirectURL != "" {
		attrs["redirect_url"] = redirectURL
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type:       "auth_reset_pin",
		Attributes: attrs,
	})
}

// Login godoc
// @Summary Login with email & password
// @Description If TOTP enabled, returns 202 + challenge_token (use /auth/totp/verify).
// @Description If TOTP is not enabled, returns 202 + email OTP challenge_token (use /auth/email-otp/send then /auth/email-otp/verify).
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.LoginRequest true "JSON:API Login request"
// @Success 200 {object} dto.AuthCurrentUserResponse
// @Success 202 {object} dto.LoginTOTPRequiredResponse
// @Success 202 {object} dto.LoginEmailOTPRequiredResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/login [post]
func (h *AuthHandler) Login(c *fiber.Ctx) error {
	var req dto.LoginRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	user, err := h.svc.LoginWithEmailPassword(c.UserContext(), req.Data.Attributes.Email, req.Data.Attributes.Password)
	if err != nil {
		return apperrorsx.Write(c, apperrorsx.Translate(apperrorsx.ModuleAuth, err))
	}

	totpEnabled, _ := h.svc.IsTOTPEnabled(c.UserContext(), user.ID)
	webauthnConfigured, _ := h.svc.IsWebAuthnConfigured(c.UserContext(), user.ID)
	linkedSSO, _ := h.svc.IsSSOLinked(c.UserContext(), user.ID)
	pinConfigured := len(user.SecurityPINHash) > 0
	if totpEnabled {
		token, err := h.svc.CreateTOTPChallenge(c.UserContext(), user.ID)
		if err != nil {
			return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Login.CreateTOTPChallenge", err)
		}

		attrs := map[string]any{
			"requires_totp":       true,
			"challenge_token":     token,
			"totp_enabled":        true,
			"email_otp_enabled":   false,
			"webauthn_configured": webauthnConfigured,
			"linked_sso":          linkedSSO,
			"pin_configured":      pinConfigured,
		}

		// When email OTP is enabled globally, mint a parallel email-OTP challenge
		// so the user can fall back to email verification without re-submitting credentials.
		if h.svc.IsLoginEmailOTPEnabled(c.UserContext()) {
			emailToken, emailErr := h.svc.CreateEmailOTPChallenge(c.UserContext(), user.ID)
			if emailErr == nil {
				attrs["email_otp_enabled"] = true
				attrs["email_otp_challenge_token"] = emailToken
				attrs["email_otp_resend_cooldown_seconds"] = int(h.svc.EmailOTPResendCooldown().Seconds())
				attrs["email_otp_max_resend_per_hour"] = h.svc.EmailOTPMaxResendPerHour()
			}
		}

		return response.Write(c, fiber.StatusAccepted, jsonapi.Resource{
			Type:       "auth_totp_required",
			Attributes: attrs,
		})
	}

	if !h.svc.IsLoginEmailOTPEnabled(c.UserContext()) {
		tokens, err := h.svc.IssueTokensWithClientAndDeviceType(
			c.UserContext(),
			user,
			c.Get(fiber.HeaderUserAgent),
			c.IP(),
			readDeviceTypeHint(c),
		)
		if err != nil {
			return writeInternalHandlerErrorWithStatus(c, fiber.StatusBadRequest, "AuthHandler.Login.IssueTokens", err)
		}
		setAuthCookies(c, h.svc, tokens)
		return response.Write(c, fiber.StatusOK, jsonapi.Resource{
			Type: "auth_tokens",
			Attributes: map[string]any{
				"access_expires_in":   int64(h.svc.AccessTTL().Seconds()),
				"refresh_expires_in":  int64(tokens.RefreshTTL.Seconds()),
				"requires_totp_setup": true,
				"email_otp_enabled":   false,
			},
		})
	}

	emailChallengeToken, err := h.svc.CreateEmailOTPChallenge(c.UserContext(), user.ID)
	if err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Email OTP challenge failed",
			Detail: "an internal error occurred",
		}})
	}
	return response.Write(c, fiber.StatusAccepted, jsonapi.Resource{
		Type: "auth_email_otp_required",
		Attributes: map[string]any{
			"requires_email_otp":      true,
			"challenge_token":         emailChallengeToken,
			"resend_cooldown_seconds": int(h.svc.EmailOTPResendCooldown().Seconds()),
			"max_resend_per_hour":     h.svc.EmailOTPMaxResendPerHour(),
			"requires_totp_setup":     true,
			"webauthn_configured":     webauthnConfigured,
			"linked_sso":              linkedSSO,
			"pin_configured":          pinConfigured,
		},
	})
}

// Exchange godoc
// @Summary Exchange frontend Microsoft SSO token into backend session
// @Description Accepts Microsoft token payload from frontend ssoSilent and issues backend auth cookies.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.ExchangeRequest true "JSON:API Exchange request"
// @Success 200 {object} dto.TokenResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/exchange [post]
func (h *AuthHandler) Exchange(c *fiber.Ctx) error {
	var req dto.ExchangeRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	identity, err := h.svc.ExchangeMicrosoftToken(
		c.UserContext(),
		req.Data.Attributes.AccessToken,
		req.Data.Attributes.IDToken,
		req.Data.Attributes.IDTokenClaims.ToMap(),
	)
	if err != nil {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "an internal error occurred",
		}})
	}

	user, err := h.svc.GetUserByID(c.UserContext(), identity.UserID)
	if err != nil || user == nil {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "linked user is not found",
		}})
	}

	// Issue internal session tokens. Keep auth provider as local since FE-managed ssoSilent
	// does not provide backend-managed Microsoft refresh token.
	tokens, err := h.svc.IssueTokensWithClientAndDeviceType(
		c.UserContext(),
		user,
		c.Get(fiber.HeaderUserAgent),
		c.IP(),
		readDeviceTypeHint(c),
	)
	if err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Token issue failed",
			Detail: "an internal error occurred",
		}})
	}
	setAuthCookies(c, h.svc, tokens)

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_tokens",
		Attributes: map[string]any{
			"access_expires_in":  int64(h.svc.AccessTTL().Seconds()),
			"refresh_expires_in": int64(tokens.RefreshTTL.Seconds()),
		},
	})
}

// VerifyEmail godoc
// @Summary Verify email
// @Description Confirms email verification after register.
// @Tags Auth
// @Produce json
// @Param token query string true "Verification token"
// @Success 200 {object} dto.AuthSessionDeleteResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/verify-email [get]
func (h *AuthHandler) VerifyEmail(c *fiber.Ctx) error {
	token := c.Query("token")
	if token == "" {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid token",
			Detail: "token is required",
			Source: &jsonapi.ErrorSource{Pointer: "/query/token"},
		}})
	}
	if err := h.svc.VerifyEmail(c.UserContext(), token); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid token",
			Detail: "an internal error occurred",
		}})
	}
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_verify_email",
		Attributes: map[string]any{
			"verified": true,
		},
	})
}

// MicrosoftLogin godoc
// @Summary Microsoft Entra SSO login
// @Description Returns JSON:API payload containing Microsoft authorization URL.
// @Description Backend starts with silent session-check (`prompt=none`). If Microsoft session is inactive, callback automatically falls back to interactive login.
// @Tags Auth
// @Produce json
// @Success 200 {object} dto.MicrosoftLoginResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/microsoft/login [get]
func (h *AuthHandler) MicrosoftLogin(c *fiber.Ctx) error {
	// Interactive sign-in for an explicit "Login with Microsoft" action. Silent session
	// checks (prompt=none) are served separately by /microsoft/login-check.
	urlStr, err := h.svc.BeginMicrosoftLogin(c.UserContext())
	if err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "SSO not configured",
			Detail: "an internal error occurred",
		}})
	}
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_microsoft_url",
		Attributes: map[string]any{
			"url":          urlStr,
			"redirect_url": urlStr,
		},
	})
}

// MicrosoftLoginCheck godoc
// @Summary Microsoft Entra silent session check URL
// @Description Returns JSON:API payload containing Microsoft authorization URL with prompt=none for strict session check.
// @Tags Auth
// @Produce json
// @Success 200 {object} dto.MicrosoftLoginResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/microsoft/login-check [get]
func (h *AuthHandler) MicrosoftLoginCheck(c *fiber.Ctx) error {
	urlStr, err := h.svc.BeginMicrosoftSessionCheckStrict(c.UserContext())
	if err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "SSO not configured",
			Detail: "an internal error occurred",
		}})
	}
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_microsoft_url",
		Attributes: map[string]any{
			"url":          urlStr,
			"redirect_url": urlStr,
		},
	})
}

// MicrosoftLink godoc
// @Summary Microsoft Entra SSO link URL
// @Description Returns JSON:API payload containing Microsoft authorization URL for linking SSO to the current authenticated user.
// @Tags Auth
// @Produce json
// @Security BearerAuth
// @Success 200 {object} dto.MicrosoftLoginResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/auth/microsoft/link [get]
func (h *AuthHandler) MicrosoftLink(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}
	urlStr, err := h.svc.BeginMicrosoftLink(c.UserContext(), userID)
	if err != nil {
		status := fiber.StatusBadRequest
		title := "SSO link failed"
		detail := "an internal error occurred"
		if errors.Is(err, service.ErrUserNotFound) {
			status = fiber.StatusNotFound
			title = "User not found"
			detail = "user not found"
		}
		return writeAuthErrors(c, status, []jsonapi.ErrorObject{{
			Status: strconv.Itoa(status),
			Title:  title,
			Detail: detail,
		}})
	}
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_microsoft_url",
		Attributes: map[string]any{
			"url":          urlStr,
			"redirect_url": urlStr,
		},
	})
}

// MicrosoftReauth godoc
// @Summary Microsoft Entra SSO reauth URL
// @Description Starts a Microsoft reauthentication flow for supported sensitive actions.
// @Tags Auth
// @Produce json
// @Param purpose query string true "Supported values: pin_reset_request, pin_reset_confirm"
// @Param token query string false "Required when purpose=pin_reset_confirm"
// @Success 200 {object} dto.MicrosoftLoginResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/auth/microsoft/reauth [get]
func (h *AuthHandler) MicrosoftReauth(c *fiber.Ctx) error {
	purpose := strings.TrimSpace(c.Query("purpose"))
	var (
		urlStr string
		err    error
	)

	switch purpose {
	case "pin_reset_request":
		userID, ok := c.Locals("user_id").([]byte)
		if !ok || len(userID) == 0 {
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Unauthorized",
				Detail: "missing auth context",
			}})
		}
		urlStr, err = h.svc.BeginMicrosoftReauth(c.UserContext(), userID, purpose)
	case "pin_reset_confirm":
		urlStr, err = h.svc.BeginMicrosoftPINResetReauth(c.UserContext(), c.Query("token"))
	default:
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid reauth request",
			Detail: "purpose must be one of: pin_reset_request, pin_reset_confirm",
		}})
	}
	if err != nil {
		status := fiber.StatusBadRequest
		title := "SSO reauth failed"
		detail := "an internal error occurred"
		if errors.Is(err, service.ErrInvalidResetLink) {
			title = "Invalid reset link"
			detail = "This reset link is invalid or expired."
		}
		return writeAuthErrors(c, status, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  title,
			Detail: detail,
		}})
	}
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_microsoft_url",
		Attributes: map[string]any{
			"url":          urlStr,
			"redirect_url": urlStr,
			"purpose":      purpose,
		},
	})
}

// MicrosoftCallback godoc
// @Summary Microsoft Entra SSO callback
// @Description Completes Microsoft SSO login. Login succeeds only when SSO identity is already linked to an existing user.
// @Tags Auth
// @Produce json
// @Param code query string true "Authorization code"
// @Param state query string true "State"
// @Success 200 {object} dto.MicrosoftCallbackResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/auth/microsoft/callback [get]
func (h *AuthHandler) MicrosoftCallback(c *fiber.Ctx) error {
	requestID := strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID))
	if requestID == "" {
		requestID = strings.TrimSpace(c.Get(fiber.HeaderXRequestID))
	}
	if requestID == "" {
		requestID = strings.TrimSpace(c.Get("X-Request-Id"))
	}
	if requestID == "" {
		requestID = strings.TrimSpace(c.Get("X-Request-ID"))
	}
	if requestID == "" {
		requestID = strings.TrimSpace(c.Get("x-request-id"))
	}
	if requestID == "" {
		requestID = strings.TrimSpace(c.Get("x-requestid"))
	}
	if requestID == "" {
		if v := c.Locals("requestid"); v != nil {
			requestID = strings.TrimSpace(fmt.Sprint(v))
		}
	}

	logCallbackFailure := func(status int, reason, title, detail string, cause error) {
		args := []any{
			slog.String("request_id", requestID),
			slog.Int("status", status),
			slog.String("reason", strings.TrimSpace(reason)),
			slog.String("title", strings.TrimSpace(title)),
			slog.String("detail", strings.TrimSpace(detail)),
			slog.String("path", c.Path()),
			slog.String("mode", strings.TrimSpace(c.Query("mode"))),
			slog.String("state", strings.TrimSpace(c.Query("state"))),
			slog.String("microsoft_error", strings.TrimSpace(c.Query("error"))),
			slog.Bool("has_code", strings.TrimSpace(c.Query("code")) != ""),
		}
		if cause != nil {
			args = append(args, slog.Any("cause", cause))
		}
		slog.WarnContext(c.UserContext(), "microsoft callback failed", args...)
	}

	writeCallbackError := func(status int, title, detail, reason string, source *jsonapi.ErrorSource) error {
		logCallbackFailure(status, reason, title, detail, nil)
		if redirect := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirect != "" && shouldUseBrowserRedirect(c) {
			if redirectURL, parseErr := url.Parse(redirect); parseErr == nil {
				q := redirectURL.Query()
				q.Set("sso", "error")
				q.Set("status", strconv.Itoa(status))
				if strings.TrimSpace(reason) != "" {
					q.Set("reason", strings.TrimSpace(reason))
				}
				if strings.TrimSpace(title) != "" {
					q.Set("title", strings.TrimSpace(title))
				}
				redirectURL.RawQuery = q.Encode()
				return c.Redirect(redirectURL.String(), fiber.StatusFound)
			}
		}
		errObj := jsonapi.ErrorObject{
			Status: strconv.Itoa(status),
			Title:  title,
			Detail: detail,
			Source: source,
		}
		return writeAuthErrors(c, status, []jsonapi.ErrorObject{errObj})
	}

	code := strings.TrimSpace(c.Query("code"))
	state := strings.TrimSpace(c.Query("state"))
	if state == "" {
		return writeCallbackError(fiber.StatusBadRequest, "Invalid callback", "state is required", "invalid_callback", nil)
	}
	mode, err := h.svc.MicrosoftStateMode(c.UserContext(), state)
	if err != nil {
		logCallbackFailure(
			fiber.StatusBadRequest,
			"invalid_callback_state",
			"Invalid callback state",
			err.Error(),
			err,
		)
		return writeCallbackError(
			fiber.StatusBadRequest,
			"Invalid callback state",
			"an internal error occurred",
			"invalid_callback_state",
			&jsonapi.ErrorSource{Pointer: "/query/state"},
		)
	}
	if code == "" {
		ssoErr := strings.TrimSpace(c.Query("error"))
		if mode == "login_check" {
			h.svc.ClearMicrosoftState(c.UserContext(), state)
			if ssoErr != "" {
				urlStr, loginErr := h.svc.BeginMicrosoftLogin(c.UserContext())
				if loginErr != nil {
					return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
						Status: "401",
						Title:  "Microsoft session not active",
						Detail: "microsoft sso session is not active: " + ssoErr,
					}})
				}
				if shouldUseBrowserRedirect(c) {
					return c.Redirect(urlStr, fiber.StatusFound)
				}
				return response.Write(c, fiber.StatusUnauthorized, jsonapi.Resource{
					Type: "auth_microsoft_url",
					Attributes: map[string]any{
						"url":                  urlStr,
						"redirect_url":         urlStr,
						"interaction_required": true,
						"microsoft_error":      ssoErr,
					},
				})
			}
		}
		if mode == "login_check_strict" && isMicrosoftInteractionRequiredError(ssoErr) {
			h.svc.ClearMicrosoftState(c.UserContext(), state)
			urlStr, loginErr := h.svc.BeginMicrosoftLogin(c.UserContext())
			if loginErr != nil {
				return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
					Status: "401",
					Title:  "Microsoft session not active",
					Detail: "microsoft sso session is not active: " + ssoErr,
				}})
			}
			if shouldUseBrowserRedirect(c) {
				return c.Redirect(urlStr, fiber.StatusFound)
			}
			return response.Write(c, fiber.StatusUnauthorized, jsonapi.Resource{
				Type: "auth_microsoft_url",
				Attributes: map[string]any{
					"url":                  urlStr,
					"redirect_url":         urlStr,
					"interaction_required": true,
					"microsoft_error":      ssoErr,
				},
			})
		}
		if mode == "login_check_strict" {
			h.svc.ClearMicrosoftState(c.UserContext(), state)
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Microsoft session not active",
				Detail: "microsoft sso session is not active",
			}})
		}
		return writeCallbackError(fiber.StatusBadRequest, "Invalid callback", "code is required", "missing_code", nil)
	}

	if mode == "reauth" {
		reauthToken, purpose, err := h.svc.CompleteMicrosoftReauth(c.UserContext(), code, state)
		if err != nil {
			status := fiber.StatusBadRequest
			title := "SSO reauth failed"
			if errors.Is(err, service.ErrInvalidSSOState) {
				title = "Invalid callback state"
			}
			if errors.Is(err, service.ErrInvalidSSOReauth) {
				status = fiber.StatusUnauthorized
			}
			reason := "sso_reauth_failed"
			if status == fiber.StatusUnauthorized {
				reason = "invalid_sso_reauth"
			}
			logCallbackFailure(status, reason, title, err.Error(), err)
			return writeCallbackError(status, title, "an internal error occurred", reason, nil)
		}
		if redirect := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirect != "" && shouldUseBrowserRedirect(c) {
			redirectURL, parseErr := url.Parse(redirect)
			if parseErr == nil {
				q := redirectURL.Query()
				q.Set("sso_reauth", "1")
				q.Set("purpose", purpose)
				q.Set("reauth_token", reauthToken)
				redirectURL.RawQuery = q.Encode()
				return c.Redirect(redirectURL.String(), fiber.StatusFound)
			}
		}
		return response.Write(c, fiber.StatusOK, jsonapi.Resource{
			Type: "auth_microsoft_reauth",
			Attributes: map[string]any{
				"purpose":      purpose,
				"reauth_token": reauthToken,
			},
		})
	}

	if mode == "link" {
		identity, err := h.svc.CompleteMicrosoftLink(c.UserContext(), code, state)
		if err != nil {
			logCallbackFailure(fiber.StatusBadRequest, "sso_link_failed", "SSO link failed", err.Error(), err)
			switch {
			case errors.Is(err, service.ErrInvalidSSOState):
				return writeCallbackError(
					fiber.StatusBadRequest,
					"Invalid callback state",
					"an internal error occurred",
					"invalid_callback_state",
					&jsonapi.ErrorSource{Pointer: "/query/state"},
				)
			case errors.Is(err, service.ErrUserNotFound):
				return writeCallbackError(fiber.StatusNotFound, "User not found", "user not found", "user_not_found", nil)
			case errors.Is(err, service.ErrSSOAlreadyLinked):
				return writeCallbackError(
					fiber.StatusConflict,
					"SSO already linked",
					"user already has an sso mapping",
					"sso_already_linked",
					nil,
				)
			case errors.Is(err, service.ErrSSOLinkedToAnotherUser):
				return writeCallbackError(
					fiber.StatusConflict,
					"SSO linked to another user",
					"this sso account is already linked to another user",
					"sso_linked_to_another_user",
					nil,
				)
			default:
				return writeCallbackError(fiber.StatusBadRequest, "SSO link failed", "an internal error occurred", "sso_link_failed", nil)
			}
		}
		if redirect := strings.TrimSpace(h.svc.SSOSuccessRedirectURL()); redirect != "" && shouldUseBrowserRedirect(c) {
			redirectURL, parseErr := url.Parse(redirect)
			if parseErr == nil {
				q := redirectURL.Query()
				q.Set("sso_link", "1")
				q.Set("provider", identity.Provider)
				redirectURL.RawQuery = q.Encode()
				return c.Redirect(redirectURL.String(), fiber.StatusFound)
			}
		}
		return response.Write(c, fiber.StatusOK, jsonapi.Resource{
			Type: "auth_sso_link",
			Attributes: map[string]any{
				"provider":         identity.Provider,
				"provider_subject": identity.ProviderSubject,
				"email":            identity.Email,
			},
		})
	}

	if mode == "login_check_strict" {
		if _, _, err := h.svc.CompleteMicrosoftLogin(c.UserContext(), code, state); err != nil {
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Microsoft session not active",
				Detail: "microsoft sso session is not active",
			}})
		}
		return response.Write(c, fiber.StatusOK, jsonapi.Resource{
			Type: "auth_microsoft_session",
			Attributes: map[string]any{
				"active": true,
			},
		})
	}

	identity, microsoftSessionID, err := h.svc.CompleteMicrosoftLogin(c.UserContext(), code, state)
	if err != nil {
		logCallbackFailure(fiber.StatusBadRequest, "sso_login_failed", "SSO login failed", err.Error(), err)
		switch {
		case errors.Is(err, service.ErrInvalidSSOState):
			return writeCallbackError(
				fiber.StatusBadRequest,
				"Invalid callback state",
				"an internal error occurred",
				"invalid_callback_state",
				&jsonapi.ErrorSource{Pointer: "/query/state"},
			)
		case errors.Is(err, service.ErrSSONotLinked):
			return writeCallbackError(
				fiber.StatusUnauthorized,
				"SSO not linked",
				"sso identity is not linked to any user",
				"sso_not_linked",
				nil,
			)
		case errors.Is(err, service.ErrSSOEmailNotFound):
			return writeCallbackError(
				fiber.StatusUnauthorized,
				"SSO email not found",
				"sso account email is not registered",
				"sso_email_not_found",
				nil,
			)
		case errors.Is(err, service.ErrUserNotFound):
			return writeCallbackError(fiber.StatusNotFound, "User not found", "linked user is not found", "user_not_found", nil)
		default:
			return writeCallbackError(fiber.StatusBadRequest, "SSO login failed", "an internal error occurred", "sso_login_failed", nil)
		}
	}

	// Issue cookies and redirect to FE if configured
	user, err := h.svc.GetUserByID(c.UserContext(), identity.UserID)
	if err == nil && user != nil {
		tokens, err := h.svc.IssueTokensWithClientForProviderAndMicrosoftSID(
			c.UserContext(),
			user,
			c.Get(fiber.HeaderUserAgent),
			c.IP(),
			"microsoft",
			microsoftSessionID,
		)
		if err == nil {
			setAuthCookiesForProvider(c, h.svc, tokens, "microsoft")
		}
	}

	if redirect := h.svc.SSOSuccessRedirectURL(); redirect != "" {
		if shouldUseBrowserRedirect(c) {
			return c.Redirect(redirect, fiber.StatusFound)
		}
	}

	id, _ := uuidv7.BytesToString(identity.ID)
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_identity",
		ID:   id,
		Attributes: map[string]any{
			"provider":         identity.Provider,
			"provider_subject": identity.ProviderSubject,
			"email":            identity.Email,
		},
	})
}

func isMicrosoftInteractionRequiredError(raw string) bool {
	code := strings.ToLower(strings.TrimSpace(raw))
	if code == "" {
		return false
	}
	return strings.Contains(code, "aadsts16000") ||
		code == "login_required" ||
		code == "interaction_required" ||
		code == "account_selection_required" ||
		code == "consent_required"
}

// SSOLink godoc
// @Summary Link Microsoft SSO to current user
// @Description Link Microsoft SSO identity to the authenticated user. SSO login will only work after successful link.
// @Tags Auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.SSOLinkRequest true "JSON:API SSO link request"
// @Success 200 {object} dto.SSOLinkResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Router /api/v1/auth/sso/link [post]
func (h *AuthHandler) SSOLink(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	var req dto.SSOLinkRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	identity, err := h.svc.LinkSSOForUser(c.UserContext(), userID, req.Data.Attributes.Provider, req.Data.Attributes.Code)
	if err != nil {
		switch {
		case errors.Is(err, service.ErrUserNotFound):
			return writeAuthErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
				Status: "404",
				Title:  "User not found",
				Detail: "user not found",
			}})
		case errors.Is(err, service.ErrSSOAlreadyLinked):
			return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
				Status: "409",
				Title:  "SSO already linked",
				Detail: "user already has an sso mapping",
			}})
		case errors.Is(err, service.ErrSSOLinkedToAnotherUser):
			return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
				Status: "409",
				Title:  "SSO linked to another user",
				Detail: "this sso account is already linked to another user",
			}})
		case errors.Is(err, service.ErrUnsupportedSSOProvider):
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "Unsupported SSO provider",
				Detail: "unsupported sso provider",
			}})
		default:
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "SSO link failed",
				Detail: "an internal error occurred",
			}})
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_sso_link",
		Attributes: map[string]any{
			"provider":         identity.Provider,
			"provider_subject": identity.ProviderSubject,
			"email":            identity.Email,
		},
	})
}

// SSOUnlink godoc
// @Summary Unlink Microsoft SSO from current user
// @Description Remove Microsoft SSO mapping from authenticated user.
// @Tags Auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.SSOUnlinkRequest true "JSON:API SSO unlink request"
// @Success 200 {object} dto.SSOUnlinkResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/auth/sso/unlink [delete]
func (h *AuthHandler) SSOUnlink(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	var req dto.SSOUnlinkRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	if err := h.svc.UnlinkSSOForUser(c.UserContext(), userID, req.Data.Attributes.Provider); err != nil {
		switch {
		case errors.Is(err, service.ErrUserNotFound):
			return writeAuthErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
				Status: "404",
				Title:  "User not found",
				Detail: "user not found",
			}})
		case errors.Is(err, service.ErrSSONotLinked):
			return writeAuthErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
				Status: "404",
				Title:  "SSO not linked",
				Detail: "sso is not linked to this user",
			}})
		case errors.Is(err, service.ErrUnsupportedSSOProvider):
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "Unsupported SSO provider",
				Detail: "unsupported sso provider",
			}})
		default:
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "SSO unlink failed",
				Detail: "an internal error occurred",
			}})
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_sso_unlink",
		Attributes: map[string]any{
			"provider": req.Data.Attributes.Provider,
			"unlinked": true,
		},
	})
}

func shouldUseBrowserRedirect(c *fiber.Ctx) bool {
	if strings.EqualFold(strings.TrimSpace(c.Get("X-Requested-With")), "XMLHttpRequest") {
		return false
	}

	mode := strings.ToLower(strings.TrimSpace(c.Get("Sec-Fetch-Mode")))
	dest := strings.ToLower(strings.TrimSpace(c.Get("Sec-Fetch-Dest")))
	if dest == "empty" || mode == "cors" || mode == "same-origin" {
		return false
	}

	accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept)))
	return !strings.Contains(accept, "application/json")
}

// WebAuthnRegisterBegin godoc
// @Summary Begin WebAuthn registration
// @Description Creates WebAuthn registration options for the authenticated user.
// @Tags Auth - WebAuthn
// @Accept json
// @Produce json
// @Param request body dto.WebAuthnRegisterBeginRequest true "JSON:API WebAuthn register begin request"
// @Success 200 {object} dto.WebAuthnBeginResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/webauthn/register/begin [post]
func (h *AuthHandler) WebAuthnRegisterBegin(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	var req dto.WebAuthnRegisterBeginRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	options, challengeToken, err := h.svc.BeginWebAuthnRegistration(c.UserContext(), userID)
	if err != nil {
		switch {
		case errors.Is(err, service.ErrWebAuthnNotConfigured):
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "WebAuthn not configured",
				Detail: "webauthn is not configured",
			}})
		default:
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "WebAuthn begin failed",
				Detail: "an internal error occurred",
			}})
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_webauthn_register_options",
		Attributes: map[string]any{
			"challenge_token":  challengeToken,
			"challenge_ttl_ms": h.svc.WebAuthnChallengeTTL().Milliseconds(),
			"public_key":       options.Response,
		},
	})
}

// WebAuthnRegisterFinish godoc
// @Summary Finish WebAuthn registration
// @Description Verifies WebAuthn registration response and stores the credential.
// @Tags Auth - WebAuthn
// @Accept json
// @Produce json
// @Param request body dto.WebAuthnRegisterFinishRequest true "JSON:API WebAuthn register finish request"
// @Success 200 {object} dto.AuthSessionsResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/webauthn/register/finish [post]
func (h *AuthHandler) WebAuthnRegisterFinish(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	var req dto.WebAuthnRegisterFinishRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	if err := h.svc.FinishWebAuthnRegistration(c.UserContext(), userID, req.Data.Attributes.ChallengeToken, req.Data.Attributes.Credential, req.Data.Attributes.Name); err != nil {
		switch {
		case errors.Is(err, service.ErrWebAuthnNotConfigured):
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "WebAuthn not configured",
				Detail: "webauthn is not configured",
			}})
		case errors.Is(err, service.ErrWebAuthnChallengeInvalid):
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Invalid challenge",
				Detail: "challenge token is invalid or expired",
			}})
		case errors.Is(err, service.ErrWebAuthnPayloadInvalid):
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "credential payload is invalid",
				Source: &jsonapi.ErrorSource{
					Pointer: "/data/attributes/credential",
				},
			}})
		case errors.Is(err, service.ErrWebAuthnCredentialExists):
			return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
				Status: "409",
				Title:  "Credential already exists",
				Detail: "credential has already been registered",
			}})
		case errors.Is(err, service.ErrWebAuthnVerificationFailed):
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Verification failed",
				Detail: "unable to verify webauthn credential",
			}})
		default:
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "WebAuthn registration failed",
				Detail: "an internal error occurred",
			}})
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_webauthn_register",
		Attributes: map[string]any{
			"registered": true,
		},
	})
}

// WebAuthnLoginBegin godoc
// @Summary Begin WebAuthn login
// @Description Creates WebAuthn assertion options for a user email.
// @Tags Auth - WebAuthn
// @Accept json
// @Produce json
// @Param request body dto.WebAuthnLoginBeginRequest true "JSON:API WebAuthn login begin request"
// @Success 200 {object} dto.WebAuthnBeginResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/webauthn/login/begin [post]
func (h *AuthHandler) WebAuthnLoginBegin(c *fiber.Ctx) error {
	var req dto.WebAuthnLoginBeginRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	options, challengeToken, err := h.svc.BeginWebAuthnLogin(c.UserContext(), req.Data.Attributes.Email)
	if err != nil {
		switch {
		case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrEmailNotVerified):
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Unauthorized",
				Detail: "invalid credentials",
			}})
		case errors.Is(err, service.ErrWebAuthnCredentialUnavailable):
			return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
				Status: "409",
				Title:  "No WebAuthn credential",
				Detail: "webauthn credential is not configured for this account",
			}})
		case errors.Is(err, service.ErrWebAuthnNotConfigured):
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "WebAuthn not configured",
				Detail: "webauthn is not configured",
			}})
		default:
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "WebAuthn begin failed",
				Detail: "an internal error occurred",
			}})
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_webauthn_login_options",
		Attributes: map[string]any{
			"challenge_token":  challengeToken,
			"challenge_ttl_ms": h.svc.WebAuthnChallengeTTL().Milliseconds(),
			"public_key":       options.Response,
		},
	})
}

// WebAuthnLoginFinish godoc
// @Summary Finish WebAuthn login
// @Description Verifies WebAuthn assertion and issues auth cookies.
// @Tags Auth - WebAuthn
// @Accept json
// @Produce json
// @Param request body dto.WebAuthnLoginFinishRequest true "JSON:API WebAuthn login finish request"
// @Success 200 {object} dto.TokenResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/webauthn/login/finish [post]
func (h *AuthHandler) WebAuthnLoginFinish(c *fiber.Ctx) error {
	var req dto.WebAuthnLoginFinishRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	user, err := h.svc.FinishWebAuthnLogin(c.UserContext(), req.Data.Attributes.ChallengeToken, req.Data.Attributes.Credential)
	if err != nil {
		switch {
		case errors.Is(err, service.ErrWebAuthnChallengeInvalid):
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Invalid challenge",
				Detail: "challenge token is invalid or expired",
			}})
		case errors.Is(err, service.ErrWebAuthnPayloadInvalid):
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "credential payload is invalid",
				Source: &jsonapi.ErrorSource{
					Pointer: "/data/attributes/credential",
				},
			}})
		case errors.Is(err, service.ErrWebAuthnVerificationFailed), errors.Is(err, service.ErrInvalidCredentials):
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Unauthorized",
				Detail: "invalid webauthn assertion",
			}})
		case errors.Is(err, service.ErrWebAuthnCredentialUnavailable):
			return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
				Status: "409",
				Title:  "No WebAuthn credential",
				Detail: "webauthn credential is not configured for this account",
			}})
		case errors.Is(err, service.ErrWebAuthnNotConfigured):
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "WebAuthn not configured",
				Detail: "webauthn is not configured",
			}})
		default:
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "WebAuthn login failed",
				Detail: "an internal error occurred",
			}})
		}
	}

	tokens, err := h.svc.IssueTokensWithClientAndDeviceType(
		c.UserContext(),
		user,
		c.Get(fiber.HeaderUserAgent),
		c.IP(),
		readDeviceTypeHint(c),
	)
	if err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Token issue failed",
			Detail: "an internal error occurred",
		}})
	}
	setAuthCookies(c, h.svc, tokens)

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_tokens",
		Attributes: map[string]any{
			"access_expires_in":  int64(h.svc.AccessTTL().Seconds()),
			"refresh_expires_in": int64(tokens.RefreshTTL.Seconds()),
		},
	})
}

// WebAuthnResetSetup godoc
// @Summary Reset WebAuthn setup
// @Description Removes all registered WebAuthn credentials so the user can re-setup passkeys.
// @Description For method=pin, obtain token from /auth/pin/verify with action=1f7d457e-0a19-4a36-8d9f-2c66fd2369ab and send it in X-PIN-Verification-Token header.
// @Tags Auth - WebAuthn
// @Accept json
// @Produce json
// @Param request body dto.WebAuthnResetSetupRequest true "JSON:API WebAuthn reset setup request"
// @Success 200 {object} dto.AuthSessionDeleteResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 429 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/webauthn/reset-setup [post]
func (h *AuthHandler) WebAuthnResetSetup(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	var req dto.WebAuthnResetSetupRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method))
	switch method {
	case "pin":
		sessionID := ""
		if accessToken := readValidAccessToken(c, h.svc); accessToken != "" {
			sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
			if err != nil {
				return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
					Status: "401",
					Title:  "Unauthorized",
					Detail: "invalid or expired token",
				}})
			}
			sessionID = sid
		}
		token := c.Get(pinVerificationHeader)
		if err := h.svc.ConsumeSecurityPINActionTokenInSession(c.UserContext(), userID, sharedconst.PinActionWebAuthnResetSetup, token, sessionID); err != nil {
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "PIN verification required",
				Detail: "valid PIN action token is required",
				Source: &jsonapi.ErrorSource{Pointer: "/headers/X-PIN-Verification-Token"},
			}})
		}
		if err := h.svc.DeleteWebAuthnSetup(c.UserContext(), userID); err != nil {
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "WebAuthn reset setup failed",
				Detail: "an internal error occurred",
			}})
		}
		return response.Write(c, fiber.StatusOK, jsonapi.Resource{
			Type: "auth_webauthn_reset_setup",
			Attributes: map[string]any{
				"reset": true,
			},
		})
	case "password":
		if req.Data.Attributes.Password == "" {
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "password is required when method is password",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"},
			}})
		}
	default:
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "method must be one of: pin, password",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
		}})
	}

	if err := h.svc.ResetWebAuthnSetup(c.UserContext(), userID, method, "", req.Data.Attributes.Password); err != nil {
		switch {
		case errors.Is(err, service.ErrInvalidReauthMethod):
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "method must be one of: pin, password",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
			}})
		case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidSecurityPIN):
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Unauthorized",
				Detail: "invalid authentication factor",
			}})
		case errors.Is(err, service.ErrSecurityPINNotSet):
			return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
				Status: "409",
				Title:  "PIN not configured",
				Detail: "security PIN is not set",
			}})
		case errors.Is(err, service.ErrSecurityPINAttemptLimit):
			return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{
				Status: "423",
				Title:  "PIN blocked",
				Detail: "security PIN is blocked. reset PIN is required.",
			}})
		case errors.Is(err, service.ErrSecurityPINLocked), errors.Is(err, service.ErrSecurityPINResetRequired):
			return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{
				Status: "423",
				Title:  "PIN blocked",
				Detail: "security PIN is blocked. reset PIN is required.",
			}})
		case errors.Is(err, service.ErrInvalidToken):
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Unauthorized",
				Detail: "user not found",
			}})
		default:
			return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "WebAuthn reset setup failed",
				Detail: "an internal error occurred",
			}})
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_webauthn_reset_setup",
		Attributes: map[string]any{
			"reset": true,
		},
	})
}

// Me godoc
// @Summary Get current user
// @Tags Auth
// @Produce json
// @Success 200 {object} dto.AuthCurrentUserResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/auth/me [get]
func (h *AuthHandler) Me(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	user, err := h.svc.GetUserByID(c.UserContext(), userID)
	if err != nil || user == nil {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "user not found",
		}})
	}

	id, _ := uuidv7.BytesToString(user.ID)
	var emailVerifiedAt string
	if user.EmailVerifiedAt != nil {
		emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
	}
	totpEnabled, _ := h.svc.IsTOTPEnabled(c.UserContext(), user.ID)
	isPreviouslyConfigured, _ := h.svc.IsTOTPPreviouslyConfigured(c.UserContext(), user.ID)
	webauthnConfigured, _ := h.svc.IsWebAuthnConfigured(c.UserContext(), user.ID)
	linkedSSO, _ := h.svc.IsSSOLinked(c.UserContext(), user.ID)
	microsoftScope, _ := h.svc.GetMicrosoftScopeByUserID(c.UserContext(), user.ID)
	pinConfigured := len(user.SecurityPINHash) > 0
	pinBlocked := h.svc.IsSecurityPINBlocked(c.UserContext(), user.ID)
	loginMethod := "unknown"
	authProvider := "unknown"
	if accessToken := readValidAccessToken(c, h.svc); accessToken != "" {
		if method, provider, err := h.svc.GetSessionAuthContextFromAccessToken(c.UserContext(), accessToken); err == nil {
			loginMethod = method
			authProvider = provider
		}
	}
	_, roles := authRoleResponseData(h.svc, c, user)
	permissions := permissionAttrs(h.svc, c, user.ID)
	permissionModules := permissionModuleGroups(h.svc, c, user.ID)
	foto := authUserFoto(c.UserContext(), h.storage, user)
	dulBases := make([]map[string]any, 0)
	if h.contactSvc != nil {
		contactRow, contactErr := h.contactSvc.GetContactByID(c.UserContext(), user.ID)
		if contactErr == nil && isPilotRoleContact(contactRow) {
			if parsed := contactDULBases(contactRow.BaseRolesRaw); len(parsed) > 0 {
				dulBases = parsed
			}
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "user",
		ID:   id,
		Attributes: map[string]any{
			"email":                    user.Email,
			"email_sso":                derefString(user.SSOEmail),
			"first_name":               user.FirstName,
			"last_name":                user.LastName,
			"mobile_phone":             user.MobilePhone,
			"timezone":                 tzpkg.WithDefault(user.Timezone),
			"foto":                     foto,
			"email_verified_at":        emailVerifiedAt,
			"roles":                    roles,
			"permissions":              permissions,
			"permission_modules":       permissionModules,
			"totp_enabled":             totpEnabled,
			"is_previously_configured": isPreviouslyConfigured,
			"webauthn_configured":      webauthnConfigured,
			"linked_sso":               linkedSSO,
			"pin_configured":           pinConfigured,
			"pin_blocked":              pinBlocked,
			"login_method":             loginMethod,
			"auth_provider":            authProvider,
			"microsoft_scope":          microsoftScope,
			"dul_bases":                dulBases,
		},
	})
}

// UpdateTimezone godoc
// @Summary Update current user timezone
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body dto.TimezoneUpdateRequest true "JSON:API timezone update request"
// @Success 200 {object} dto.AuthCurrentUserResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/me/timezone [patch]
func (h *AuthHandler) UpdateTimezone(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	var req dto.TimezoneUpdateRequest
	if err := c.BodyParser(&req); err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: "an internal error occurred",
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	normalizedTimezone, err := tzpkg.Normalize(req.Data.Attributes.Timezone)
	if err != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, timezoneValidationError("/data/attributes/timezone", err))
	}
	if _, err := h.svc.UpdateUserTimezone(c.UserContext(), userID, normalizedTimezone); err != nil {
		if errors.Is(err, tzpkg.ErrEmpty) || errors.Is(err, tzpkg.ErrInvalid) {
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, timezoneValidationError("/data/attributes/timezone", err))
		}
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Update failed",
			Detail: "an internal error occurred",
		}})
	}

	user, err := h.svc.GetUserByID(c.UserContext(), userID)
	if err != nil || user == nil {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "user not found",
		}})
	}

	id, _ := uuidv7.BytesToString(user.ID)
	var emailVerifiedAt string
	if user.EmailVerifiedAt != nil {
		emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
	}

	_, roles := authRoleResponseData(h.svc, c, user)
	permissions := permissionAttrs(h.svc, c, user.ID)
	permissionModules := permissionModuleGroups(h.svc, c, user.ID)

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "user",
		ID:   id,
		Attributes: map[string]any{
			"email":              user.Email,
			"first_name":         user.FirstName,
			"last_name":          user.LastName,
			"mobile_phone":       user.MobilePhone,
			"timezone":           tzpkg.WithDefault(user.Timezone),
			"email_verified_at":  emailVerifiedAt,
			"roles":              roles,
			"permissions":        permissions,
			"permission_modules": permissionModules,
		},
	})
}

// Refresh godoc
// @Summary Refresh access token
// @Tags Auth - Refresh
// @Produce json
// @Success 200 {object} dto.TokenResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/auth/refresh [post]
func (h *AuthHandler) Refresh(c *fiber.Ctx) error {
	var (
		tokens service.TokenPair
		err    error
	)
	refreshCandidates := service.CookieValues(c.Get(fiber.HeaderCookie), h.svc.RefreshCookieName())
	if len(refreshCandidates) == 0 {
		refreshCandidates = []string{strings.TrimSpace(c.Cookies(h.svc.RefreshCookieName()))}
	}
	for _, refreshToken := range refreshCandidates {
		tokens, err = h.svc.RefreshTokensWithClientAndDeviceType(
			c.UserContext(),
			refreshToken,
			c.Get(fiber.HeaderUserAgent),
			c.IP(),
			readDeviceTypeHint(c),
		)
		if err == nil {
			break
		}
	}
	if err != nil {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "an internal error occurred",
		}})
	}
	setAuthCookies(c, h.svc, tokens)

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_tokens",
		Attributes: map[string]any{
			"access_expires_in":  int64(h.svc.AccessTTL().Seconds()),
			"refresh_expires_in": int64(tokens.RefreshTTL.Seconds()),
			"microsoft": map[string]any{
				"status": "checked_during_refresh",
				"token":  map[string]any{},
			},
		},
	})
}

// Logout godoc
// @Summary Logout (revoke refresh)
// @Tags Auth - Refresh
// @Produce json
// @Success 200 {object} dto.VerifyEmailResponse
// @Router /api/v1/auth/logout [post]
func (h *AuthHandler) Logout(c *fiber.Ctx) error {
	accessToken := readValidAccessToken(c, h.svc)
	refreshToken := readValidRefreshToken(c, h.svc)
	if accessToken != "" {
		if userID, err := h.svc.ParseAccessToken(c.UserContext(), accessToken); err == nil && len(userID) == 16 {
			_ = h.svc.RevokeAllUserSessions(c.UserContext(), userID)
		}
	}
	_ = h.svc.RevokeRefreshToken(c.UserContext(), refreshToken)
	clearAuthCookies(c, h.svc)
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_logout",
		Attributes: map[string]any{
			"logged_out": true,
		},
	})
}

// MicrosoftRefresh godoc
// @Summary Refresh Microsoft access token
// @Tags Auth - SSO
// @Produce json
// @Success 200 {object} dto.TokenResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 428 {object} dto.ErrorResponse
// @Router /api/v1/auth/microsoft/refresh [post]
func (h *AuthHandler) MicrosoftRefresh(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}
	tokenSet, err := h.svc.RefreshMicrosoftAccessToken(c.UserContext(), userID)
	if err != nil {
		if errors.Is(err, service.ErrMicrosoftReauthRequired) {
			return writeAuthErrors(c, fiber.StatusPreconditionRequired, []jsonapi.ErrorObject{{
				Status: "428",
				Title:  "Reauthentication required",
				Detail: "microsoft session requires reauthentication",
			}})
		}
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "an internal error occurred",
		}})
	}
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_microsoft_tokens",
		Attributes: map[string]any{
			"access_token": tokenSet.AccessToken,
			"token_type":   tokenSet.TokenType,
			"scope":        tokenSet.Scope,
			"expires_in":   tokenSet.ExpiresIn,
		},
	})
}

// MicrosoftLogout godoc
// @Summary Logout Microsoft OAuth session
// @Tags Auth - SSO
// @Produce json
// @Success 200 {object} dto.VerifyEmailResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/auth/microsoft/logout [post]
func (h *AuthHandler) MicrosoftLogout(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}
	if err := h.svc.LogoutMicrosoft(c.UserContext(), userID); err != nil {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "an internal error occurred",
		}})
	}
	_ = h.svc.RevokeRefreshToken(c.UserContext(), readValidRefreshToken(c, h.svc))
	clearAuthCookies(c, h.svc)
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_microsoft_logout",
		Attributes: map[string]any{
			"logged_out": true,
		},
	})
}

// MicrosoftFrontChannelLogout godoc
// @Summary Microsoft front-channel logout callback
// @Description OIDC front-channel logout endpoint. Configure this URL as the "Front-channel logout URL" in Microsoft Entra. Microsoft loads it in a hidden iframe when the user signs out at the IdP (or from another RP). It clears local auth cookies and revokes the server-side session plus the stored Microsoft refresh token for the browser that owns the request. It never redirects and always returns 200 so the IdP logout flow can complete.
// @Tags Auth - SSO
// @Produce html
// @Success 200 {string} string "logged out"
// @Router /api/v1/auth/microsoft/front-channel-logout [get]
func (h *AuthHandler) MicrosoftFrontChannelLogout(c *fiber.Ctx) error {
	// This endpoint is loaded by the IdP inside an iframe. Responses must not be
	// cached and must not redirect (a redirect would break the IdP logout flow).
	c.Set(fiber.HeaderCacheControl, "no-store, max-age=0")
	c.Set(fiber.HeaderPragma, "no-cache")
	c.Set(fiber.HeaderXFrameOptions, "")
	c.Set("Content-Security-Policy", "frame-ancestors https://login.microsoftonline.com https://*.microsoftonline.com https://login.live.com")

	// OIDC front-channel logout MAY include iss (issuer) and sid (session id). When an
	// issuer is supplied it must look like a Microsoft issuer; otherwise ignore the
	// request body but still answer 200 as the spec requires.
	if iss := strings.TrimSpace(c.Query("iss")); iss != "" && !isMicrosoftIssuer(iss) {
		return sendFrontChannelLogoutOK(c)
	}

	revoked := false
	if sid := strings.TrimSpace(c.Query("sid")); sid != "" {
		if err := h.svc.RevokeMicrosoftSessionBySID(c.UserContext(), sid); err == nil {
			revoked = true
		}
	}

	// Cookie-based revocation remains as a fallback for browsers that still
	// send the app cookies inside the front-channel iframe.
	if accessToken := readValidAccessToken(c, h.svc); accessToken != "" {
		if userID, err := h.svc.ParseAccessToken(c.UserContext(), accessToken); err == nil && len(userID) == 16 {
			_ = h.svc.RevokeAllUserSessions(c.UserContext(), userID)
			_ = h.svc.LogoutMicrosoft(c.UserContext(), userID)
			revoked = true
		}
	}
	if refreshToken := readValidRefreshToken(c, h.svc); refreshToken != "" {
		_ = h.svc.RevokeRefreshToken(c.UserContext(), refreshToken)
		revoked = true
	}
	clearAuthCookiesForFrontChannel(c, h.svc)
	_ = revoked
	return sendFrontChannelLogoutOK(c)
}

func sendFrontChannelLogoutOK(c *fiber.Ctx) error {
	c.Set(fiber.HeaderContentType, "text/html; charset=utf-8")
	return c.Status(fiber.StatusOK).SendString(`<!doctype html><meta charset="utf-8"><title>Logged out</title>`)
}

// isMicrosoftIssuer reports whether iss is a plausible Microsoft Entra issuer URL.
func isMicrosoftIssuer(iss string) bool {
	iss = strings.TrimSpace(iss)
	if iss == "" {
		return false
	}
	u, err := url.Parse(iss)
	if err != nil || u.Scheme != "https" {
		return false
	}
	host := strings.ToLower(u.Hostname())
	return host == "login.microsoftonline.com" ||
		host == "sts.windows.net" ||
		strings.HasSuffix(host, ".microsoftonline.com") ||
		strings.HasSuffix(host, ".windows.net")
}

// Sessions godoc
// @Summary List active sessions
// @Description Lists all active sessions/devices for current user.
// @Tags Auth - Refresh
// @Produce json
// @Success 200 {object} dto.AuthSessionsResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/auth/sessions [get]
func (h *AuthHandler) Sessions(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	currentSessionID := ""
	if accessToken := readValidAccessToken(c, h.svc); strings.TrimSpace(accessToken) != "" {
		sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
		if err == nil {
			currentSessionID = sid
		}
	}

	sessions, err := h.svc.ListUserSessions(c.UserContext(), userID, currentSessionID)
	if err != nil {
		return writeAuthErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "List sessions failed",
			Detail: "an internal error occurred",
		}})
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_sessions",
		Attributes: map[string]any{
			"sessions": sessions,
		},
	})
}

// DeleteSession godoc
// @Summary Delete one session
// @Description Revokes one session/device immediately. Requires PIN action token (header X-PIN-Verification-Token) with action=3d7d1d5f-ec48-4eca-a7ea-37f4f7927d06.
// @Tags Auth - Refresh
// @Produce json
// @Param session_id path string true "Session ID"
// @Success 200 {object} dto.AuthSessionDeleteResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/sessions/{session_id} [delete]
func (h *AuthHandler) DeleteSession(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	sessionID := strings.TrimSpace(c.Params("session_id"))
	if _, err := uuidv7.ParseString(sessionID); err != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "session_id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/session_id"},
		}})
	}

	if err := h.svc.RevokeUserSession(c.UserContext(), userID, sessionID); err != nil {
		switch {
		case errors.Is(err, service.ErrSessionNotFound):
			return writeHandlerErrorDef(c, "AuthHandler.DeleteSession.SessionNotFound", apperrorsx.ErrDataNotFound, err)
		case errors.Is(err, service.ErrInvalidToken):
			return writeHandlerErrorDef(c, "AuthHandler.DeleteSession.InvalidToken", apperrorsx.ErrUserUnauthorized, err)
		default:
			return writeHandlerErrorDefWithStatus(c, "AuthHandler.DeleteSession", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
		}
	}

	if accessToken := readValidAccessToken(c, h.svc); strings.TrimSpace(accessToken) != "" {
		sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
		if err == nil && sid == sessionID {
			clearAuthCookies(c, h.svc)
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_session_delete",
		Attributes: map[string]any{
			"deleted": true,
		},
	})
}

// TOTPSetup godoc
// @Summary Setup TOTP
// @Description Generates secret and otpauth URL. TOTP is NOT enabled until confirmed.
// @Tags Auth - TOTP
// @Accept json
// @Produce json
// @Param request body dto.TOTPSetupRequest true "JSON:API TOTP setup request"
// @Success 200 {object} dto.TOTPSetupResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/totp/setup [post]
func (h *AuthHandler) TOTPSetup(c *fiber.Ctx) error {
	authUserID := actorUserID(c)
	if len(authUserID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	var req dto.TOTPSetupRequest
	if err := c.BodyParser(&req); err != nil {
		return writeHandlerErrorDef(c, "AuthHandler.TOTPSetup.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
	if err != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "user_id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
		}})
	}
	if !bytes.Equal(userID, authUserID) {
		return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
			Status: "403",
			Title:  "Forbidden",
			Detail: "user_id does not match authenticated user",
		}})
	}

	user, err := h.svc.GetUserByID(c.UserContext(), authUserID)
	if err != nil || user == nil {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "authenticated user not found",
		}})
	}

	secret, otpURL, issuer, err := h.svc.SetupTOTP(c.UserContext(), authUserID, user.Email)
	if err != nil {
		return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPSetup", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_totp_setup",
		Attributes: map[string]any{
			"secret":      secret,
			"otpauth_url": otpURL,
			"issuer":      issuer,
		},
	})
}

// TOTPConfirm godoc
// @Summary Confirm TOTP setup
// @Description Validates initial TOTP code and enables TOTP for future logins.
// @Tags Auth - TOTP
// @Accept json
// @Produce json
// @Param request body dto.TOTPConfirmRequest true "JSON:API TOTP confirm request"
// @Success 200 {object} dto.VerifyEmailResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/totp/confirm [post]
func (h *AuthHandler) TOTPConfirm(c *fiber.Ctx) error {
	authUserID := actorUserID(c)
	if len(authUserID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	var req dto.TOTPConfirmRequest
	if err := c.BodyParser(&req); err != nil {
		return writeHandlerErrorDef(c, "AuthHandler.TOTPConfirm.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
	if err != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "user_id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
		}})
	}
	if !bytes.Equal(userID, authUserID) {
		return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
			Status: "403",
			Title:  "Forbidden",
			Detail: "user_id does not match authenticated user",
		}})
	}

	ok, err := h.svc.ConfirmTOTP(c.UserContext(), authUserID, strings.TrimSpace(req.Data.Attributes.Code))
	if err != nil || !ok {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Invalid TOTP",
			Detail: "code is invalid or expired",
		}})
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_totp_confirm",
		Attributes: map[string]any{
			"confirmed": true,
		},
	})
}

// TOTPVerify godoc
// @Summary Verify TOTP code
// @Description Completes login when TOTP is enabled and returns auth cookies.
// @Tags Auth - TOTP
// @Accept json
// @Produce json
// @Param request body dto.TOTPVerifyRequest true "JSON:API TOTP verify request"
// @Success 200 {object} dto.TokenResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/totp/verify [post]
func (h *AuthHandler) TOTPVerify(c *fiber.Ctx) error {
	var req dto.TOTPVerifyRequest
	if err := c.BodyParser(&req); err != nil {
		return writeHandlerErrorDef(c, "AuthHandler.TOTPVerify.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	challengeToken := strings.TrimSpace(req.Data.Attributes.ChallengeToken)
	userID, err := h.svc.ResolveTOTPChallenge(c.UserContext(), challengeToken)
	if err != nil {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Invalid challenge",
			Detail: "challenge token is invalid or expired",
		}})
	}

	ok, err := h.svc.VerifyTOTP(c.UserContext(), userID, strings.TrimSpace(req.Data.Attributes.Code))
	if err != nil || !ok {
		if _, attemptErr := h.svc.RegisterFailedTOTPChallengeAttempt(c.UserContext(), challengeToken); attemptErr != nil {
			if errors.Is(attemptErr, service.ErrTOTPChallengeAttemptLimit) {
				return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
					Status: "401",
					Title:  "Invalid challenge",
					Detail: "challenge token is invalid or expired",
				}})
			}
		}
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Invalid TOTP",
			Detail: "code is invalid or expired",
		}})
	}
	h.svc.InvalidateTOTPChallenge(c.UserContext(), challengeToken)

	user, err := h.svc.GetUserByID(c.UserContext(), userID)
	if err != nil || user == nil {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "user not found",
		}})
	}

	tokens, err := h.svc.IssueTokensAfterTOTPWithClientAndDeviceType(
		c.UserContext(),
		user,
		c.Get(fiber.HeaderUserAgent),
		c.IP(),
		readDeviceTypeHint(c),
	)
	if err != nil {
		return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPVerify.IssueTokens", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
	}
	setAuthCookies(c, h.svc, tokens)

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_tokens",
		Attributes: map[string]any{
			"access_expires_in":  int64(h.svc.AccessTTL().Seconds()),
			"refresh_expires_in": int64(tokens.RefreshTTL.Seconds()),
		},
	})
}

// SendEmailOTP godoc
// @Summary Send login OTP to email
// @Description Sends a one-time 6-digit OTP to user email using login challenge token from /auth/login.
// @Description Security controls: old OTP is invalidated, resend cooldown is enforced, and resend/hour quota is enforced.
// @Tags Auth - Email OTP
// @Accept json
// @Produce json
// @Param request body dto.EmailOTPSendRequest true "JSON:API Email OTP send request"
// @Success 200 {object} dto.EmailOTPSendResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 429 {object} dto.ErrorResponse
// @Router /api/v1/auth/email-otp/send [post]
func (h *AuthHandler) SendEmailOTP(c *fiber.Ctx) error {
	if !h.svc.IsLoginEmailOTPEnabled(c.UserContext()) {
		return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
			Status: "403",
			Title:  "Email OTP disabled",
			Detail: "email otp login is disabled",
		}})
	}
	var req dto.EmailOTPSendRequest
	if err := c.BodyParser(&req); err != nil {
		return writeHandlerErrorDef(c, "AuthHandler.SendEmailOTP.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	if err := h.svc.SendLoginEmailOTP(c.UserContext(), strings.TrimSpace(req.Data.Attributes.ChallengeToken)); err != nil {
		if errors.Is(err, service.ErrEmailOTPRateLimited) || errors.Is(err, service.ErrEmailOTPResendExceeded) {
			return writeHandlerErrorDefWithStatus(c, "AuthHandler.SendEmailOTP.RateLimited", fiber.StatusTooManyRequests, apperrorsx.ErrBusinessRuleFailed, err)
		}
		return writeHandlerErrorDefWithStatus(c, "AuthHandler.SendEmailOTP", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
	}
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_email_otp_send",
		Attributes: map[string]any{
			"sent": true,
		},
	})
}

// VerifyEmailOTP godoc
// @Summary Verify login OTP from email
// @Description Verifies one-time OTP and completes login by issuing auth cookies.
// @Description OTP is one-time use, expires automatically, and failed attempts are limited and audited.
// @Tags Auth - Email OTP
// @Accept json
// @Produce json
// @Param request body dto.EmailOTPVerifyRequest true "JSON:API Email OTP verify request"
// @Success 200 {object} dto.EmailOTPVerifyResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/email-otp/verify [post]
func (h *AuthHandler) VerifyEmailOTP(c *fiber.Ctx) error {
	if !h.svc.IsLoginEmailOTPEnabled(c.UserContext()) {
		return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
			Status: "403",
			Title:  "Email OTP disabled",
			Detail: "email otp login is disabled",
		}})
	}
	var req dto.EmailOTPVerifyRequest
	if err := c.BodyParser(&req); err != nil {
		return writeHandlerErrorDef(c, "AuthHandler.VerifyEmailOTP.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	user, err := h.svc.VerifyLoginEmailOTP(c.UserContext(), strings.TrimSpace(req.Data.Attributes.ChallengeToken), strings.TrimSpace(req.Data.Attributes.Code))
	if err != nil {
		if errors.Is(err, service.ErrInvalidEmailOTP) {
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Unauthorized",
				Detail: "invalid otp code",
			}})
		}
		return writeHandlerErrorDefWithStatus(c, "AuthHandler.VerifyEmailOTP", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
	}
	tokens, err := h.svc.IssueTokensAfterTOTPWithClientAndDeviceType(
		c.UserContext(),
		user,
		c.Get(fiber.HeaderUserAgent),
		c.IP(),
		readDeviceTypeHint(c),
	)
	if err != nil {
		return writeHandlerErrorDefWithStatus(c, "AuthHandler.VerifyEmailOTP.IssueTokens", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
	}
	setAuthCookies(c, h.svc, tokens)
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_email_otp_verify",
		Attributes: map[string]any{
			"verified":            true,
			"requires_totp_setup": true,
		},
	})
}

// TOTPDisable godoc
// @Summary Disable TOTP
// @Tags Auth - TOTP
// @Accept json
// @Produce json
// @Param request body dto.TOTPDisableRequest true "JSON:API TOTP disable request"
// @Success 200 {object} dto.VerifyEmailResponse
// @Failure 422 {object} dto.ErrorResponse
// @Router /api/v1/auth/totp/disable [post]
func (h *AuthHandler) TOTPDisable(c *fiber.Ctx) error {
	authUserID := actorUserID(c)
	if len(authUserID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	var req dto.TOTPDisableRequest
	if err := c.BodyParser(&req); err != nil {
		return writeHandlerErrorDef(c, "AuthHandler.TOTPDisable.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
	if err != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "user_id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
		}})
	}
	if !bytes.Equal(userID, authUserID) {
		return writeAuthErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
			Status: "403",
			Title:  "Forbidden",
			Detail: "user_id does not match authenticated user",
		}})
	}

	if err := h.svc.DisableTOTP(c.UserContext(), authUserID); err != nil {
		return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPDisable", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_totp_disable",
		Attributes: map[string]any{
			"disabled": true,
		},
	})
}

// TOTPResetSetup godoc
// @Summary Reset TOTP setup
// @Description Removes TOTP setup so the user can re-setup authenticator.
// @Description For method=pin, obtain token from /auth/pin/verify with action=0b5409b5-94f3-4aab-9de2-44b0ad5858f1 and send it in X-PIN-Verification-Token header.
// @Tags Auth - TOTP
// @Accept json
// @Produce json
// @Param request body dto.TOTPResetSetupRequest true "JSON:API TOTP reset setup request"
// @Success 200 {object} dto.VerifyEmailResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 429 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/auth/totp/reset-setup [post]
func (h *AuthHandler) TOTPResetSetup(c *fiber.Ctx) error {
	userID, ok := c.Locals("user_id").([]byte)
	if !ok || len(userID) == 0 {
		return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}

	var req dto.TOTPResetSetupRequest
	if err := c.BodyParser(&req); err != nil {
		return writeHandlerErrorDef(c, "AuthHandler.TOTPResetSetup.BodyParser", apperrorsx.ErrAuthInvalidJSON, err)
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	method := strings.ToLower(strings.TrimSpace(req.Data.Attributes.Method))
	switch method {
	case "pin":
		sessionID := ""
		if accessToken := readValidAccessToken(c, h.svc); accessToken != "" {
			sid, err := h.svc.ParseAccessTokenSessionID(c.UserContext(), accessToken)
			if err != nil {
				return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
					Status: "401",
					Title:  "Unauthorized",
					Detail: "invalid or expired token",
				}})
			}
			sessionID = sid
		}
		token := c.Get(pinVerificationHeader)
		if err := h.svc.ConsumeSecurityPINActionTokenInSession(c.UserContext(), userID, sharedconst.PinActionTOTPResetSetup, token, sessionID); err != nil {
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "PIN verification required",
				Detail: "valid PIN action token is required",
				Source: &jsonapi.ErrorSource{Pointer: "/headers/X-PIN-Verification-Token"},
			}})
		}
		if err := h.svc.DeleteTOTPSetup(c.UserContext(), userID); err != nil {
			return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPResetSetup.Delete", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
		}
		return response.Write(c, fiber.StatusOK, jsonapi.Resource{
			Type: "auth_totp_reset_setup",
			Attributes: map[string]any{
				"reset": true,
			},
		})
	case "password":
		if req.Data.Attributes.Password == "" {
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "password is required when method is password",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/password"},
			}})
		}
	default:
		return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "method must be one of: pin, password",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
		}})
	}

	if err := h.svc.ResetTOTPSetup(c.UserContext(), userID, method, "", req.Data.Attributes.Password); err != nil {
		switch {
		case errors.Is(err, service.ErrInvalidReauthMethod):
			return writeAuthErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "method must be one of: pin, password",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/method"},
			}})
		case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidSecurityPIN):
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Unauthorized",
				Detail: "invalid authentication factor",
			}})
		case errors.Is(err, service.ErrSecurityPINNotSet):
			return writeAuthErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
				Status: "409",
				Title:  "PIN not configured",
				Detail: "security PIN is not set",
			}})
		case errors.Is(err, service.ErrSecurityPINAttemptLimit):
			return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{
				Status: "423",
				Title:  "PIN blocked",
				Detail: "security PIN is blocked. reset PIN is required.",
			}})
		case errors.Is(err, service.ErrSecurityPINLocked), errors.Is(err, service.ErrSecurityPINResetRequired):
			return writeAuthErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{
				Status: "423",
				Title:  "PIN blocked",
				Detail: "security PIN is blocked. reset PIN is required.",
			}})
		case errors.Is(err, service.ErrInvalidToken):
			return writeAuthErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
				Status: "401",
				Title:  "Unauthorized",
				Detail: "user not found",
			}})
		default:
			return writeHandlerErrorDefWithStatus(c, "AuthHandler.TOTPResetSetup", fiber.StatusBadRequest, apperrorsx.ErrInternal, err)
		}
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type: "auth_totp_reset_setup",
		Attributes: map[string]any{
			"reset": true,
		},
	})
}

func permissionAttrs(svc *service.AuthService, c *fiber.Ctx, userID []byte) []map[string]any {
	perms, err := svc.GetPermissionsByUserID(c.UserContext(), userID)
	if err != nil {
		return []map[string]any{}
	}
	out := make([]map[string]any, 0, len(perms))
	for i := range perms {
		pid, _ := uuidv7.BytesToString(perms[i].ID)
		out = append(out, map[string]any{
			"id":           pid,
			"name":         perms[i].Name,
			"key":          perms[i].Key,
			"description":  perms[i].Description,
			"requires_pin": perms[i].RequiresPIN,
		})
	}
	return out
}

// permissionModuleGroups returns the user's permissions grouped by module, in the
// same shape as GET /roles/permissions/get-all.
// TODO: remove permissionAttrs (the flat "permissions" field) once the frontend
// migrates to "permission_modules".
func permissionModuleGroups(svc *service.AuthService, c *fiber.Ctx, userID []byte) []dto.PermissionGroupResource {
	perms, err := svc.GetPermissionsByUserID(c.UserContext(), userID)
	if err != nil {
		return []dto.PermissionGroupResource{}
	}
	return groupPermissionsByModule(perms)
}

func authRoleResponseData(svc *service.AuthService, c *fiber.Ctx, user *auth.User) (any, []map[string]any) {
	roleIDs := make([]string, 0, len(user.RoleIDs)+1)
	roles := make([]map[string]any, 0, len(user.Roles)+1)
	seen := make(map[string]struct{}, len(user.RoleIDs)+1)

	appendRole := func(roleID []byte, name, description string) {
		if len(roleID) != 16 {
			return
		}
		roleIDStr, _ := uuidv7.BytesToString(roleID)
		if _, exists := seen[roleIDStr]; exists {
			return
		}
		seen[roleIDStr] = struct{}{}
		roleIDs = append(roleIDs, roleIDStr)
		roles = append(roles, map[string]any{
			"id":          roleIDStr,
			"name":        name,
			"description": description,
		})
	}

	primaryRole := findAuthRoleByID(user, user.RoleID)
	if primaryRole == nil && len(user.RoleID) == 16 {
		primaryRole, _ = svc.GetRoleByID(c.UserContext(), user.RoleID)
	}
	if primaryRole != nil {
		appendRole(primaryRole.ID, primaryRole.Name, primaryRole.Description)
	}

	for i := range user.Roles {
		appendRole(user.Roles[i].ID, user.Roles[i].Name, user.Roles[i].Description)
	}
	for i := range user.RoleIDs {
		appendRole(user.RoleIDs[i], "", "")
	}
	if len(roles) == 0 && len(user.RoleID) == 16 {
		appendRole(user.RoleID, "", "")
	}

	// primaryRoleID := ""
	var primaryRoleAttrs any
	if len(roles) > 0 {
		// primaryRoleID = roleIDs[0]
		primaryRoleAttrs = roles[0]
	}

	return primaryRoleAttrs, roles
}

func roleResponseIDFields(roles []map[string]any) (any, []string) {
	roleIDs := make([]string, 0, len(roles))
	for i := range roles {
		if roleID, ok := roles[i]["id"].(string); ok && roleID != "" {
			roleIDs = append(roleIDs, roleID)
		}
	}
	if len(roleIDs) == 0 {
		return nil, roleIDs
	}
	return roleIDs[0], roleIDs
}

func findAuthRoleByID(user *auth.User, roleID []byte) *auth.Role {
	if user == nil || len(roleID) != 16 {
		return nil
	}
	target := string(roleID)
	for i := range user.Roles {
		if string(user.Roles[i].ID) == target {
			role := user.Roles[i]
			return &role
		}
	}
	return nil
}

func setAuthCookies(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair) {
	setAuthCookiesWithSameSite(c, svc, tokens, parseSameSite(svc.CookieSameSite()), svc.CookieSecure())
}

func setAuthCookiesForProvider(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair, authProvider string) {
	sameSite := parseSameSite(svc.CookieSameSite())
	secure := svc.CookieSecure()
	if strings.EqualFold(strings.TrimSpace(authProvider), "microsoft") && secure {
		sameSite = "None"
	}
	setAuthCookiesWithSameSite(c, svc, tokens, sameSite, secure)
}

func setAuthCookiesWithSameSite(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair, sameSite string, secure bool) {
	refreshTTL := tokens.RefreshTTL
	if refreshTTL <= 0 {
		refreshTTL = svc.RefreshTTL()
	}
	clearAuthCookieScopes(c, svc, sameSite, secure)
	c.Cookie(&fiber.Cookie{
		Name:     svc.AccessCookieName(),
		Value:    tokens.AccessToken,
		HTTPOnly: true,
		Secure:   secure,
		SameSite: sameSite,
		Domain:   svc.CookieDomain(),
		Path:     "/",
		MaxAge:   int(svc.AccessTTL().Seconds()),
	})
	c.Cookie(&fiber.Cookie{
		Name:     svc.RefreshCookieName(),
		Value:    tokens.RefreshToken,
		HTTPOnly: true,
		Secure:   secure,
		SameSite: sameSite,
		Domain:   svc.CookieDomain(),
		Path:     "/api/v1/auth/refresh",
		MaxAge:   int(refreshTTL.Seconds()),
	})
}

func clearAuthCookieScopes(c *fiber.Ctx, svc *service.AuthService, sameSite string, secure bool) {
	name := svc.AccessCookieName()
	refreshName := svc.RefreshCookieName()
	domain := strings.TrimSpace(svc.CookieDomain())

	c.Cookie(&fiber.Cookie{
		Name:     name,
		Value:    "",
		HTTPOnly: true,
		Secure:   secure,
		SameSite: sameSite,
		Domain:   domain,
		Path:     "/",
		MaxAge:   -1,
	})
	c.Cookie(&fiber.Cookie{
		Name:     refreshName,
		Value:    "",
		HTTPOnly: true,
		Secure:   secure,
		SameSite: sameSite,
		Domain:   domain,
		Path:     "/api/v1/auth/refresh",
		MaxAge:   -1,
	})
	if domain != "" {
		c.Cookie(&fiber.Cookie{
			Name:     name,
			Value:    "",
			HTTPOnly: true,
			Secure:   secure,
			SameSite: sameSite,
			Path:     "/",
			MaxAge:   -1,
		})
		c.Cookie(&fiber.Cookie{
			Name:     refreshName,
			Value:    "",
			HTTPOnly: true,
			Secure:   secure,
			SameSite: sameSite,
			Path:     "/api/v1/auth/refresh",
			MaxAge:   -1,
		})
	}
}

func readValidAccessToken(c *fiber.Ctx, svc *service.AuthService) string {
	if c == nil || svc == nil {
		return ""
	}
	candidates := service.CookieValues(c.Get(fiber.HeaderCookie), svc.AccessCookieName())
	for _, token := range candidates {
		if _, err := svc.ParseAccessToken(c.UserContext(), token); err == nil {
			return token
		}
	}
	return strings.TrimSpace(c.Cookies(svc.AccessCookieName()))
}

func readValidRefreshToken(c *fiber.Ctx, svc *service.AuthService) string {
	if c == nil || svc == nil {
		return ""
	}
	candidates := service.CookieValues(c.Get(fiber.HeaderCookie), svc.RefreshCookieName())
	if len(candidates) == 0 {
		return strings.TrimSpace(c.Cookies(svc.RefreshCookieName()))
	}
	return candidates[0]
}

func writeAuthErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error {
	enriched := make([]jsonapi.ErrorObject, 0, len(errs))
	for i := range errs {
		e := errs[i]
		if strings.TrimSpace(e.Code) == "" || strings.TrimSpace(e.ErrorCode) == "" {
			code, errorCode := inferAuthErrorCode(status, e.Title, e.Detail)
			if strings.TrimSpace(e.Code) == "" {
				e.Code = code
			}
			if strings.TrimSpace(e.ErrorCode) == "" {
				e.ErrorCode = errorCode
			}
		}
		enriched = append(enriched, e)
	}
	return response.WriteErrors(c, status, enriched)
}

func inferAuthErrorCode(status int, title, detail string) (string, string) {
	lowerTitle := strings.ToLower(strings.TrimSpace(title))
	lowerDetail := strings.ToLower(strings.TrimSpace(detail))

	switch status {
	case fiber.StatusAccepted:
		if strings.Contains(lowerTitle, "pin verification") || strings.Contains(lowerDetail, "pin action token") {
			return apperrorsx.CodePINVerificationRequired, apperrorsx.ErrPINVerificationRequired202.ErrorCode
		}
		return apperrorsx.CodeInvalidRequest, apperrorsx.ErrInvalidRequest.ErrorCode
	case fiber.StatusUnauthorized:
		if strings.Contains(lowerDetail, "token") || strings.Contains(lowerTitle, "invalid challenge") {
			return apperrorsx.CodeTokenInvalid, apperrorsx.ErrTokenInvalid.ErrorCode
		}
		if strings.Contains(lowerDetail, "pin") {
			return apperrorsx.CodePINIncorrect, apperrorsx.ErrPINIncorrect.ErrorCode
		}
		if strings.Contains(lowerDetail, "credentials") || strings.Contains(lowerTitle, "unauthorized") {
			return apperrorsx.CodeUserUnauthorized, apperrorsx.ErrUserUnauthorized.ErrorCode
		}
		return apperrorsx.CodeUserUnauthorized, apperrorsx.ErrUserUnauthorized.ErrorCode
	case fiber.StatusForbidden:
		if strings.Contains(lowerTitle, "registration disabled") {
			return apperrorsx.CodeAuthRegistrationDisabled, apperrorsx.ErrAuthRegistrationDisabled.ErrorCode
		}
		if strings.Contains(lowerDetail, "permission") || strings.Contains(lowerDetail, "forbidden") {
			return apperrorsx.CodeForbiddenAccess, apperrorsx.ErrForbiddenAccess.ErrorCode
		}
		return apperrorsx.CodeForbiddenAccess, apperrorsx.ErrForbiddenAccess.ErrorCode
	case fiber.StatusNotFound:
		if strings.Contains(lowerDetail, "user not found") || strings.Contains(lowerDetail, "session is not found") {
			return apperrorsx.CodeDataNotFound, apperrorsx.ErrDataNotFound.ErrorCode
		}
		return apperrorsx.CodeDataNotFound, apperrorsx.ErrDataNotFound.ErrorCode
	case fiber.StatusConflict:
		if strings.Contains(lowerDetail, "pin") {
			return apperrorsx.CodePINNotSet, apperrorsx.ErrPINNotSet.ErrorCode
		}
		return apperrorsx.CodeBusinessRuleFailed, apperrorsx.ErrBusinessRuleFailed.ErrorCode
	case fiber.StatusUnprocessableEntity:
		if strings.Contains(lowerDetail, "pin") {
			return apperrorsx.CodePINValidationFailed, apperrorsx.ErrPINValidationFailed.ErrorCode
		}
		if strings.Contains(lowerTitle, "validation") {
			return apperrorsx.CodeValidationError, apperrorsx.ErrValidation.ErrorCode
		}
		return apperrorsx.CodeValidationError, apperrorsx.ErrValidation.ErrorCode
	case fiber.StatusTooManyRequests:
		if strings.Contains(lowerDetail, "blocked") || strings.Contains(lowerTitle, "pin blocked") {
			return apperrorsx.CodePINBlocked, apperrorsx.ErrPINBlocked.ErrorCode
		}
		if strings.Contains(lowerDetail, "pin") || strings.Contains(lowerTitle, "too many attempts") {
			return apperrorsx.CodePINAttemptLimitReached, apperrorsx.ErrPINAttemptLimitReached.ErrorCode
		}
		return apperrorsx.CodeBusinessRuleFailed, apperrorsx.ErrBusinessRuleFailed.ErrorCode
	case fiber.StatusInternalServerError:
		if strings.Contains(lowerTitle, "reset password failed") {
			return apperrorsx.CodeAuthResetPasswordFailed, apperrorsx.ErrAuthResetPasswordFailed.ErrorCode
		}
		return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode
	case fiber.StatusBadRequest:
		if strings.Contains(lowerTitle, "invalid json") {
			return apperrorsx.CodeAuthInvalidJSON, apperrorsx.ErrAuthInvalidJSON.ErrorCode
		}
		if strings.Contains(lowerTitle, "role not found") {
			return apperrorsx.CodeAuthRoleNotFound, apperrorsx.ErrAuthRoleNotFound.ErrorCode
		}
		if strings.Contains(lowerTitle, "registration failed") {
			if strings.Contains(lowerDetail, "already registered") {
				return apperrorsx.CodeAuthUsernameAlreadyRegistered, apperrorsx.ErrAuthUsernameAlreadyRegistered.ErrorCode
			}
			return apperrorsx.CodeAuthRegistrationFailed, apperrorsx.ErrAuthRegistrationFailed.ErrorCode
		}
		if strings.Contains(lowerTitle, "invite failed") {
			return apperrorsx.CodeAuthInviteFailed, apperrorsx.ErrAuthInviteFailed.ErrorCode
		}
		if strings.Contains(lowerTitle, "set password failed") {
			if strings.Contains(lowerDetail, "invalid or expired token") {
				return apperrorsx.CodeAuthSetPasswordTokenInvalid, apperrorsx.ErrAuthSetPasswordTokenInvalid.ErrorCode
			}
			return apperrorsx.CodeAuthSetPasswordFailed, apperrorsx.ErrAuthSetPasswordFailed.ErrorCode
		}
		if strings.Contains(lowerTitle, "invalid reset link") {
			return apperrorsx.CodeAuthResetPasswordInvalidLink, apperrorsx.ErrAuthResetPasswordInvalidLink.ErrorCode
		}
		return apperrorsx.CodeInvalidRequest, apperrorsx.ErrInvalidRequest.ErrorCode
	default:
		return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode
	}
}

func clearAuthCookies(c *fiber.Ctx, svc *service.AuthService) {
	clearAuthCookieScopes(c, svc, parseSameSite(svc.CookieSameSite()), svc.CookieSecure())
}

func clearAuthCookiesForFrontChannel(c *fiber.Ctx, svc *service.AuthService) {
	clearAuthCookieScopes(c, svc, "None", true)
}

func parseSameSite(v string) string {
	switch strings.ToLower(v) {
	case "strict":
		return "Strict"
	case "none":
		return "None"
	default:
		return "Lax"
	}
}

func readDeviceTypeHint(c *fiber.Ctx) string {
	if c == nil {
		return ""
	}
	return strings.TrimSpace(strings.ToLower(c.Get(deviceTypeHeader)))
}

func authUserFoto(ctx context.Context, storage fileManagerDownloadURLStorage, u *auth.User) *dto.UserFoto {
	if u == nil {
		return nil
	}
	fotoUUID := uuidBytesToStringOrEmpty(u.ProfileAttachmentID)
	fotoDownloadURL := ""
	fotoThumbnailURL := ""
	var fotoOriginalSizeBytes *int64
	if u.ProfileAttachment != nil && u.ProfileAttachment.File != nil {
		asset := resolveFileManagerDownloadURLs(ctx, storage, u.ProfileAttachment.File)
		fotoDownloadURL = strings.TrimSpace(asset.OriginalURL)
		fotoThumbnailURL = strings.TrimSpace(asset.ThumbnailURL)
		fotoOriginalSizeBytes = int64Ptr(asset.OriginalSizeBytes)
	}
	if fotoUUID == "" && fotoDownloadURL == "" && fotoThumbnailURL == "" && fotoOriginalSizeBytes == nil {
		return nil
	}
	return &dto.UserFoto{
		UUID:                 fotoUUID,
		DownloadURL:          fotoDownloadURL,
		ThumbnailDownloadURL: fotoThumbnailURL,
		OriginalSizeBytes:    fotoOriginalSizeBytes,
	}
}
