ProductService.java

package com.rbctcsworld.ecommerce.product;

import jakarta.persistence.criteria.Predicate;
import org.springframework.data.jpa.domain.Specification;
import java.util.ArrayList;
import java.util.Locale;

import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
import com.rbctcsworld.ecommerce.common.exception.ConflictException;
import com.rbctcsworld.ecommerce.common.exception.NotFoundException;
import com.rbctcsworld.ecommerce.audit.AuditChange;
import com.rbctcsworld.ecommerce.audit.AuditEvent;
import com.rbctcsworld.ecommerce.inventory.InventoryService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;

@Service
@Transactional
public class ProductService {

    private final ProductRepository repo;
    private final ApplicationEventPublisher events;
    private final InventoryService inventory;

    public ProductService(ProductRepository repo, ApplicationEventPublisher events, InventoryService inventory) {
        this.repo = repo;
        this.events = events;
        this.inventory = inventory;
    }

    /** The product fields the audit trail follows (ratings change with every review and are not audited). */
    static Map<String, Object> audited(Product p) {
        return AuditChange.fields("name", p.getName(), "sku", p.getSku(), "category", p.getCategory(),
                "price", p.getPrice(), "stock", p.getStock(), "imageUrl", p.getImageUrl(), "active", p.isActive(),
                "variantGroup", p.getVariantGroup(), "size", p.getSize(), "color", p.getColor(),
                "dealPrice", p.getDealPrice(), "dealStartsAt", p.getDealStartsAt(), "dealEndsAt", p.getDealEndsAt());
    }

    private void audit(Product p, String action, Map<String, Object> before) {
        events.publishEvent(AuditEvent.of(AuditChange.diff(AuditChange.PRODUCT, p.getId(), action, before, audited(p))));
    }

    public static final int DEFAULT_PAGE_SIZE = 20;
    public static final int MAX_PAGE_SIZE = 100;
    public static final List<String> SORTS = List.of("id", "name", "price_asc", "price_desc");

    /**
     * DEF-007: one page of the active catalog instead of every product. Page 0-based, size 1..100.
     * Every sort ends with the id, so pages are stable (no product appears twice or goes missing).
     */
    @Transactional(readOnly = true)
    public Page<Product> page(String q, int page, int size, String sort) {
        return page(q, page, size, sort, ProductFilter.NONE);
    }

    /**
     * Like {@link #page(String, int, int, String)} with the extra filters of {@link ProductFilter}.
     * minPrice/maxPrice may not be negative and maxPrice may not be below minPrice (400).
     */
    @Transactional(readOnly = true)
    public Page<Product> page(String q, int page, int size, String sort, ProductFilter filter) {
        if (page < 0) throw new BusinessRuleException("page must be 0 or greater");
        if (size < 1 || size > MAX_PAGE_SIZE) throw new BusinessRuleException("size must be between 1 and " + MAX_PAGE_SIZE);
        String s = sort == null || sort.isBlank() ? "id" : sort.trim().toLowerCase();
        if (!SORTS.contains(s)) throw new BusinessRuleException("sort must be one of " + SORTS);
        PageRequest request = PageRequest.of(page, size, sortOf(s));
        ProductFilter f = checked(filter == null ? ProductFilter.NONE : filter);
        if (!f.isEmpty()) return repo.findAll(spec(q, f), request);
        return (q == null || q.isBlank())
                ? repo.findByActiveTrue(request)
                : repo.findByNameContainingIgnoreCaseAndActiveTrue(q.trim(), request);
    }

    /**
     * active AND name contains q AND every filter of {@link ProductFilter} (each when given). grouped: one row per
     * item - a single product, or the first variant (lowest id) of its group that passes the same filters.
     */
    static Specification<Product> spec(String q, ProductFilter f) {
        return (root, query, cb) -> {
            List<Predicate> where = new ArrayList<>();
            where.add(cb.isTrue(root.<Boolean>get("active")));
            if (q != null && !q.isBlank()) {
                where.add(cb.like(cb.lower(root.<String>get("name")), "%" + q.trim().toLowerCase(Locale.ROOT) + "%"));
            }
            where.addAll(variantFilters(cb, root, f));
            if (Boolean.TRUE.equals(f.grouped())) {
                var first = query.subquery(Long.class);
                var v = first.from(Product.class);
                List<Predicate> same = new ArrayList<>(variantFilters(cb, v, f));
                same.add(cb.equal(v.get("variantGroup"), root.get("variantGroup")));
                same.add(cb.isTrue(v.<Boolean>get("active")));
                first.select(cb.min(v.<Long>get("id"))).where(same.toArray(new Predicate[0]));
                where.add(cb.or(cb.isNull(root.get("variantGroup")), cb.equal(root.get("id"), first)));
            }
            return cb.and(where.toArray(new Predicate[0]));
        };
    }

    /** The filters that look at one product row (the in-memory twin is ProductFilter.matches). */
    private static List<Predicate> variantFilters(jakarta.persistence.criteria.CriteriaBuilder cb,
                                                  jakarta.persistence.criteria.Path<Product> p, ProductFilter f) {
        List<Predicate> where = new ArrayList<>();
        if (!f.categories().isEmpty()) where.add(cb.lower(p.<String>get("category")).in(f.categories()));
        if (f.minPrice() != null) where.add(cb.greaterThanOrEqualTo(p.<java.math.BigDecimal>get("price"), f.minPrice()));
        if (f.maxPrice() != null) where.add(cb.lessThanOrEqualTo(p.<java.math.BigDecimal>get("price"), f.maxPrice()));
        if (Boolean.TRUE.equals(f.inStock())) where.add(cb.greaterThan(p.<Integer>get("stock"), 0));
        if (f.minRating() != null) where.add(cb.greaterThanOrEqualTo(p.<java.math.BigDecimal>get("ratingAverage"), f.minRating()));
        if (!f.colorList().isEmpty()) where.add(cb.lower(p.<String>get("color")).in(f.colorList()));
        if (!f.sizeList().isEmpty()) where.add(cb.lower(p.<String>get("size")).in(f.sizeList()));
        return where;
    }

    public static final int MAX_FACET_PRODUCTS = 5000;
    public static final int MAX_FILTER_VALUES = 20;

    /**
     * Counts for the catalog's filter panel, for the products matching q and the other filters ("disjunctive"
     * facets: ticking "books" does not hide the count of "home"). Counts are ITEMS (cards), not variants.
     * Computed in memory over at most 5000 matching products: enough for this shop; a big one would use the
     * aggregations of a search engine (OpenSearch, Elasticsearch) instead.
     */
    @Transactional(readOnly = true)
    public Facets facets(String q, ProductFilter filter) {
        ProductFilter f = checked(filter == null ? ProductFilter.NONE : filter);
        List<Product> candidates = repo.findAll(spec(q, ProductFilter.NONE), PageRequest.of(0, MAX_FACET_PRODUCTS, Sort.by("id"))).getContent();
        return Facets.count(candidates, f);
    }

    /** The checks every filtered query shares (400 for each). */
    static ProductFilter checked(ProductFilter f) {
        if (f.minPrice() != null && f.minPrice().signum() < 0) throw new BusinessRuleException("minPrice cannot be negative");
        if (f.maxPrice() != null && f.maxPrice().signum() < 0) throw new BusinessRuleException("maxPrice cannot be negative");
        if (f.minPrice() != null && f.maxPrice() != null && f.maxPrice().compareTo(f.minPrice()) < 0) {
            throw new BusinessRuleException("maxPrice must not be below minPrice");
        }
        if (f.minRating() != null && (f.minRating().signum() < 0 || f.minRating().compareTo(java.math.BigDecimal.valueOf(5)) > 0)) {
            throw new BusinessRuleException("minRating must be between 0 and 5");
        }
        if (f.categories().size() > MAX_FILTER_VALUES || f.colorList().size() > MAX_FILTER_VALUES || f.sizeList().size() > MAX_FILTER_VALUES) {
            throw new BusinessRuleException("at most " + MAX_FILTER_VALUES + " values per filter");
        }
        return f;
    }

    public static final int MAX_SIMILAR = 12;

    /**
     * Up to {@code limit} (1..12, default 4) products like this one: same category, in stock first,
     * closest price first. 404 when the product itself is not on sale.
     */
    @Transactional(readOnly = true)
    public List<Product> similar(long id, Integer limit) {
        int n = limit == null ? 4 : limit;
        if (n < 1 || n > MAX_SIMILAR) throw new BusinessRuleException("limit must be between 1 and " + MAX_SIMILAR);
        Product p = get(id);
        // other sizes and colours of the same item are not "similar products" (the product page offers them)
        int siblings = p.getVariantGroup() == null ? 0 : repo.variantsOf(p.getVariantGroup()).size();
        PageRequest first = PageRequest.of(0, n + siblings);
        List<Product> found = p.getCategory() == null || p.getCategory().isBlank()
                ? repo.similarByPrice(p.getId(), p.getPrice(), first)
                : repo.similarInCategory(p.getId(), p.getCategory(), p.getPrice(), first);
        return found.stream()
                .filter(o -> p.getVariantGroup() == null || !p.getVariantGroup().equals(o.getVariantGroup()))
                .limit(n).toList();
    }

    /**
     * "Customers who bought this also bought": the products that most often share an order with this item (all of
     * its sizes and colours count as the item). Only products on sale and in stock, one per item (variant group),
     * most frequent first; fewer than {@code limit} when there is little history; empty when nobody bought it yet.
     */
    @Transactional(readOnly = true)
    public List<Product> boughtTogether(long id, Integer limit) {
        int n = limit == null ? 4 : limit;
        if (n < 1 || n > MAX_SIMILAR) throw new BusinessRuleException("limit must be between 1 and " + MAX_SIMILAR);
        Product p = get(id);
        List<Long> sources = p.getVariantGroup() == null ? List.of(p.getId())
                : repo.variantsOf(p.getVariantGroup()).stream().map(Product::getId).toList();
        // ask for more than n: sold-out and repeated variants of one item are dropped below
        List<Object[]> ranked = repo.boughtWith(sources, PageRequest.of(0, n * 6 + 12));
        if (ranked.isEmpty()) return List.of();
        Map<Long, Product> byId = new java.util.HashMap<>();
        repo.findAllById(ranked.stream().map(r -> ((Number) r[0]).longValue()).toList()).forEach(x -> byId.put(x.getId(), x));
        List<Product> out = new ArrayList<>();
        java.util.Set<String> seen = new java.util.HashSet<>();
        for (Object[] row : ranked) {
            Product c = byId.get(((Number) row[0]).longValue());
            if (c == null || !c.isActive() || c.getStock() < 1) continue;
            if (!seen.add(c.getVariantGroup() == null ? "P" + c.getId() : "G" + c.getVariantGroup())) continue;
            out.add(c);
            if (out.size() == n) break;
        }
        return out;
    }

    /** Every size and colour of this product's item (itself included); a product without variants: just itself. */
    @Transactional(readOnly = true)
    public List<Product> variants(long id) {
        Product p = get(id);
        return p.getVariantGroup() == null ? List.of(p) : repo.variantsOf(p.getVariantGroup());
    }

    public static final int MAX_GROUPS = 100;

    /** For catalog cards: per variant group, how many options, which colours and sizes, price range, units. */
    @Transactional(readOnly = true)
    public List<VariantSummary> variantSummaries(List<String> groups) {
        List<String> wanted = groups == null ? List.of() : groups.stream()
                .filter(g -> g != null && !g.isBlank()).map(g -> g.trim().toUpperCase(Locale.ROOT)).distinct().toList();
        if (wanted.size() > MAX_GROUPS) throw new BusinessRuleException("at most " + MAX_GROUPS + " groups at a time");
        if (wanted.isEmpty()) return List.of();
        Map<String, List<Product>> byGroup = new java.util.LinkedHashMap<>();
        wanted.forEach(g -> byGroup.put(g, new ArrayList<>()));
        repo.variantsOfAll(wanted).forEach(p -> byGroup.get(p.getVariantGroup()).add(p));
        return byGroup.entrySet().stream().filter(e -> !e.getValue().isEmpty())
                .map(e -> VariantSummary.of(e.getKey(), e.getValue())).toList();
    }

    /**
     * Normalises and checks the variant fields: group in upper case, blanks are null; size or colour need a group,
     * a group needs a size or a colour (400); no two products on sale of one group with the same size and colour
     * (409, ignoring case).
     */
    VariantKey variantOf(ProductRequest r, Long selfId) {
        String group = blankToNull(r.variantGroup());
        String size = blankToNull(r.size());
        String color = blankToNull(r.color());
        if (group == null) {
            if (size != null || color != null) throw new BusinessRuleException("A size or colour needs a variant group");
            return new VariantKey(null, null, null);
        }
        group = group.toUpperCase(Locale.ROOT);
        if (size == null && color == null) throw new BusinessRuleException("A variant needs a size, a colour or both");
        for (Product other : repo.variantsOf(group)) {
            if (!other.getId().equals(selfId) && equalsIgnoreCase(other.getSize(), size) && equalsIgnoreCase(other.getColor(), color)) {
                throw new ConflictException("Variant group " + group + " already has " + label(color, size) + " (SKU " + other.getSku() + ")");
            }
        }
        return new VariantKey(group, size, color);
    }

    record VariantKey(String group, String size, String color) {}

    private static String blankToNull(String v) {
        return v == null || v.isBlank() ? null : v.trim().replaceAll("\\s+", " ");
    }

    private static boolean equalsIgnoreCase(String a, String b) {
        return a == null ? b == null : a.equalsIgnoreCase(b);
    }

    private static String label(String color, String size) {
        return java.util.stream.Stream.of(color, size).filter(java.util.Objects::nonNull).collect(java.util.stream.Collectors.joining(" / "));
    }

    @Transactional(readOnly = true)
    public List<String> categories() {
        return repo.activeCategories();
    }

    static Sort sortOf(String sort) {
        Sort byId = Sort.by(Sort.Order.asc("id"));
        return switch (sort) {
            case "name" -> Sort.by(Sort.Order.asc("name").ignoreCase()).and(byId);
            case "price_asc" -> Sort.by(Sort.Order.asc("price")).and(byId);
            case "price_desc" -> Sort.by(Sort.Order.desc("price")).and(byId);
            default -> byId;
        };
    }

    /** Deleted (inactive) products are invisible: 404, same as never existed. */
    @Transactional(readOnly = true)
    public Product get(long id) {
        return repo.findByIdAndActiveTrue(id)
                .orElseThrow(() -> new NotFoundException("Product not found: " + id));
    }

    public Product create(ProductRequest r) {
        String sku = r.sku().trim();
        if (repo.existsBySku(sku)) {
            throw new ConflictException("SKU already exists: " + sku);
        }
        VariantKey variant = variantOf(r, null);
        Product p = new Product(r.name().trim(), sku, r.category(), r.price(), r.stock());
        p.changeImage(r.imageUrl());
        p.changeVariant(variant.group(), variant.size(), variant.color());
        checkDeal(r, null);
        p.changeDeal(r.dealPrice(), r.dealStartsAt(), r.dealEndsAt());
        Product saved = repo.save(p);
        inventory.stockNewProduct(saved.getId(), saved.getStock());       // the first units go to the default warehouse
        audit(saved, "PRODUCT_CREATED", null);
        return saved;
    }

    public Product update(long id, ProductRequest r) {
        Product p = get(id);
        String sku = r.sku().trim();
        repo.findBySku(sku)
                .filter(other -> !other.getId().equals(p.getId()))
                .ifPresent(other -> { throw new ConflictException("SKU already exists: " + sku); });
        VariantKey variant = variantOf(r, p.getId());
        checkDeal(r, p);
        Map<String, Object> before = audited(p);
        inventory.changeTotalFromProductForm(p.getId(), r.stock());        // atomic; the entity never writes stock
        p.update(r.name().trim(), sku, r.category(), r.price(), r.stock());
        p.changeImage(r.imageUrl());                                  // PUT replaces: no imageUrl = no picture
        p.changeVariant(variant.group(), variant.size(), variant.color());  // ... and no variant fields = no variant
        p.changeDeal(r.dealPrice(), r.dealStartsAt(), r.dealEndsAt());      // ... and no deal fields = no deal
        Product saved = repo.save(p);
        audit(saved, "PRODUCT_UPDATED", before);
        return saved;
    }

    public static final int MAX_DEAL_DAYS = 30;
    public static final int MAX_DEALS_SHOWN = 12;

    /**
     * A deal is a price below the normal price between two UTC times: all three fields or none (400), the end after
     * the start, at most 30 days long, and not already over. An unchanged deal that has ended is kept as it is, so
     * the form of a product whose deal is over can still be saved.
     */
    void checkDeal(ProductRequest r, Product existing) {
        boolean any = r.dealPrice() != null || r.dealStartsAt() != null || r.dealEndsAt() != null;
        if (!any) return;
        if (r.dealPrice() == null) throw new BusinessRuleException("A deal needs a deal price");
        if (r.dealStartsAt() == null || r.dealEndsAt() == null) throw new BusinessRuleException("A deal needs a start and an end");
        if (r.dealPrice().compareTo(r.price()) >= 0) throw new BusinessRuleException("The deal price must be lower than the price");
        if (!r.dealEndsAt().isAfter(r.dealStartsAt())) throw new BusinessRuleException("The deal must end after it starts");
        if (java.time.Duration.between(r.dealStartsAt(), r.dealEndsAt()).compareTo(java.time.Duration.ofDays(MAX_DEAL_DAYS)) > 0) {
            throw new BusinessRuleException("A deal can last " + MAX_DEAL_DAYS + " days at most");
        }
        boolean unchanged = existing != null && r.dealPrice().compareTo(existing.getDealPrice() == null ? java.math.BigDecimal.valueOf(-1) : existing.getDealPrice()) == 0
                && r.dealStartsAt().equals(existing.getDealStartsAt()) && r.dealEndsAt().equals(existing.getDealEndsAt());
        if (!unchanged && !r.dealEndsAt().isAfter(java.time.LocalDateTime.now(java.time.ZoneOffset.UTC))) {
            throw new BusinessRuleException("The deal has already ended");
        }
    }

    /** The deals running right now (UTC), the one ending first on top; only products in stock. */
    @Transactional(readOnly = true)
    public List<Product> liveDeals() {
        return repo.liveDeals(java.time.LocalDateTime.now(java.time.ZoneOffset.UTC), PageRequest.of(0, MAX_DEALS_SHOWN));
    }

    /** Soft delete: keeps history for orders/audit, hides the product from customers. */
    public void delete(long id) {
        Product p = get(id);
        Map<String, Object> before = audited(p);
        p.setActive(false);
        repo.save(p);
        audit(p, "PRODUCT_DELETED", before);
    }
}