AuthService.java

package com.wavii.service;

import com.wavii.dto.auth.*;
import com.wavii.model.User;
import com.wavii.model.VerificationToken;
import com.wavii.model.enums.Role;
import com.wavii.model.enums.Subscription;
import com.wavii.repository.UserRepository;
import com.wavii.repository.VerificationTokenRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

/**
 * Servicio de autenticación y gestión de usuarios.
 * Maneja el registro, inicio de sesión y recuperación de cuentas.
 * 
 * @author eduglezexp
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthService {

    private static final String TYPE_EMAIL_VERIFICATION = "EMAIL_VERIFICATION";
    private static final String TYPE_PASSWORD_RESET = "PASSWORD_RESET";

    private final UserRepository userRepository;
    private final VerificationTokenRepository tokenRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final EmailService emailService;
    private final OdooService odooService;

    /**
     * Registra un nuevo usuario en el sistema.
     * 
     * @param request Datos de registro.
     * @return Respuesta con los tokens y datos del usuario.
     */
    @Transactional
    public AuthResponse register(RegisterRequest request) {
        if (userRepository.existsByNameIgnoreCase(request.getName().strip())) {
            throw new IllegalArgumentException("__NAME__Este nombre de usuario ya está en uso");
        }
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new IllegalArgumentException("Ya existe una cuenta con ese email");
        }

        User user = User.builder()
                .name(request.getName())
                .email(request.getEmail())
                .passwordHash(passwordEncoder.encode(request.getPassword()))
                .role(Role.USUARIO)
                .subscription(Subscription.FREE)
                .emailVerified(false)
                .onboardingCompleted(false)
                .teacherVerified(false)
                .createdAt(LocalDateTime.now())
                .build();

        userRepository.save(user);
        log.debug("Usuario registrado: {}", user.getEmail());

        String verificationToken = createVerificationToken(user, TYPE_EMAIL_VERIFICATION);
        emailService.sendVerificationEmail(user.getEmail(), user.getName(), verificationToken);

        String accessToken = jwtService.generateAccessToken(user);
        String refreshToken = jwtService.generateRefreshToken(user);

        return buildAuthResponse(user, accessToken, refreshToken);
    }

    /**
     * Autentica a un usuario y genera sus tokens.
     * 
     * @param request Credenciales de login.
     * @return Respuesta con los tokens y datos del usuario.
     */
    @Transactional
    public AuthResponse login(LoginRequest request) {
        User user = userRepository.findByEmail(request.getEmail())
                .orElseThrow(() -> new BadCredentialsException("Credenciales incorrectas"));

        if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
            throw new BadCredentialsException("Credenciales incorrectas");
        }

        if (!user.isEmailVerified()) {
            throw new EmailNotVerifiedException("EMAIL_NOT_VERIFIED",
                    "Debes verificar tu email antes de iniciar sesión");
        }

        user.setLastLoginAt(LocalDateTime.now());
        userRepository.save(user);

        String accessToken = jwtService.generateAccessToken(user);
        String refreshToken = jwtService.generateRefreshToken(user);

        log.debug("Login exitoso: {}", user.getEmail());
        return buildAuthResponse(user, accessToken, refreshToken);
    }

    /**
     * Renueva el token de acceso a partir de un refresh token.
     * 
     * @param request Contiene el refresh token.
     * @return Nueva respuesta de autenticación con nuevos tokens.
     */
    @Transactional
    public AuthResponse refreshToken(RefreshTokenRequest request) {
        String token = request.getRefreshToken();

        String email;
        try {
            email = jwtService.extractEmail(token);
        } catch (Exception e) {
            throw new IllegalArgumentException("Refresh token inválido o expirado");
        }

        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("Usuario no encontrado"));

        if (!jwtService.isTokenValid(token, user)) {
            throw new IllegalArgumentException("Refresh token inválido o expirado");
        }

        String newAccessToken = jwtService.generateAccessToken(user);
        String newRefreshToken = jwtService.generateRefreshToken(user);

        return buildAuthResponse(user, newAccessToken, newRefreshToken);
    }

    /**
     * Inicia el proceso de recuperación de contraseña enviando un email.
     * 
     * @param request Contiene el email del usuario.
     */
    @Transactional
    public void forgotPassword(ForgotPasswordRequest request) {
        userRepository.findByEmail(request.getEmail()).ifPresent(user -> {
            invalidatePreviousTokens(user, TYPE_PASSWORD_RESET);
            String resetToken = createVerificationToken(user, TYPE_PASSWORD_RESET);
            emailService.sendPasswordResetEmail(user.getEmail(), user.getName(), resetToken);
        });
    }

    /**
     * Restablece la contraseña del usuario tras validar el token de recuperación.
     * 
     * @param request Contiene el token y la nueva contraseña.
     */
    @Transactional
    public void resetPassword(ResetPasswordRequest request) {
        VerificationToken verificationToken = tokenRepository.findByToken(request.getToken())
                .orElseThrow(() -> new IllegalArgumentException("Token inválido"));

        if (verificationToken.isUsed()) {
            throw new IllegalArgumentException("Este enlace ya fue utilizado");
        }
        if (verificationToken.getExpiresAt().isBefore(LocalDateTime.now())) {
            throw new IllegalArgumentException("El enlace ha expirado");
        }
        if (!TYPE_PASSWORD_RESET.equals(verificationToken.getType())) {
            throw new IllegalArgumentException("Token de tipo incorrecto");
        }

        User user = verificationToken.getUser();
        user.setPasswordHash(passwordEncoder.encode(request.getNewPassword()));
        userRepository.save(user);

        verificationToken.setUsed(true);
        tokenRepository.save(verificationToken);

        log.debug("Contraseña restablecida para: {}", user.getEmail());
    }

    /**
     * Verifica el email de un usuario mediante un token.
     * 
     * @param token Token de verificación.
     * @return Respuesta de autenticación para el usuario verificado.
     */
    @Transactional
    public AuthResponse verifyEmail(String token) {
        VerificationToken verificationToken = tokenRepository.findByToken(token)
                .orElseThrow(() -> new IllegalArgumentException("Token inválido"));

        if (verificationToken.isUsed()) {
            throw new IllegalArgumentException("Este enlace ya fue utilizado");
        }
        if (verificationToken.getExpiresAt().isBefore(LocalDateTime.now())) {
            throw new IllegalArgumentException("El enlace ha expirado");
        }
        if (!TYPE_EMAIL_VERIFICATION.equals(verificationToken.getType())) {
            throw new IllegalArgumentException("Token de tipo incorrecto");
        }

        User user = verificationToken.getUser();
        user.setEmailVerified(true);
        userRepository.save(user);

        verificationToken.setUsed(true);
        tokenRepository.save(verificationToken);

        log.debug("Email verificado para: {}", user.getEmail());

        odooService.createCrmContact(
                user.getName(),
                user.getEmail(),
                user.getRole().name(),
                user.getSubscription().name()
        );

        String accessToken = jwtService.generateAccessToken(user);
        String refreshToken = jwtService.generateRefreshToken(user);

        return buildAuthResponse(user, accessToken, refreshToken);
    }

    /**
     * Reenvía el email de verificación.
     * 
     * @param email Email del usuario.
     */
    @Transactional
    public void resendVerification(String email) {
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("No existe una cuenta con ese email"));

        if (user.isEmailVerified()) {
            throw new IllegalArgumentException("El email ya está verificado");
        }

        invalidatePreviousTokens(user, TYPE_EMAIL_VERIFICATION);
        String newToken = createVerificationToken(user, TYPE_EMAIL_VERIFICATION);
        emailService.sendVerificationEmail(user.getEmail(), user.getName(), newToken);
    }

    /**
     * Comprueba si el email está verificado.
     * 
     * @param email Email del usuario.
     * @return true si está verificado.
     */
    public boolean isEmailVerified(String email) {
        return userRepository.findByEmail(email)
                .map(User::isEmailVerified)
                .orElse(false);
    }

    /**
     * Crea un token de verificación o restablecimiento de contraseña para un usuario.
     * 
     * @param user Usuario para el que se crea el token.
     * @param type Tipo de token (verificación o reset).
     * @return El token generado (UUID).
     */
    private String createVerificationToken(User user, String type) {
        VerificationToken vt = VerificationToken.builder()
                .token(UUID.randomUUID().toString())
                .user(user)
                .type(type)
                .expiresAt(LocalDateTime.now().plusHours(24))
                .used(false)
                .build();
        tokenRepository.save(vt);
        return vt.getToken();
    }

    /**
     * Invalida todos los tokens previos no utilizados de un tipo específico para un usuario.
     * 
     * @param user Usuario al que pertenecen los tokens.
     * @param type Tipo de token a invalidar.
     */
    private void invalidatePreviousTokens(User user, String type) {
        List<VerificationToken> existing = tokenRepository.findAllByUserAndTypeAndUsedFalse(user, type);
        existing.forEach(t -> t.setUsed(true));
        tokenRepository.saveAll(existing);
    }

    /**
     * Construye el DTO de respuesta de autenticación con los datos del usuario y sus tokens.
     * 
     * @param user Usuario autenticado.
     * @param accessToken Token de acceso generado.
     * @param refreshToken Token de refresco generado.
     * @return DTO con la información de sesión.
     */
    private AuthResponse buildAuthResponse(User user, String accessToken, String refreshToken) {
        return AuthResponse.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .userId(user.getId())
                .name(user.getName())
                .email(user.getEmail())
                .city(user.getCity())
                .role(user.getRole())
                .subscription(user.getSubscription().toPublicId())
                .emailVerified(user.isEmailVerified())
                .onboardingCompleted(user.isOnboardingCompleted())
                .teacherVerified(user.isTeacherVerified())
                .build();
    }

    // ---- Custom exception ----

    public static class EmailNotVerifiedException extends RuntimeException {
        private final String code;

        public EmailNotVerifiedException(String code, String message) {
            super(message);
            this.code = code;
        }

        public String getCode() {
            return code;
        }
    }
}