UsuarioController.java

package com.licensis.notaire.api;

import com.licensis.notaire.config.JwtTokenService;
import com.licensis.notaire.dto.DtoPersona;
import com.licensis.notaire.dto.DtoUsuario;
import com.licensis.notaire.negocio.Usuario;
import com.licensis.notaire.observability.MetricsUtil;
import com.licensis.notaire.repository.UsuarioRepository;
import com.licensis.notaire.security.LoginAttemptService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.transaction.annotation.Transactional;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@RestController
@RequestMapping("/api/v1/usuarios")
@Tag(name = "Usuarios", description = "API para gestionar usuarios")
public class UsuarioController {

    private static final Logger log = LoggerFactory.getLogger(UsuarioController.class);

    private final UsuarioRepository usuarioRepository;
    private final JwtTokenService jwtTokenService;
    private final MetricsUtil metricsUtil;
    private final PasswordEncoder passwordEncoder;
    private final LoginAttemptService loginAttemptService;

    public UsuarioController(UsuarioRepository usuarioRepository, JwtTokenService jwtTokenService,
                             MetricsUtil metricsUtil, PasswordEncoder passwordEncoder,
                             LoginAttemptService loginAttemptService) {
        this.usuarioRepository = usuarioRepository;
        this.jwtTokenService = jwtTokenService;
        this.metricsUtil = metricsUtil;
        this.passwordEncoder = passwordEncoder;
        this.loginAttemptService = loginAttemptService;
    }

    record PersonaInfo(Integer idPersona, String nombre, String apellido) {}

    record RolInfo(Integer idRol, String nombre) {}

    record UsuarioResponse(Integer idUsuario, String nombre, String tipo, boolean activo,
                            PersonaInfo persona, RolInfo rol) {}

    record UsuarioRequest(
            @NotBlank String nombre,
            String contrasenia,
            @NotBlank String tipo,
            boolean activo) {}

    private UsuarioResponse toResponse(Usuario u) {
        PersonaInfo persona = null;
        if (u.getFkIdPersona() != null) {
            var p = u.getFkIdPersona();
            persona = new PersonaInfo(p.getIdPersona(), p.getNombre(), p.getApellido());
        }
        RolInfo rolInfo = null;
        if (u.getRol() != null) {
            rolInfo = new RolInfo(u.getRol().getIdRol(), u.getRol().getNombre());
        }
        return new UsuarioResponse(u.getIdUsuario(), u.getNombre(), u.getTipo(), u.getEstado(), persona, rolInfo);
    }

    @GetMapping
    @Operation(summary = "Obtener todos los usuarios")
    @Transactional(readOnly = true)
    public ResponseEntity<List<UsuarioResponse>> getAllUsuarios() {
        return ResponseEntity.ok(usuarioRepository.findAll().stream().map(this::toResponse).toList());
    }

    @GetMapping("/persona/{idPersona}")
    @Operation(summary = "Obtener usuario por id de persona asociada")
    @Transactional(readOnly = true)
    public ResponseEntity<UsuarioResponse> getUsuarioByPersona(@PathVariable Integer idPersona) {
        return usuarioRepository.findFirstByFkIdPersonaIdPersona(idPersona)
                .map(this::toResponse)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @ApiResponses({
    @ApiResponse(responseCode = "200", description = "OK"),
    @ApiResponse(responseCode = "404", description = "No encontrado")
})
    @GetMapping("/{id}")
    @Operation(summary = "Obtener usuario por ID")
    @Transactional(readOnly = true)
    public ResponseEntity<UsuarioResponse> getUsuarioById(@PathVariable Integer id) {
        return usuarioRepository.findById(id)
                .map(this::toResponse)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @ApiResponses({
    @ApiResponse(responseCode = "201", description = "Creado"),
    @ApiResponse(responseCode = "400", description = "Solicitud inválida"),
    @ApiResponse(responseCode = "409", description = "Conflicto")
})
    @PostMapping
    @Operation(summary = "Crear nuevo usuario")
    public ResponseEntity<Object> createUsuario(@Valid @RequestBody UsuarioRequest request) {
        try {
            Usuario usuario = new Usuario();
            usuario.setNombre(request.nombre());
            usuario.setTipo(request.tipo());
            usuario.setEstado(request.activo());
            String pwd = request.contrasenia();
            usuario.setContrasenia(pwd != null && !pwd.isEmpty() ? passwordEncoder.encode(pwd) : "");
            usuario = usuarioRepository.save(usuario);
            return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(usuario));
        } catch (Exception e) {
            log.error("Failed to create usuario", e);
            return ResponseEntity.internalServerError().build();
        }
    }

    @ApiResponses({
    @ApiResponse(responseCode = "200", description = "OK"),
    @ApiResponse(responseCode = "404", description = "No encontrado")
})
    @PutMapping("/{id}")
    @Operation(summary = "Actualizar usuario")
    public ResponseEntity<Void> updateUsuario(@PathVariable Integer id, @Valid @RequestBody UsuarioRequest request) {
        Optional<Usuario> existing = usuarioRepository.findById(id);
        if (existing.isEmpty()) {
            return ResponseEntity.notFound().build();
        }
        try {
            Usuario usuario = existing.get();
            usuario.setNombre(request.nombre());
            usuario.setTipo(request.tipo());
            usuario.setEstado(request.activo());
            String pwd = request.contrasenia();
            if (pwd != null && !pwd.isEmpty()) {
                usuario.setContrasenia(passwordEncoder.encode(pwd));
            }
            usuarioRepository.save(usuario);
            return ResponseEntity.ok().build();
        } catch (Exception e) {
            log.error("Failed to update usuario id {}", id, e);
            return ResponseEntity.internalServerError().build();
        }
    }

    @ApiResponses({
    @ApiResponse(responseCode = "204", description = "Eliminado"),
    @ApiResponse(responseCode = "404", description = "No encontrado")
})
    @DeleteMapping("/{id}")
    @Operation(summary = "Eliminar usuario")
    public ResponseEntity<Void> deleteUsuario(@PathVariable Integer id) {
        if (!usuarioRepository.existsById(id)) {
            return ResponseEntity.notFound().build();
        }
        try {
            usuarioRepository.deleteById(id);
            return ResponseEntity.noContent().build();
        } catch (Exception e) {
            log.error("Failed to delete usuario id {}", id, e);
            return ResponseEntity.status(HttpStatus.CONFLICT).build();
        }
    }

    @ApiResponses({
    @ApiResponse(responseCode = "200", description = "OK (ver campo 'valido' para el resultado)"),
    @ApiResponse(responseCode = "429", description = "Cuenta bloqueada temporalmente por intentos fallidos")
})
    @PostMapping("/login")
    @Operation(summary = "Autenticar usuario")
    public ResponseEntity<?> login(@RequestBody DtoUsuario loginRequest) {
        try {
            if (loginAttemptService.isLocked(loginRequest.getNombre())) {
                log.warn("Login bloqueado para '{}': demasiados intentos fallidos.", loginRequest.getNombre());
                metricsUtil.incrementCounter("login", "locked");
                Map<String, Object> lockedResponse = new HashMap<>();
                lockedResponse.put("valido", false);
                lockedResponse.put("message", "Cuenta bloqueada temporalmente por demasiados intentos fallidos.");
                return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(lockedResponse);
            }

            List<Usuario> usuarios = usuarioRepository.findAll();

            if (usuarios == null || usuarios.isEmpty()) {
                log.warn("Login fallido: No hay usuarios en la base de datos.");
                DtoUsuario errorResponse = new DtoUsuario();
                errorResponse.setValido(false);
                return ResponseEntity.ok(errorResponse);
            }

            log.debug("Intento de login para usuario: '{}'", loginRequest.getNombre());

            for (Usuario usuario : usuarios) {
                if (usuario.getNombre().equalsIgnoreCase(loginRequest.getNombre())) {
                    if (passwordMatches(loginRequest.getContrasenia(), usuario)) {
                        if (usuario.getEstado()) {
                            log.info("Login exitoso para usuario: '{}'", usuario.getNombre());
                            metricsUtil.incrementCounter("login", "success");
                            loginAttemptService.onLoginSucceeded(usuario.getNombre());
                            DtoUsuario dtoUsuario = new DtoUsuario();
                            dtoUsuario.setIdUsuario(usuario.getIdUsuario());
                            dtoUsuario.setNombre(usuario.getNombre());
                            dtoUsuario.setEstado(usuario.getEstado());
                            dtoUsuario.setTipo(usuario.getTipo());
                            dtoUsuario.setVersion(usuario.getVersion());
                            if (usuario.getFkIdPersona() != null) {
                                DtoPersona dtoPersona = new DtoPersona();
                                dtoPersona.setIdPersona(usuario.getFkIdPersona().getIdPersona());
                                dtoPersona.setNombre(usuario.getFkIdPersona().getNombre());
                                dtoPersona.setApellido(usuario.getFkIdPersona().getApellido());
                                dtoUsuario.setPersonas(dtoPersona);
                            }
                            dtoUsuario.setValido(true);
                            log.debug("DTO Usuario creado - valido: {}, estado: {}", dtoUsuario.isValido(), dtoUsuario.isEstado());
                            
                            // Create a map response to ensure 'valido' field is included
                            Map<String, Object> response = new HashMap<>();
                            response.put("valido", true);
                            response.put("token", jwtTokenService.generateToken(usuario.getNombre()));
                            response.put("idUsuario", dtoUsuario.getIdUsuario());
                            response.put("nombre", dtoUsuario.getNombre());
                            response.put("estado", dtoUsuario.isEstado());
                            response.put("tipo", dtoUsuario.getTipo());
                            response.put("version", dtoUsuario.getVersion());
                            if (dtoUsuario.getPersonas() != null) {
                                Map<String, Object> personaMap = new HashMap<>();
                                personaMap.put("idPersona", dtoUsuario.getPersonas().getIdPersona());
                                personaMap.put("nombre", dtoUsuario.getPersonas().getNombre());
                                personaMap.put("apellido", dtoUsuario.getPersonas().getApellido());
                                response.put("personas", personaMap);
                            }
                            return ResponseEntity.ok(response);
                        } else {
                            log.warn("Login fallido para '{}': usuario inactivo", usuario.getNombre());
                            metricsUtil.incrementCounter("login", "inactive");
                            loginAttemptService.onLoginFailed(usuario.getNombre());
                            return ResponseEntity.ok(invalidLoginResponse());
                        }
                    } else {
                        log.warn("Login fallido para '{}': contraseña incorrecta", usuario.getNombre());
                        metricsUtil.incrementCounter("login", "bad_credentials");
                        loginAttemptService.onLoginFailed(usuario.getNombre());
                        return ResponseEntity.ok(invalidLoginResponse());
                    }
                }
            }

            log.warn("Login fallido: usuario '{}' no encontrado en {} usuarios cargados.", loginRequest.getNombre(),
                    usuarios.size());
            metricsUtil.incrementCounter("login", "not_found");
            loginAttemptService.onLoginFailed(loginRequest.getNombre());
            return ResponseEntity.ok(invalidLoginResponse());

        } catch (Exception e) {
            log.error("Failed to process login for {}", loginRequest.getNombre(), e);
            metricsUtil.incrementCounter("login", "error");
            return ResponseEntity.ok(invalidLoginResponse());
        }
    }

    private Map<String, Object> invalidLoginResponse() {
        Map<String, Object> response = new HashMap<>();
        response.put("valido", false);
        return response;
    }

    /**
     * Verifica la contraseña ingresada contra el hash almacenado, soportando tanto el
     * nuevo formato BCrypt como el legado MD5 (issue #554). Si el hash almacenado
     * todavía es MD5 y la contraseña coincide, se re-encripta en BCrypt y se persiste
     * de forma transparente, migrando la credencial sin exigir un reseteo al usuario.
     */
    private boolean passwordMatches(String rawPassword, Usuario usuario) {
        String storedHash = usuario.getContrasenia();
        if (isBcryptHash(storedHash)) {
            return passwordEncoder.matches(rawPassword, storedHash);
        }
        if (!legacyMd5Hash(rawPassword).equals(storedHash)) {
            return false;
        }
        usuario.setContrasenia(passwordEncoder.encode(rawPassword));
        usuarioRepository.save(usuario);
        return true;
    }

    private boolean isBcryptHash(String hash) {
        return hash != null && hash.startsWith("$2");
    }

    /**
     * Hash MD5 legado, mantenido únicamente para verificar credenciales sembradas
     * antes de la migración a BCrypt (issue #554); no se usa para generar hashes nuevos.
     */
    private String legacyMd5Hash(String stringAEncriptar) {
        char[] hexChars = {
                '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
        };
        try {
            MessageDigest msgd = MessageDigest.getInstance("MD5");
            byte[] bytes = msgd.digest(stringAEncriptar.getBytes());
            StringBuilder strbCadenaMD5 = new StringBuilder(2 * bytes.length);
            for (int i = 0; i < bytes.length; i++) {
                int bajo = (int) (bytes[i] & 0x0f);
                int alto = (int) ((bytes[i] & 0xf0) >> 4);
                strbCadenaMD5.append(hexChars[alto]);
                strbCadenaMD5.append(hexChars[bajo]);
            }
            return strbCadenaMD5.toString();
        } catch (NoSuchAlgorithmException e) {
            return null;
        }
    }
}