ControllerExceptionHandler.java

package com.albertsilva.dev.asjcatalog.web.exception.handler;

import java.time.Instant;
import java.util.Locale;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.context.NoSuchMessageException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;

import com.albertsilva.dev.asjcatalog.service.exception.AuthenticatedUserNotFoundException;
import com.albertsilva.dev.asjcatalog.service.exception.DatabaseException;
import com.albertsilva.dev.asjcatalog.service.exception.InvalidTokenException;
import com.albertsilva.dev.asjcatalog.service.exception.PasswordUpdateException;
import com.albertsilva.dev.asjcatalog.service.exception.ResourceNotFoundException;
import com.albertsilva.dev.asjcatalog.web.exception.enums.ApiErrorCode;
import com.albertsilva.dev.asjcatalog.web.exception.response.ProblemDetails;
import com.albertsilva.dev.asjcatalog.web.exception.response.ValidationError;

import jakarta.servlet.http.HttpServletRequest;

/**
 * Tratamento global de exceções lançadas pelos controllers e pelas camadas
 * abaixo deles ({@code @RestControllerAdvice}). Converte cada exceção em uma
 * resposta JSON com {@link ProblemDetails} (ou {@link ValidationError}).
 *
 * <p>
 * <b>Formato:</b> {@code ProblemDetails} é uma classe <b>própria</b> do projeto
 * ({@code timestamp}, {@code status}, {@code code}, {@code error},
 * {@code message}, {@code path}); <b>não</b> é o
 * {@code org.springframework.http.ProblemDetail} e não segue o RFC 7807. Título
 * e detalhe são textos do {@code MessageSource} no idioma da requisição
 * ({@code Locale} do cabeçalho {@code Accept-Language}; padrão {@code pt_BR}).
 * O {@code code} é o {@link ApiErrorCode}, estável entre idiomas.
 * </p>
 *
 * <p>
 * <b>Exceções tratadas</b> (o Spring escolhe o handler pelo tipo de exceção
 * <em>mais específico</em>, não pela ordem de declaração):
 * </p>
 * <table>
 * <caption>Mapeamento exceção → resposta</caption>
 * <tr><th>Exceção</th><th>HTTP</th><th>{@code code}</th></tr>
 * <tr><td>{@code ResourceNotFoundException}</td><td>404</td><td>RESOURCE_NOT_FOUND</td></tr>
 * <tr><td>{@code NoResourceFoundException}</td><td>404</td><td>RESOURCE_NOT_FOUND</td></tr>
 * <tr><td>{@code DatabaseException}</td><td>400</td><td>DATABASE_ERROR</td></tr>
 * <tr><td>{@code DataIntegrityViolationException}</td><td>409</td><td>CONFLICT</td></tr>
 * <tr><td>{@code MethodArgumentNotValidException}</td><td>422</td><td>VALIDATION_ERROR (com {@code fieldErrors})</td></tr>
 * <tr><td>{@code AccessDeniedException}</td><td>403</td><td>ACCESS_DENIED</td></tr>
 * <tr><td>{@code InvalidTokenException}</td><td>400</td><td>INVALID_TOKEN</td></tr>
 * <tr><td>{@code AuthenticatedUserNotFoundException}</td><td>401</td><td>AUTHENTICATION_REQUIRED</td></tr>
 * <tr><td>{@code PasswordUpdateException}</td><td>422</td><td>PASSWORD_UPDATE_ERROR</td></tr>
 * <tr><td>{@code DisabledException}</td><td>403</td><td>ACCESS_DISABLED</td></tr>
 * <tr><td>{@code Exception} (qualquer outra)</td><td>500</td><td>INTERNAL_SERVER_ERROR</td></tr>
 * </table>
 *
 * <p>
 * <b>Consequências do handler genérico:</b> a classe não estende
 * {@code ResponseEntityExceptionHandler} e não há handler para
 * {@code HttpRequestMethodNotSupportedException}, {@code HttpMessageNotReadableException},
 * {@code MethodArgumentTypeMismatchException}, {@code MissingServletRequestParameterException},
 * {@code HttpMediaTypeNotSupportedException}, {@code ConstraintViolationException},
 * {@code AuthenticationException}, {@code UnsupportedOperationException} etc.:
 * todas caem em {@link #handleGeneric} e viram <b>{@code 500}</b> (em vez de
 * 405, 400, 415...). Isso foi observado nos logs do baseline (um {@code PATCH}
 * em rota {@code PUT} resultou em {@code 500}).
 * </p>
 *
 * <p>
 * <b>Escopo:</b> só exceções que chegam ao {@code DispatcherServlet}. Falhas de
 * autenticação do filtro de bearer token (token ausente, inválido ou expirado)
 * são respondidas antes, pela cadeia de segurança, sem passar por este
 * advice; já o {@code AccessDeniedException} de {@code @PreAuthorize} é lançado
 * dentro do controller e é tratado aqui.
 * </p>
 *
 * <p>
 * <b>Logs:</b> {@code WARN} para 404, 403 e 401 de identidade e token inválido;
 * {@code ERROR}, com a exceção completa (stack trace), para
 * {@code DatabaseException}, {@code DataIntegrityViolationException} e
 * {@code Exception}; {@code DEBUG} para recurso estático; nenhum log para
 * validação, {@code PasswordUpdateException} e {@code DisabledException}. O
 * corpo da resposta nunca contém stack trace nem a mensagem crua da exceção.
 * </p>
 */
@RestControllerAdvice
public class ControllerExceptionHandler {

  private static final Logger logger = LoggerFactory.getLogger(ControllerExceptionHandler.class);

  private final MessageSource messageSource;

  /**
   * @param messageSource fonte das mensagens traduzidas (bean de {@code MessageSourceConfig})
   */
  public ControllerExceptionHandler(MessageSource messageSource) {
    this.messageSource = messageSource;
  }

  /**
   * {@code ResourceNotFoundException} → {@code 404}, {@code RESOURCE_NOT_FOUND}.
   * O detalhe é a mensagem traduzida da <b>chave</b> carregada pela exceção (por
   * exemplo, {@code error.user.notFound}); se a chave não existir, usa
   * {@code error.resource.message}. Log {@code WARN}.
   */
  @ExceptionHandler(ResourceNotFoundException.class)
  public ResponseEntity<ProblemDetails> handleResourceNotFound(ResourceNotFoundException e,
      HttpServletRequest request, Locale locale) {
    HttpStatus status = HttpStatus.NOT_FOUND;
    logger.warn("ResourceNotFoundException - path: {}, message: {}", request.getRequestURI(), e.getMessage());
    ProblemDetails err = buildProblemDetails(status, ApiErrorCode.RESOURCE_NOT_FOUND,
        "error.resource.title", e.getMessage(), "error.resource.message", request.getRequestURI(), locale);
    return ResponseEntity.status(status).body(err);
  }

  /**
   * {@code DatabaseException} → {@code 400}, {@code DATABASE_ERROR}. Detalhe pela
   * chave da exceção, com padrão {@code error.database.message}. Log
   * {@code ERROR} com stack trace.
   */
  @ExceptionHandler(DatabaseException.class)
  public ResponseEntity<ProblemDetails> handleDatabase(DatabaseException e, HttpServletRequest request, Locale locale) {
    HttpStatus status = HttpStatus.BAD_REQUEST;
    logger.error("DatabaseException - path: {}, message: {}", request.getRequestURI(), e.getMessage(), e);
    ProblemDetails err = buildProblemDetails(status, ApiErrorCode.DATABASE_ERROR,
        "error.database.title", e.getMessage(), "error.database.message", request.getRequestURI(), locale);
    return ResponseEntity.status(status).body(err);
  }

  /**
   * {@code DataIntegrityViolationException} → {@code 409}, {@code CONFLICT}, com
   * texto genérico ({@code error.conflict.message}); a mensagem da exceção
   * (que pode citar restrições do banco) só vai para o log {@code ERROR}, com
   * stack trace.
   */
  @ExceptionHandler(DataIntegrityViolationException.class)
  public ResponseEntity<ProblemDetails> handleDataIntegrity(DataIntegrityViolationException e,
      HttpServletRequest request, Locale locale) {
    HttpStatus status = HttpStatus.CONFLICT;
    logger.error("DataIntegrityViolationException - path: {}, message: {}", request.getRequestURI(), e.getMessage(), e);
    ProblemDetails err = buildProblemDetails(status, ApiErrorCode.CONFLICT,
        "error.conflict.title", "error.conflict.message", request.getRequestURI(), locale);
    return ResponseEntity.status(status).body(err);
  }

  /**
   * {@code NoResourceFoundException} (rota ou recurso estático inexistente) →
   * {@code 404}, {@code RESOURCE_NOT_FOUND}, texto genérico. Log {@code DEBUG}.
   */
  @ExceptionHandler(NoResourceFoundException.class)
  public ResponseEntity<ProblemDetails> handleNoResourceFound(NoResourceFoundException e, HttpServletRequest request,
      Locale locale) {
    HttpStatus status = HttpStatus.NOT_FOUND;
    logger.debug("Static resource not found - path: {}", request.getRequestURI());
    ProblemDetails err = buildProblemDetails(status, ApiErrorCode.RESOURCE_NOT_FOUND,
        "error.resource.title", "error.resource.message", request.getRequestURI(), locale);
    return ResponseEntity.status(status).body(err);
  }

  /**
   * {@code MethodArgumentNotValidException} (falha do Bean Validation em
   * {@code @Valid @RequestBody}) → {@code 422}, {@code VALIDATION_ERROR}, com
   * {@link ValidationError}.
   *
   * <p>
   * Para cada <b>erro de campo</b> ({@code getFieldErrors()}) acrescenta um
   * {@code FieldMessage} (nome do campo e mensagem já interpolada). Vários erros
   * do mesmo campo geram várias entradas. Erros <b>globais</b>
   * ({@code getGlobalErrors()}) não são incluídos; hoje todos os validators de
   * classe associam a violação a um campo, então nada é perdido. Sem log.
   * </p>
   */
  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity<ProblemDetails> validation(MethodArgumentNotValidException e, HttpServletRequest request,
      Locale locale) {
    HttpStatus status = HttpStatus.UNPROCESSABLE_ENTITY;
    ValidationError err = buildValidationError(status, ApiErrorCode.VALIDATION_ERROR,
        "error.validation.title", "error.validation.message", request.getRequestURI(), locale);
    e.getBindingResult().getFieldErrors().forEach(f -> err.addError(f.getField(), f.getDefaultMessage()));
    return ResponseEntity.status(status).body(err);
  }

  /**
   * {@code AccessDeniedException} → {@code 403}, {@code ACCESS_DENIED}. Trata a
   * negação de {@code @PreAuthorize} (lançada dentro do controller); inclui o
   * caso de usuário anônimo em rota liberada por URL mas protegida por método
   * (por exemplo, {@code GET /accounts/me}), que responde {@code 403} aqui e não
   * {@code 401}. Log {@code WARN}.
   */
  @ExceptionHandler(AccessDeniedException.class)
  public ResponseEntity<ProblemDetails> handleAccessDenied(AccessDeniedException e, HttpServletRequest request,
      Locale locale) {
    HttpStatus status = HttpStatus.FORBIDDEN;
    logger.warn("AccessDeniedException - path: {}, message: {}", request.getRequestURI(), e.getMessage());
    ProblemDetails err = buildProblemDetails(status, ApiErrorCode.ACCESS_DENIED,
        "error.access.title", "error.access.message", request.getRequestURI(), locale);
    return ResponseEntity.status(status).body(err);
  }

  /**
   * {@code InvalidTokenException} (token de ativação/recuperação de tipo errado,
   * desabilitado ou expirado) → {@code 400}, {@code INVALID_TOKEN}. Detalhe pela
   * chave da exceção ({@code error.token.type.invalid}, {@code error.token.disabled},
   * {@code error.token.expired}). Não se refere ao JWT. Log {@code WARN}.
   */
  @ExceptionHandler(InvalidTokenException.class)
  public ResponseEntity<ProblemDetails> handleInvalidToken(InvalidTokenException e, HttpServletRequest request,
      Locale locale) {
    HttpStatus status = HttpStatus.BAD_REQUEST;
    logger.warn("InvalidTokenException - path: {}, message: {}", request.getRequestURI(), e.getMessage());
    ProblemDetails err = buildProblemDetails(status, ApiErrorCode.INVALID_TOKEN,
        "error.invalid.token.title", e.getMessage(), "error.invalid.token.message", request.getRequestURI(), locale);
    return ResponseEntity.status(status).body(err);
  }

  /**
   * {@code AuthenticatedUserNotFoundException} (JWT sem principal válido, sem
   * claim {@code userId} ou com usuário inexistente) → {@code 401},
   * {@code AUTHENTICATION_REQUIRED}. Detalhe pela chave da exceção
   * ({@code error.auth.invalid.principal}, {@code error.auth.userId.claim.notFound}
   * ou {@code error.auth.user.notFound}), traduzida nos três idiomas; se a chave
   * não existir, usa {@code error.authentication.message}. Log {@code WARN}.
   */
  @ExceptionHandler(AuthenticatedUserNotFoundException.class)
  public ResponseEntity<ProblemDetails> handleAuthenticatedUserNotFound(AuthenticatedUserNotFoundException e,
      HttpServletRequest request, Locale locale) {
    HttpStatus status = HttpStatus.UNAUTHORIZED;
    logger.warn("AuthenticatedUserNotFoundException - path: {}, message: {}", request.getRequestURI(), e.getMessage());
    ProblemDetails err = buildProblemDetails(status, ApiErrorCode.AUTHENTICATION_REQUIRED,
        "error.authentication.title", e.getMessage(), "error.authentication.message", request.getRequestURI(), locale);

    return ResponseEntity.status(status).body(err);
  }

  /**
   * {@code PasswordUpdateException} (confirmação divergente, senha atual
   * incorreta, nova senha igual à atual) → {@code 422},
   * {@code PASSWORD_UPDATE_ERROR}. Detalhe pela chave da exceção. Sem log.
   */
  @ExceptionHandler(PasswordUpdateException.class)
  public ResponseEntity<ProblemDetails> handlePasswordUpdate(PasswordUpdateException e, HttpServletRequest request,
      Locale locale) {

    HttpStatus status = HttpStatus.UNPROCESSABLE_ENTITY;

    ProblemDetails err = buildProblemDetails(status, ApiErrorCode.PASSWORD_UPDATE_ERROR, "error.password.update.title",
        e.getMessage(), "error.password.update.message", request.getRequestURI(), locale);

    return ResponseEntity.status(status).body(err);
  }

  /**
   * {@code DisabledException} → {@code 403}, {@code ACCESS_DISABLED}. Nenhum
   * código da aplicação lança essa exceção (o login com conta inativa é
   * tratado no provider OAuth2, que usa {@code OAuth2AuthenticationException}), e
   * não há provider padrão do Spring configurado; provavelmente o handler não é
   * alcançado hoje. Sem log.
   */
  @ExceptionHandler(DisabledException.class)
  public ResponseEntity<ProblemDetails> handleDisabledException(DisabledException e, HttpServletRequest request,
      Locale locale) {
    HttpStatus status = HttpStatus.FORBIDDEN;
    ProblemDetails err = buildProblemDetails(status, ApiErrorCode.ACCESS_DISABLED,
        "error.account.disabled.title", "error.account.disabled.message", request.getRequestURI(), locale);
    return ResponseEntity.status(status).body(err);
  }

  /**
   * Qualquer outra {@code Exception} → {@code 500}, {@code INTERNAL_SERVER_ERROR},
   * com texto genérico. Log {@code ERROR} com a exceção completa.
   *
   * <p>
   * Inclui exceções do próprio Spring MVC sem handler específico, como
   * {@code HttpRequestMethodNotSupportedException} (405), corpo JSON ilegível,
   * parâmetro obrigatório ausente e tipo de argumento inválido, e também
   * {@code UnsupportedOperationException} de {@code POST /accounts/deactivate}.
   * </p>
   */
  @ExceptionHandler(Exception.class)
  public ResponseEntity<ProblemDetails> handleGeneric(Exception e, HttpServletRequest request, Locale locale) {
    HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
    logger.error("Unexpected error - path: {}", request.getRequestURI(), e);
    ProblemDetails err = buildProblemDetails(status, ApiErrorCode.INTERNAL_SERVER_ERROR,
        "error.internal.title", "error.internal.message", request.getRequestURI(), locale);
    return ResponseEntity.status(status).body(err);
  }

  /**
   * Monta a resposta com título e detalhe obtidos de <b>duas chaves fixas</b> do
   * {@code MessageSource} (sem usar a mensagem da exceção); {@code timestamp} =
   * instante atual.
   */
  private ProblemDetails buildProblemDetails(HttpStatus status, ApiErrorCode code, String titleKey, String messageKey,
      String path, Locale locale) {
    return new ProblemDetails(Instant.now(), status.value(), code,
        getMessage(titleKey, locale), getMessage(messageKey, locale), path);
  }

  /**
   * Monta a resposta cujo detalhe vem da <b>chave carregada pela exceção</b>
   * ({@code detailCodeOrMessage}), com recuo para {@code fallbackMessageKey}.
   */
  private ProblemDetails buildProblemDetails(HttpStatus status, ApiErrorCode code, String titleKey,
      String detailCodeOrMessage, String fallbackMessageKey, String path, Locale locale) {
    return new ProblemDetails(Instant.now(), status.value(), code,
        getMessage(titleKey, locale), resolveMessage(detailCodeOrMessage, fallbackMessageKey, locale), path);
  }

  /**
   * Cria o {@link ValidationError} (lista de erros de campo vazia, a ser
   * preenchida pelo chamador).
   */
  private ValidationError buildValidationError(HttpStatus status, ApiErrorCode code, String titleKey, String messageKey,
      String path, Locale locale) {
    return new ValidationError(Instant.now(), status.value(), code,
        getMessage(titleKey, locale), getMessage(messageKey, locale), path);
  }

  /**
   * Traduz {@code codeOrMessage} como chave do {@code MessageSource}; se for
   * {@code null}, ou se a chave não existir, devolve a mensagem de
   * {@code fallbackKey}. Assim, uma exceção lançada com texto que não é chave não
   * vaza texto interno para o cliente.
   */
  private String resolveMessage(String codeOrMessage, String fallbackKey, Locale locale) {
    if (codeOrMessage == null) {
      return getMessage(fallbackKey, locale);
    }

    try {
      String message = messageSource.getMessage(codeOrMessage, null, locale);
      return message != null ? message : getMessage(fallbackKey, locale);
    } catch (NoSuchMessageException e) {
      return getMessage(fallbackKey, locale);
    }
  }

  /**
   * Busca a mensagem da chave no idioma informado; se a chave não existir,
   * devolve a própria chave como texto.
   */
  private String getMessage(String key, Locale locale) {
    return messageSource.getMessage(key, null, key, locale);
  }
}