CategoryService.java

package com.albertsilva.dev.asjcatalog.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import com.albertsilva.dev.asjcatalog.domain.catalog.Category;
import com.albertsilva.dev.asjcatalog.dto.category.request.CategoryCreateRequest;
import com.albertsilva.dev.asjcatalog.dto.category.request.CategoryUpdateRequest;
import com.albertsilva.dev.asjcatalog.dto.category.response.CategoryDetailsResponse;
import com.albertsilva.dev.asjcatalog.dto.category.response.CategoryResponse;
import com.albertsilva.dev.asjcatalog.mapper.category.CategoryMapper;
import com.albertsilva.dev.asjcatalog.repository.CategoryRepository;
import com.albertsilva.dev.asjcatalog.service.exception.ResourceNotFoundException;

import jakarta.persistence.EntityNotFoundException;

/**
 * Serviço de aplicação das categorias ({@link Category}): listagem paginada,
 * consulta, criação, atualização, ativação/desativação e exclusão.
 *
 * <p>
 * <b>Dependências:</b> {@code CategoryRepository} e {@code CategoryMapper}. Este
 * service não chama validators: a unicidade do nome
 * ({@code @CategoryCreateValid} / {@code @CategoryUpdateValid}) e o formato dos
 * campos são validados antes, na camada web. O banco também impõe nome único.
 * </p>
 *
 * <p>
 * <b>Relação com produtos:</b> este service não acessa produtos.
 * {@code Category.products} é o lado inverso do relacionamento (o dono é
 * {@code Product}), e nenhuma regra deste service verifica se a categoria está
 * em uso.
 * </p>
 *
 * <p>
 * <b>Transações:</b> todos os métodos públicos são transacionais (leituras com
 * {@code readOnly = true}); os métodos privados executam na transação do método
 * público que os chamou. <b>Autorização:</b> não é feita aqui, e sim nos
 * controllers ({@code @PreAuthorize}) e na configuração de segurança.
 * <b>Exceção lançada:</b> {@link ResourceNotFoundException}
 * ({@code error.category.notFound}).
 * </p>
 *
 * <p>
 * O indicador {@code active} é apenas gravado por {@link #create},
 * {@link #activate(Long)} e {@link #deactivate(Long)}; nenhuma consulta ou
 * regra deste service o utiliza para filtrar ou bloquear categorias.
 * </p>
 */
@Service
public class CategoryService {

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

  private final CategoryRepository categoryRepository;
  private final CategoryMapper categoryMapper;

  /**
   * Constrói o serviço de categorias com suas dependências principais.
   *
   * @param categoryRepository repositório de categorias
   * @param categoryMapper     responsável pela conversão entre DTOs e entidades
   */
  public CategoryService(CategoryRepository categoryRepository, CategoryMapper categoryMapper) {
    this.categoryRepository = categoryRepository;
    this.categoryMapper = categoryMapper;
  }

  /**
   * Lista categorias de forma paginada, com filtro opcional por nome.
   *
   * <p>
   * O termo recebe {@code trim}; nulo, vazio ou só com espaços significa "sem
   * filtro" e usa {@code findAll(pageable)}. Com filtro, usa
   * {@code findByNameContainingIgnoreCase}. A resposta ({@code id} e
   * {@code name}) não acessa relacionamentos, então a conversão não dispara
   * consultas adicionais.
   * </p>
   *
   * @param name     termo procurado no nome (opcional)
   * @param pageable página, tamanho e ordenação
   * @return página de categorias
   */
  @Transactional(readOnly = true)
  public Page<CategoryResponse> search(String name, Pageable pageable) {

    String filter = StringUtils.hasText(name) ? name.trim() : null;

    logger.debug("Buscando categorias | filtro={} | page={} | size={} | sort={}", filter, pageable.getPageNumber(),
        pageable.getPageSize(), pageable.getSort());

    Page<Category> categoriesPage = (filter != null)
        ? categoryRepository.findByNameContainingIgnoreCase(filter, pageable)
        : categoryRepository.findAll(pageable);

    logger.debug("Busca concluída | total={}", categoriesPage.getTotalElements());

    return categoryMapper.toResponsePage(categoriesPage);
  }

  /**
   * Retorna os detalhes de uma categoria (nome, descrição e indicador
   * {@code active}).
   *
   * @param id identificador da categoria
   * @return detalhes da categoria
   * @throws ResourceNotFoundException ({@code error.category.notFound}) se a
   *                                   categoria não existir
   */
  @Transactional(readOnly = true)
  public CategoryDetailsResponse findById(Long id) {
    return categoryMapper.toDetailsResponse(findEntityById(id));
  }

  /**
   * Cria uma categoria <b>ativa</b>.
   *
   * <p>
   * O mapper copia nome e descrição do DTO; a categoria é marcada como ativa
   * e salva. A unicidade do nome é validada antes, por
   * {@code @CategoryCreateValid}; uma violação da restrição única do banco
   * ocorreria na gravação e não é tratada aqui.
   * </p>
   *
   * @param categoryCreateRequest dados da nova categoria
   * @return categoria criada ({@code id} e {@code name})
   */
  @Transactional
  public CategoryResponse create(CategoryCreateRequest categoryCreateRequest) {
    logger.debug("Inserindo nova categoria - dados: {}", categoryCreateRequest);
    Category entity = categoryMapper.toEntity(categoryCreateRequest);
    entity.setActive(true);
    entity = categoryRepository.save(entity);
    logger.info("Categoria criada com sucesso. id: {}", entity.getId());
    return categoryMapper.toResponse(entity);
  }

  /**
   * Atualiza nome e descrição de uma categoria.
   *
   * <p>
   * Usa {@code getReferenceById} (referência preguiçosa): a existência só é
   * verificada quando o proxy é acessado (pelo mapper ou por {@code save}),
   * dentro do bloco {@code try}; se a categoria não existir, o
   * {@code EntityNotFoundException} da JPA é convertido em
   * {@link ResourceNotFoundException}. O {@code CategoryMapper.updateEntity}
   * só sobrescreve campos não nulos do DTO. A unicidade do nome (excluindo a
   * própria categoria) é validada antes, por {@code @CategoryUpdateValid}. O
   * indicador {@code active} não é alterado.
   * </p>
   *
   * @param id                    identificador da categoria
   * @param categoryUpdateRequest dados da atualização
   * @return categoria atualizada ({@code id} e {@code name})
   * @throws ResourceNotFoundException se a categoria não existir
   */
  @Transactional
  public CategoryResponse update(Long id, CategoryUpdateRequest categoryUpdateRequest) {
    logger.debug("Atualizando categoria. id: {}", id);

    try {
      Category entity = categoryRepository.getReferenceById(id);
      categoryMapper.updateEntity(categoryUpdateRequest, entity);
      entity = categoryRepository.save(entity);

      logger.info("Categoria atualizada com sucesso. id: {}", id);
      return categoryMapper.toResponse(entity);

    } catch (EntityNotFoundException e) {
      logger.warn("Falha ao atualizar categoria. Categoria não encontrada. id: {}", id);
      throw new ResourceNotFoundException("error.category.notFound");
    }
  }

  /**
   * Marca a categoria como ativa. Operação idempotente: se já estiver ativa,
   * nada é alterado. Não chama {@code save}: a alteração da entidade
   * gerenciada é gravada no commit.
   *
   * @param id identificador da categoria
   * @throws ResourceNotFoundException se a categoria não existir
   */
  @Transactional
  public void activate(Long id) {
    changeStatus(id, true);
  }

  /**
   * Marca a categoria como inativa. Operação idempotente: se já estiver
   * inativa, nada é alterado. Não chama {@code save}: a alteração da entidade
   * gerenciada é gravada no commit. O indicador não oculta a categoria de
   * {@code search} nem afeta os produtos associados.
   *
   * @param id identificador da categoria
   * @throws ResourceNotFoundException se a categoria não existir
   */
  @Transactional
  public void deactivate(Long id) {
    changeStatus(id, false);
  }

  /**
   * Remove fisicamente uma categoria.
   *
   * <p>
   * Carrega a categoria com {@code findById} (404 se não existir) e chama
   * {@code delete}. Não há verificação de produtos vinculados: como
   * {@code Category} é o lado inverso do relacionamento com {@code Product}, a
   * JPA não remove os vínculos de {@code tb_product_category}. Se houver
   * produtos associados, a violação da chave estrangeira tende a ser detectada
   * no commit, depois do retorno do método, e propagada como
   * {@code DataIntegrityViolationException} (não tratada aqui; o handler
   * global a converte em resposta HTTP 409).
   * </p>
   *
   * @param id identificador da categoria
   * @throws ResourceNotFoundException se a categoria não existir
   */
  @Transactional
  public void delete(Long id) {
    logger.debug("Deletando categoria. id: {}", id);

    Category entity = findEntityById(id);
    categoryRepository.delete(entity);
    logger.info("Categoria deletada com sucesso. id: {}", id);
  }

  /**
   * Busca uma entidade {@link Category} por ID.
   *
   * <p>
   * Realiza consulta imediata e lança exceção caso não encontre a entidade.
   * </p>
   *
   * @param id identificador da categoria
   * @return entidade encontrada
   * @throws ResourceNotFoundException caso a categoria não exista
   */
  private Category findEntityById(Long id) {
    logger.debug("Buscando categoria por id: {}", id);

    return categoryRepository.findById(id).orElseThrow(() -> {
      logger.warn("Categoria não encontrada. id: {}", id);
      return new ResourceNotFoundException("error.category.notFound");
    });
  }

  /**
   * Define o indicador {@code active} da categoria, se for diferente do atual.
   *
   * <p>
   * Carrega a categoria com {@code findById}; se o valor já for o desejado,
   * apenas retorna. Não chama {@code save}: a mudança é gravada no commit.
   * </p>
   *
   * @param id     identificador da categoria
   * @param active valor desejado ({@code true} = ativa)
   * @throws ResourceNotFoundException se a categoria não existir
   */
  private void changeStatus(Long id, boolean active) {
    Category entity = findEntityById(id);

    if (entity.isActive() == active) {
      logger.debug("Status já definido | id={} | active={}", id, active);
      return;
    }

    entity.setActive(active);

    logger.info("Status alterado | id={} | active={}", id, active);
  }

}