Facets.java

package com.rbctcsworld.ecommerce.product;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.function.Predicate;

/**
 * The filter panel of the catalog: for every option, how many ITEMS (catalog cards) would be found.
 * "Disjunctive" facets like on Amazon: the counts of one facet ignore that facet's own selection, so ticking
 * "books" still shows how many "home" items there are; every other selected filter does apply.
 *
 *   total       items matching everything
 *   categories  [{value, count}] A-Z
 *   prices      fixed bands: under 25, 25-50, 50-100, 100-200, 200 and up ({key, min, max, count}; max inclusive)
 *   ratings     4, 3, 2, 1 stars and up
 *   inStock     items with at least one variant in stock
 *   colors / sizes  [{value, count}] (sizes in clothing order)
 */
public record Facets(long total, List<Option> categories, List<PriceBand> prices, List<RatingOption> ratings, long inStock,
                     List<Option> colors, List<Option> sizes) {

    public record Option(String value, long count) {}

    public record PriceBand(String key, BigDecimal min, BigDecimal max, long count) {}

    public record RatingOption(int min, long count) {}

    static final List<PriceBand> BANDS = List.of(
            band("under-25", "0", "24.99"), band("25-50", "25", "49.99"), band("50-100", "50", "99.99"),
            band("100-200", "100", "199.99"), new PriceBand("200-up", new BigDecimal("200"), null, 0));

    private static PriceBand band(String key, String min, String max) {
        return new PriceBand(key, new BigDecimal(min), new BigDecimal(max), 0);
    }

    /** One catalog card: a single product, or all variants of one group. */
    static String item(Product p) {
        return p.getVariantGroup() == null ? "P" + p.getId() : "G" + p.getVariantGroup();
    }

    static Facets count(List<Product> products, ProductFilter f) {
        long total = items(products, f::matches);

        ProductFilter noCat = f.without("category");
        List<Option> categories = options(products, noCat, p -> p.getCategory() == null ? null : p.getCategory().trim().toLowerCase(java.util.Locale.ROOT),
                Comparator.naturalOrder());

        ProductFilter noPrice = f.without("price");
        List<PriceBand> prices = BANDS.stream().map(b -> new PriceBand(b.key(), b.min(), b.max(), items(products,
                p -> noPrice.matches(p) && p.getPrice().compareTo(b.min()) >= 0 && (b.max() == null || p.getPrice().compareTo(b.max()) <= 0))))
                .toList();

        ProductFilter noRating = f.without("rating");
        List<RatingOption> ratings = List.of(4, 3, 2, 1).stream().map(r -> new RatingOption(r, items(products,
                p -> noRating.matches(p) && p.getRatingAverage().compareTo(BigDecimal.valueOf(r)) >= 0))).toList();

        ProductFilter noStock = f.without("inStock");
        long inStock = items(products, p -> noStock.matches(p) && p.getStock() > 0);

        List<Option> colors = options(products, f.without("color"), Product::getColor, String.CASE_INSENSITIVE_ORDER);
        List<Option> sizes = options(products, f.without("size"), Product::getSize, VariantSummary.SIZES);
        return new Facets(total, categories, prices, ratings, inStock, colors, sizes);
    }

    /** Distinct items with at least one product passing the test. */
    static long items(List<Product> products, Predicate<Product> test) {
        return products.stream().filter(test).map(Facets::item).distinct().count();
    }

    /** value -> number of items having a product with that value that passes the filter; values without items are left out. */
    private static List<Option> options(List<Product> products, ProductFilter f, Function<Product, String> value, Comparator<String> order) {
        Map<String, Set<String>> byValue = new TreeMap<>(order);
        Map<String, String> shown = new LinkedHashMap<>();      // the first spelling seen, for display
        for (Product p : products) {
            String v = value.apply(p);
            if (v == null || v.isBlank() || !f.matches(p)) continue;
            String key = v.trim();
            shown.putIfAbsent(key.toLowerCase(java.util.Locale.ROOT), key);
            byValue.computeIfAbsent(shown.get(key.toLowerCase(java.util.Locale.ROOT)), k -> new java.util.HashSet<>()).add(item(p));
        }
        List<Option> out = new ArrayList<>();
        byValue.forEach((v, items) -> out.add(new Option(v, items.size())));
        return out;
    }
}