ProductMapper.java

package com.albertsilva.dev.asjcatalog.mapper.product;

import org.springframework.data.domain.Page;
import org.springframework.stereotype.Component;

import com.albertsilva.dev.asjcatalog.domain.catalog.Product;
import com.albertsilva.dev.asjcatalog.dto.category.response.CategoryDetailsResponse;
import com.albertsilva.dev.asjcatalog.dto.category.response.CategoryResponse;
import com.albertsilva.dev.asjcatalog.dto.product.request.ProductCreateRequest;
import com.albertsilva.dev.asjcatalog.dto.product.request.ProductUpdateRequest;
import com.albertsilva.dev.asjcatalog.dto.product.response.ProductDetailsResponse;
import com.albertsilva.dev.asjcatalog.dto.product.response.ProductResponse;

/**
 * Conversor manual (sem MapStruct) entre os DTOs de produto e a entidade
 * {@link Product}. Componente sem estado, usado apenas por
 * {@code ProductService}.
 *
 * <p>
 * <b>Categorias:</b> na <b>entrada</b> (criação/atualização) este mapper
 * <b>não trata categorias</b>: {@code categoryIds} é resolvido e aplicado por
 * {@code ProductService.syncCategories}. Na <b>saída</b>, ele percorre
 * {@code Product.getCategories()} e as converte: em
 * {@link ProductResponse} como
 * {@link com.albertsilva.dev.asjcatalog.dto.category.response.CategoryResponse}
 * (id e nome) e em {@link ProductDetailsResponse} como
 * {@link com.albertsilva.dev.asjcatalog.dto.category.response.CategoryDetailsResponse}
 * (id, nome, descrição e {@code active}). Como a coleção é carregada sob
 * demanda, a conversão só evita consultas adicionais quando o produto foi
 * carregado com {@code JOIN FETCH} (caso de {@code findAllPaged}).
 * </p>
 *
 * <p>
 * Os métodos de conversão devolvem {@code null} para entrada {@code null}; o
 * mapper não normaliza valores.
 * </p>
 */
@Component
public class ProductMapper {

  /**
   * Cria um novo {@link Product} (ainda sem id) a partir do request de criação.
   *
   * <p>
   * Copia {@code name}, {@code description}, {@code price} e {@code imgUrl}.
   * <b>Não copia</b> {@code date} (o campo do request é descartado) nem
   * {@code categoryIds}, e <b>não define</b> {@code active}: a instância fica
   * com o valor padrão do campo ({@code false}) até que
   * {@code ProductService.create} o defina como {@code true}. As datas são
   * preenchidas pelos callbacks JPA.
   * </p>
   *
   * @param request dados de criação
   * @return nova entidade (não persistida) ou {@code null} se {@code request} for
   *         {@code null}
   */
  public Product toEntity(ProductCreateRequest request) {
    if (request == null) {
      return null;
    }

    Product entity = new Product();
    entity.setName(request.name());
    entity.setDescription(request.description());
    entity.setPrice(request.price());
    entity.setImgUrl(request.imgUrl());

    return entity;
  }

  /**
   * Aplica o request de atualização a um produto existente, <b>somente nos
   * campos diferentes de {@code null}</b>.
   *
   * <p>
   * {@code name}, {@code description}, {@code price} e {@code imgUrl} são
   * tratados de forma independente: cada um só é sobrescrito se o valor recebido
   * não for {@code null}; portanto, {@code null} mantém o valor atual e não é
   * possível limpar um campo por esta via. {@code categoryIds} não é tratado
   * aqui (ver {@code ProductService.syncCategories}), e {@code active} e as datas
   * não são alterados. Se {@code request} ou {@code entity} for {@code null},
   * nada é feito.
   * </p>
   *
   * @param request dados de atualização
   * @param entity  produto a ser modificado
   */
  public void updateEntity(ProductUpdateRequest request, Product entity) {
    if (request == null || entity == null) {
      return;
    }

    if (request.name() != null) {
      entity.setName(request.name());
    }

    if (request.description() != null) {
      entity.setDescription(request.description());
    }

    if (request.price() != null) {
      entity.setPrice(request.price());
    }

    if (request.imgUrl() != null) {
      entity.setImgUrl(request.imgUrl());
    }

  }

  /**
   * Converte o produto em resposta <b>resumida</b>: {@code id}, {@code name},
   * {@code description}, {@code price}, {@code imgUrl} e a lista de categorias
   * (cada uma como id e nome).
   *
   * <p>
   * Percorre {@code entity.getCategories()}; se a coleção ainda não estiver
   * inicializada, ela é carregada nesse momento (uma consulta adicional por
   * produto, padrão N+1). Datas e {@code active} não são expostos.
   * </p>
   *
   * @param entity produto
   * @return resposta ou {@code null} se {@code entity} for {@code null}
   */
  public ProductResponse toResponse(Product entity) {
    if (entity == null) {
      return null;
    }

    return new ProductResponse(
        entity.getId(),
        entity.getName(),
        entity.getDescription(),
        entity.getPrice(),
        entity.getImgUrl(),
        entity.getCategories().stream().map(cat -> new CategoryResponse(cat.getId(), cat.getName())).toList());
  }

  /**
   * Converte o produto em resposta <b>detalhada</b>: todos os campos de
   * {@link #toResponse(Product)} mais {@code createdAt}, {@code updatedAt} e
   * {@code active}, com as categorias em forma detalhada (id, nome, descrição e
   * {@code active}).
   *
   * <p>
   * Percorre {@code entity.getCategories()}, com o mesmo efeito de carregamento
   * sob demanda descrito em {@link #toResponse(Product)}.
   * </p>
   *
   * @param entity produto
   * @return resposta ou {@code null} se {@code entity} for {@code null}
   */
  public ProductDetailsResponse toDetailsResponse(Product entity) {
    if (entity == null)
      return null;

    return new ProductDetailsResponse(
        entity.getId(),
        entity.getName(),
        entity.getDescription(),
        entity.getPrice(),
        entity.getImgUrl(),
        entity.getCreatedAt(),
        entity.getUpdatedAt(),
        entity.isActive(),
        entity.getCategories().stream().map(cat -> new CategoryDetailsResponse(
            cat.getId(),
            cat.getName(),
            cat.getDescription(),
            cat.isActive())).toList());
  }

  /**
   * Converte uma página de produtos em página de {@link ProductResponse}
   * aplicando {@link #toResponse(Product)} a cada elemento e preservando os
   * metadados de paginação.
   *
   * @param entities página de entidades
   * @return página de respostas resumidas
   */
  public Page<ProductResponse> toResponsePage(Page<Product> entities) {
    return entities.map(this::toResponse);
  }
}