<?php
declare(strict_types=1);

namespace Autogedal\BlackFriday\Model;

use Magento\Catalog\Helper\Image as ImageHelper;
use Magento\Catalog\Model\Product;
use Magento\Catalog\Model\Product\Attribute\Source\Status;
use Magento\Catalog\Model\Product\Visibility;
use Magento\Catalog\Model\ResourceModel\Category\CollectionFactory as CategoryCollectionFactory;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory as ProductCollectionFactory;
use Magento\Eav\Model\Config as EavConfig;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Pricing\PriceCurrencyInterface;
use Magento\Framework\Serialize\SerializerInterface;
use Magento\InventoryIndexer\Model\StockIndexTableNameResolverInterface;
use Magento\InventorySalesApi\Api\Data\SalesChannelInterface;
use Magento\InventorySalesApi\Api\StockResolverInterface;
use Magento\Store\Model\StoreManagerInterface;

/**
 * Products of the landing page: only the uploaded Black Friday list (minus the excluded SKUs), enabled and visible
 * in the catalog. Before the start only salable products are shown and no price leaves the server.
 *
 * The per-product data needed to filter (tabs, car, stock, bestseller rank, discount after the start) is built once
 * and cached for a few minutes; each request then only filters it and loads the 24 products of the page.
 *
 * Without a chosen category (getOverview) the page shows one row per category - two bestsellers and two of the
 * biggest discounts (four bestsellers before the start, when no price is known on the page) - and "load more" adds
 * products of all the categories in a random order: first the bestsellers / discounted ones, then the others.
 */
class ProductProvider
{
    private const CACHE_TTL = 600;
    private const ROW_SIZE = 4;
    private const ROW_BESTSELLERS = 2;

    /** keyword in the category name => icon of the tab */
    private const ICONS = [
        'carlig' => 'carlige', 'covor' => 'covorase', 'tavit' => 'tavite', 'paravant' => 'paravanturi',
        'cutii' => 'cutii', 'cutie' => 'cutii', 'bare' => 'bare', 'biciclet' => 'biciclete', 'scut' => 'scuturi',
    ];

    private ?array $dataset = null;

    public function __construct(
        private readonly ResourceConnection $resource,
        private readonly Config $config,
        private readonly SkuList $skuList,
        private readonly StoreManagerInterface $storeManager,
        private readonly StockResolverInterface $stockResolver,
        private readonly StockIndexTableNameResolverInterface $stockIndexTableNameResolver,
        private readonly EavConfig $eavConfig,
        private readonly ProductCollectionFactory $productCollectionFactory,
        private readonly CategoryCollectionFactory $categoryCollectionFactory,
        private readonly Visibility $visibility,
        private readonly ImageHelper $imageHelper,
        private readonly PriceCurrencyInterface $priceCurrency,
        private readonly CacheInterface $cache,
        private readonly SerializerInterface $serializer,
        private readonly array $tabFilters = []
    ) {
    }

    /**
     * Data for the product list of the page.
     *
     * @return array<string, mixed>
     */
    public function getList(
        int $tabId,
        int $brandId,
        int $modelId,
        int $page,
        ?array $favorites = null,
        ?bool $forceLive = null,
        array $extra = []
    ): array {
        $live = $forceLive ?? $this->config->isLive();
        $data = $this->getDataset($live);
        $tabs = $data['tabs'];
        if (!isset($tabs[$tabId])) {
            $tabId = (int)array_key_first($tabs);
        }

        $inTab = array_filter($data['products'], fn (array $p) => in_array($tabId, $p['tabs'], true));
        $brands = $this->facet($inTab, fn (array $p) => $p['brand'], $data['labels'], 6);
        if ($brandId && !in_array($brandId, array_column($brands, 'id'), true)) {
            $brandId = $modelId = 0;   // the car chosen in another category is not in this one (e.g. universal roof boxes)
        }
        $ofBrand = $brandId ? array_filter($inTab, fn (array $p) => $p['brand'] === $brandId) : [];
        $models = $brandId ? $this->facet($ofBrand, fn (array $p) => $p['model'], $data['labels']) : [];
        if ($modelId && !in_array($modelId, array_column($models, 'id'), true)) {
            $modelId = 0;
        }
        $ofModel = $modelId ? array_filter($ofBrand, fn (array $p) => $p['model'] === $modelId) : [];
        [$extraFacets, $extra] = $this->extraFacets($tabId, $ofModel, $extra, $data['labels']);

        // products without a car (universal: boxes, bike carriers) stay visible with the car filter on
        $list = array_filter($inTab, fn (array $p) => !$p['brand']
            || ((!$brandId || $p['brand'] === $brandId) && (!$modelId || $p['model'] === $modelId) && $this->matchesExtra($p, $extra)));
        uasort($list, fn (array $a, array $b) => [$b['rank'], $a['id']] <=> [$a['rank'], $b['id']]);

        $pageSize = $this->config->getPageSize();
        $page = max(1, $page);
        $pageIds = array_slice(array_keys($list), ($page - 1) * $pageSize, $pageSize);

        return [
            'live' => $live,
            'over' => $forceLive === null && $this->config->isOver(),
            'serverNow' => time() * 1000,
            'tabs' => $this->publicTabs($tabs),
            'tab' => $tabId,
            'universal' => $inTab && !$brands,
            'facets' => ['brands' => $brands, 'models' => $models, 'extra' => $extraFacets],
            // filters actually applied (a car or a value not available in the category is dropped)
            'filters' => ['brand' => $brandId, 'model' => $modelId, 'extra' => $extra ?: new \stdClass()],
            'hasMore' => count($list) > $page * $pageSize,
            'page' => $page,
            'pageSize' => $pageSize,
            'items' => $this->loadItems($pageIds, $data['products'], $live),
        ] + ($favorites === null ? [] : ['favorites' => array_keys($this->displayable($favorites, $data['products']))]);
    }

    /**
     * No category chosen: the rows of the categories ($more = false) or the next products of the mixed list
     * ($more = true, offset = products of the list already shown). $seed (chosen by the page) keeps the random order
     * of the mixed list the same while the visitor loads more, so nothing repeats.
     *
     * @return array<string, mixed>
     */
    public function getOverview(bool $more, int $offset, int $seed, ?array $favorites = null, ?bool $forceLive = null): array
    {
        $live = $forceLive ?? $this->config->isLive();
        $data = $this->getDataset($live);

        $rows = [];
        $inRows = [];
        foreach ($data['tabs'] as $tabId => $tab) {
            // a product of several categories is shown in the first row only
            $inTab = array_filter($data['products'], fn (array $p) => !isset($inRows[$p['id']]) && in_array($tabId, $p['tabs'], true));
            $ids = $this->rowIds($inTab, $live);
            foreach ($ids as $id) {
                $inRows[$id] = true;
            }
            if ($ids) {
                $rows[] = ['tab' => $tab, 'ids' => $ids];
            }
        }
        $mixed = $this->mixedIds(array_diff_key($data['products'], $inRows), $live, $seed);

        $pageSize = $this->config->getPageSize();
        $offset = max(0, $offset);
        $pageIds = $more ? array_slice($mixed, $offset, $pageSize) : [];
        $loaded = [];
        foreach ($this->loadItems(array_merge($more ? [] : array_keys($inRows), $pageIds), $data['products'], $live) as $item) {
            $loaded[$item['id']] = $item;
        }
        $itemsOf = fn (array $ids) => array_values(array_filter(array_map(fn (int $id) => $loaded[$id] ?? null, $ids)));

        return [
            'live' => $live,
            'over' => $forceLive === null && $this->config->isOver(),
            'serverNow' => time() * 1000,
            'tabs' => $this->publicTabs($data['tabs']),
            'tab' => 0,
            'universal' => false,
            'facets' => ['brands' => [], 'models' => [], 'extra' => []],
            'filters' => ['brand' => 0, 'model' => 0, 'extra' => new \stdClass()],
            'rows' => $more ? [] : array_map(fn (array $row) => [
                'id' => $row['tab']['id'],
                'label' => $row['tab']['label'],
                'url' => $row['tab']['url'] ?? '',
                'icon' => $row['tab']['icon'],
                'items' => $itemsOf($row['ids']),
            ], $rows),
            'more' => $more,
            'hasMore' => count($mixed) > ($more ? $offset + $pageSize : 0),
            'page' => 1,
            'pageSize' => $pageSize,
            'items' => $itemsOf($pageIds),
        ] + ($favorites === null ? [] : ['favorites' => array_keys($this->displayable($favorites, $data['products']))]);
    }

    /**
     * Tabs as sent to the page.
     *
     * @return array<int, array{id: int, label: string, icon: string}>
     */
    private function publicTabs(array $tabs): array
    {
        return array_values(array_map(fn (array $tab) => ['id' => $tab['id'], 'label' => $tab['label'], 'icon' => $tab['icon']], $tabs));
    }

    /**
     * Row of a category: salable products first; two bestsellers then the biggest discounts after the start, four
     * bestsellers before it; filled with the next products by rank when the category has fewer.
     *
     * @param array<int, array> $products products of the category
     * @return int[]
     */
    private function rowIds(array $products, bool $live): array
    {
        $byRank = fn (array $a, array $b) => [$b['rank'], $a['id']] <=> [$a['rank'], $b['id']];
        $byDiscount = fn (array $a, array $b) => [$b['pct'] ?? 0, $b['rank'], $a['id']] <=> [$a['pct'] ?? 0, $a['rank'], $b['id']];
        $salable = array_filter($products, fn (array $p) => $p['salable']);
        $picked = [];
        $take = function (array $list, callable $order, int $upTo, ?callable $filter = null) use (&$picked): void {
            uasort($list, $order);
            foreach ($list as $id => $p) {
                if (count($picked) >= $upTo) {
                    return;
                }
                if (!isset($picked[$id]) && (!$filter || $filter($p))) {
                    $picked[$id] = $id;
                }
            }
        };
        $sold = fn (array $p) => $p['rank'] > 0;
        if ($live) {
            $take($salable, $byRank, self::ROW_BESTSELLERS, $sold);
            $take($salable, $byDiscount, self::ROW_SIZE, fn (array $p) => ($p['pct'] ?? 0) > 0);
        } else {
            $take($salable, $byRank, self::ROW_SIZE, $sold);
        }
        $take($salable, $byRank, self::ROW_SIZE);
        $take($products, $byRank, self::ROW_SIZE);   // out of stock only when the category has nothing else
        return array_values($picked);
    }

    /**
     * Products for "load more" without a category, in a random order fixed by $seed: the bestsellers and (after the
     * start) the discounted ones first, then the other salable ones, the out of stock ones last.
     *
     * @return int[]
     */
    private function mixedIds(array $products, bool $live, int $seed): array
    {
        $tier = fn (array $p) => !$p['salable'] ? 2 : (($p['rank'] > 0 || ($live && ($p['pct'] ?? 0) > 0)) ? 0 : 1);
        $keys = [];
        foreach ($products as $id => $p) {
            $keys[$id] = [$tier($p), crc32($seed . ':' . $id)];
        }
        uasort($keys, fn (array $a, array $b) => $a <=> $b);
        return array_map('intval', array_keys($keys));
    }

    /**
     * "Favorite" tab: the favourites of the visitor (sent from the browser, most recent first) that are still shown
     * on the page, from all the categories. Offset based so a favourite removed in the tab does not shift the next page.
     *
     * @param string[] $favorites
     * @return array<string, mixed>
     */
    public function getFavorites(array $favorites, int $offset, ?bool $forceLive = null): array
    {
        $live = $forceLive ?? $this->config->isLive();
        $data = $this->getDataset($live);
        $shown = $this->displayable($favorites, $data['products']);
        $pageSize = $this->config->getPageSize();
        $offset = max(0, $offset);

        return [
            'live' => $live,
            'over' => $forceLive === null && $this->config->isOver(),
            'serverNow' => time() * 1000,
            'tabs' => $this->publicTabs($data['tabs']),
            'tab' => 'fav',
            'universal' => false,
            'facets' => ['brands' => [], 'models' => []],
            'hasMore' => count($shown) > $offset + $pageSize,
            'page' => 1,
            'pageSize' => $pageSize,
            'items' => $this->loadItems(array_slice(array_values($shown), $offset, $pageSize), $data['products'], $live),
            'favorites' => array_keys($shown),
        ];
    }

    /**
     * Favourites that the page can still show (in the list, not excluded, enabled, salable before the start), same order.
     *
     * @param string[] $skus
     * @return array<string, int> sku => product id
     */
    private function displayable(array $skus, array $products): array
    {
        $ids = $this->campaignIds();
        $shown = [];
        foreach ($skus as $sku) {
            $id = $ids[mb_strtoupper(trim($sku))] ?? null;
            if ($id !== null && isset($products[$id])) {
                $shown[$sku] = (int)$id;
            }
        }
        return $shown;
    }

    /**
     * Products of the given SKUs that are still in the campaign and salable, with their current prices (launch email).
     *
     * @param string[] $skus
     * @return array<int, array<string, mixed>>
     */
    public function getItemsForSkus(array $skus): array
    {
        $known = $this->campaignIds();
        $products = $this->getDataset(true)['products'];
        $ids = [];
        foreach ($skus as $sku) {
            $id = $known[mb_strtoupper(trim($sku))] ?? null;
            if ($id !== null && isset($products[$id]) && $products[$id]['salable']) {
                $ids[$id] = $id;
            }
        }
        return $this->loadItems(array_values($ids), $products, true);
    }

    /**
     * SKUs of the campaign among the given ones (favourites sent from the browser).
     *
     * @param string[] $skus
     * @return string[]
     */
    public function filterCampaignSkus(array $skus): array
    {
        $known = $this->campaignIds();
        return array_values(array_unique(array_filter($skus, fn (string $sku) => isset($known[mb_strtoupper(trim($sku))]))));
    }

    /** @return array<string, int> SKU of the campaign list (upper case: the CSV may differ in case from the catalog) => id */
    private function campaignIds(): array
    {
        // kept in the cached dataset (same TTL / tag): no query per request for the favourites of the visitors
        return $this->getDataset($this->config->isLive())['skus'] ?? $this->skuMap();
    }

    /** @return array<string, int> upper-case SKU of the campaign list (minus the excluded ones) => product id */
    private function skuMap(): array
    {
        $ids = [];
        foreach ($this->skuList->getProductIds() as $sku => $id) {
            $ids[mb_strtoupper(trim((string)$sku))] = (int)$id;
        }
        return $ids;
    }

    /** @return array{products: array<int, array>, tabs: array<int, array>, labels: array<int, string>, skus: array<string, int>} */
    private function getDataset(bool $live): array
    {
        if ($this->dataset !== null && $this->dataset['live'] === $live) {
            return $this->dataset;
        }
        $store = $this->storeManager->getStore();
        $key = sprintf('autogedal_bf_dataset_%d_%d', $store->getId(), (int)$live);
        $cached = $this->cache->load($key);
        if ($cached) {
            return $this->dataset = $this->serializer->unserialize($cached);
        }
        $data = $this->buildDataset($live) + ['live' => $live];
        $this->cache->save($this->serializer->serialize($data), $key, [PageIdentity::CACHE_TAG], self::CACHE_TTL);
        return $this->dataset = $data;
    }

    private function buildDataset(bool $live): array
    {
        $store = $this->storeManager->getStore();
        $skus = $this->skuMap();
        $ids = array_values($skus);
        if ($ids) {
            $collection = $this->productCollectionFactory->create()
                ->setStoreId((int)$store->getId())
                ->addStoreFilter($store)
                ->addIdFilter($ids)
                ->addAttributeToFilter('status', Status::STATUS_ENABLED)
                ->setVisibility($this->visibility->getVisibleInCatalogIds());
            $ids = array_map('intval', $collection->getAllIds());
        }
        if (!$ids) {
            return ['products' => [], 'tabs' => [], 'labels' => [], 'skus' => $skus];
        }

        $salable = $this->salableIds($ids);
        if (!$live) {
            $ids = array_values(array_intersect($ids, $salable));
        }
        $salable = array_flip($salable);
        $car = $this->attributeValues($ids, ['brand' => 'auto_brand', 'model' => 'auto_model']);
        $extraCodes = array_unique(array_merge([], ...array_map('array_keys', array_values($this->tabFilters))));
        $attrs = $extraCodes ? $this->attributeValues($ids, array_combine($extraCodes, $extraCodes)) : [];
        $tabsOf = $this->mainCategories($ids);
        $ranks = $this->skuList->getRanks();
        // after the start only: the prices are not known on the page before it
        $discounts = $live ? $this->discounts($ids) : [];

        $products = [];
        foreach ($ids as $id) {
            if (empty($tabsOf[$id])) {
                continue;   // not in any main category of the store: there is no tab to show it in
            }
            $products[$id] = [
                'id' => $id,
                'tabs' => $tabsOf[$id],
                'brand' => $car[$id]['brand'] ?? 0,
                'model' => $car[$id]['model'] ?? 0,
                'attrs' => $attrs[$id] ?? [],   // values of the category filters (body, years), option ids
                'salable' => isset($salable[$id]),
                'rank' => $ranks[$id] ?? 0.0,
                'pct' => $discounts[$id] ?? 0,   // to rank the biggest discounts (the card shows the exact price)
            ];
        }

        $optionIds = [];
        foreach ($products as $p) {
            $optionIds[$p['brand']] = true;
            $optionIds[$p['model']] = true;
            foreach ($p['attrs'] as $values) {
                foreach ((array)$values as $optionId) {
                    $optionIds[$optionId] = true;
                }
            }
        }
        unset($optionIds[0]);

        return [
            'products' => $products,
            'tabs' => $this->tabs(array_unique(array_merge(...array_values(array_column($products, 'tabs'))))),
            'labels' => $this->optionLabels(array_keys($optionIds), (int)$store->getId()),
            'skus' => $skus,
        ];
    }

    /**
     * Discount (%) of each product for the ranking "biggest discount", from the same data as the price of the card:
     * price and special price with its dates (catalog price rules are not used on the site). Not from the price index,
     * which may lag behind (update by schedule), e.g. right when the Black Friday prices start.
     * Bundle sets as the page shows them (all the components): a dynamic bundle is the sum of its components
     * (x quantity), minus its special price (a percentage for bundles); a fixed price bundle is its own price minus
     * that percentage. Same rounding as the discount label.
     *
     * @param int[] $ids
     * @return array<int, int>
     */
    private function discounts(array $ids): array
    {
        $connection = $this->resource->getConnection();
        $bundles = [];
        foreach (array_chunk($ids, 5000) as $chunk) {
            foreach ($connection->fetchCol($connection->select()
                ->from($this->resource->getTableName('catalog_product_entity'), ['entity_id'])
                ->where('type_id = ?', 'bundle')
                ->where('entity_id IN (?)', $chunk)) as $id) {
                $bundles[(int)$id] = true;
            }
        }

        $result = [];
        foreach ($this->catalogPrices(array_values(array_filter($ids, fn (int $id) => !isset($bundles[$id])))) as $id => $price) {
            $result[$id] = $this->percent($price['price'], $this->finalPrice($price));
        }
        if ($bundles) {
            $result += $this->bundleDiscounts(array_keys($bundles));
        }
        return $result;
    }

    /**
     * @param int[] $bundleIds
     * @return array<int, int>
     */
    private function bundleDiscounts(array $bundleIds): array
    {
        $own = $this->catalogPrices($bundleIds);   // special price of a bundle = percentage
        $fixed = $this->attributeValues($bundleIds, ['type' => 'price_type']);   // 1 = fixed, 0 (absent) = dynamic
        $sums = [];
        $dynamic = array_values(array_filter($bundleIds, fn (int $id) => empty($fixed[$id]['type'])));
        if ($dynamic) {
            $connection = $this->resource->getConnection();
            $selections = $connection->fetchAll($connection->select()
                ->from($this->resource->getTableName('catalog_product_bundle_selection'), ['parent_product_id', 'product_id', 'selection_qty'])
                ->where('parent_product_id IN (?)', $dynamic));
            $children = $this->catalogPrices(array_values(array_unique(array_map('intval', array_column($selections, 'product_id')))));
            foreach ($selections as $row) {
                $child = $children[(int)$row['product_id']] ?? null;
                if ($child === null) {
                    continue;
                }
                $qty = max(1.0, (float)$row['selection_qty']);
                $id = (int)$row['parent_product_id'];
                $sums[$id][0] = ($sums[$id][0] ?? 0.0) + $child['price'] * $qty;
                $sums[$id][1] = ($sums[$id][1] ?? 0.0) + $this->finalPrice($child) * $qty;
            }
        }
        foreach ($bundleIds as $id) {
            if (!empty($fixed[$id]['type']) && isset($own[$id])) {
                $sums[$id] = [$own[$id]['price'], $own[$id]['price']];
            }
        }
        $result = [];
        foreach ($sums as $id => [$regular, $final]) {
            $percent = $own[$id]['special'] ?? null;
            if ($percent !== null && $percent > 0 && $percent < 100) {
                $final *= $percent / 100;
            }
            $result[$id] = $this->percent($regular, $final);
        }
        return $result;
    }

    /**
     * Price and active special price (null when not set or outside its dates) of each product, the store value over
     * the default one.
     *
     * @param int[] $ids
     * @return array<int, array{price: float, special: float|null}>
     */
    private function catalogPrices(array $ids): array
    {
        if (!$ids) {
            return [];
        }
        $connection = $this->resource->getConnection();
        $storeId = (int)$this->storeManager->getStore()->getId();
        $attributes = [];
        foreach (['price', 'special_price', 'special_from_date', 'special_to_date'] as $code) {
            $attribute = $this->eavConfig->getAttribute(Product::ENTITY, $code);
            $attributes[(int)$attribute->getId()] = [$code, $attribute->getBackendTable()];
        }
        $values = [];
        foreach (array_chunk($ids, 5000) as $chunk) {
            foreach (array_unique(array_column($attributes, 1)) as $table) {
                $rows = $connection->fetchAll($connection->select()
                    ->from($table, ['entity_id', 'attribute_id', 'store_id', 'value'])
                    ->where('attribute_id IN (?)', array_keys($attributes))
                    ->where('store_id IN (?)', [0, $storeId])
                    ->where('entity_id IN (?)', $chunk)
                    ->order('store_id ASC'));
                foreach ($rows as $row) {
                    if ($row['value'] !== null) {
                        // ordered by store: the store value overrides the default one
                        $values[(int)$row['entity_id']][$attributes[(int)$row['attribute_id']][0]] = (string)$row['value'];
                    }
                }
            }
        }
        $today = date('Y-m-d');
        $result = [];
        foreach ($values as $id => $value) {
            $special = isset($value['special_price']) ? (float)$value['special_price'] : null;
            $from = substr($value['special_from_date'] ?? '', 0, 10);
            $to = substr($value['special_to_date'] ?? '', 0, 10);
            if ($special !== null && (($from !== '' && $from > $today) || ($to !== '' && $to < $today))) {
                $special = null;
            }
            $result[$id] = ['price' => (float)($value['price'] ?? 0), 'special' => $special];
        }
        return $result;
    }

    /** @param array{price: float, special: float|null} $price */
    private function finalPrice(array $price): float
    {
        return $price['special'] !== null && $price['special'] < $price['price'] ? $price['special'] : $price['price'];
    }

    /** Same rounding as the discount label of the site (Autogedal\Labels ProductLabels) and of the cards. */
    private function percent(float $regular, float $final): int
    {
        return $regular > $final && $regular > 0 ? (int)(100 - round($final / $regular * 100)) : 0;
    }

    /** @return int[] ids salable on the stock of the current website (MSI index) */
    private function salableIds(array $ids): array
    {
        $websiteCode = $this->storeManager->getWebsite()->getCode();
        $stockId = $this->stockResolver->execute(SalesChannelInterface::TYPE_WEBSITE, $websiteCode)->getStockId();
        $connection = $this->resource->getConnection();
        $result = [];
        foreach (array_chunk($ids, 5000) as $chunk) {
            $result[] = $connection->fetchCol($connection->select()
                ->from(['e' => $this->resource->getTableName('catalog_product_entity')], ['entity_id'])
                ->join(['si' => $this->stockIndexTableNameResolver->execute($stockId)], 'si.sku = e.sku', [])
                ->where('si.is_salable = 1')
                ->where('e.entity_id IN (?)', $chunk));
        }
        return array_map('intval', array_merge([], ...$result));
    }

    /**
     * Option ids of select attributes (auto_brand / auto_model / auto_body ... stored as text or int), store 0.
     *
     * @param array<string, string> $codes key in the result => attribute code
     * @return array<int, array<string, int>>
     */
    private function attributeValues(array $ids, array $codes): array
    {
        $connection = $this->resource->getConnection();
        $result = [];
        foreach ($codes as $key => $code) {
            $attribute = $this->eavConfig->getAttribute(Product::ENTITY, $code);
            if (!$attribute || !$attribute->getId()) {
                continue;
            }
            // multiselect (e.g. auto_body_bare): every option id of the product, not only the first one
            $multiple = $attribute->getFrontendInput() === 'multiselect';
            foreach (array_chunk($ids, 5000) as $chunk) {
                $rows = $connection->fetchPairs($connection->select()
                    ->from($attribute->getBackendTable(), ['entity_id', 'value'])
                    ->where('attribute_id = ?', (int)$attribute->getId())
                    ->where('store_id = 0')
                    ->where('entity_id IN (?)', $chunk));
                foreach ($rows as $id => $value) {
                    if ($multiple) {
                        $optionIds = array_values(array_filter(array_map('intval', explode(',', (string)$value))));
                        if ($optionIds) {
                            $result[(int)$id][$key] = $optionIds;
                        }
                    } elseif ((int)$value) {
                        $result[(int)$id][$key] = (int)$value;
                    }
                }
            }
        }
        return $result;
    }

    /** @return array<int, int[]> product id => main categories (children of the store root) it belongs to */
    private function mainCategories(array $ids): array
    {
        $rootPath = '1/' . (int)$this->storeManager->getStore()->getRootCategoryId() . '/';
        $connection = $this->resource->getConnection();
        $result = [];
        foreach (array_chunk($ids, 5000) as $chunk) {
            $rows = $connection->fetchAll($connection->select()
                ->distinct()
                ->from(['cp' => $this->resource->getTableName('catalog_category_product')], ['product_id'])
                ->join(['c' => $this->resource->getTableName('catalog_category_entity')], 'c.entity_id = cp.category_id', [
                    'main' => new \Zend_Db_Expr("SUBSTRING_INDEX(SUBSTRING_INDEX(c.path, '/', 3), '/', -1)"),
                ])
                ->where('c.level >= 2')
                ->where('c.path LIKE ?', $rootPath . '%')
                ->where('cp.product_id IN (?)', $chunk));
            foreach ($rows as $row) {
                $result[(int)$row['product_id']][] = (int)$row['main'];
            }
        }
        return $result;
    }

    /** @return array<int, array{id: int, label: string, icon: string, url: string}> tabs in the configured order, then by name */
    private function tabs(array $categoryIds): array
    {
        if (!$categoryIds) {
            return [];
        }
        $collection = $this->categoryCollectionFactory->create()
            ->setStoreId((int)$this->storeManager->getStore()->getId())
            ->addAttributeToSelect(['name', 'is_active', 'url_key'])
            ->addIdFilter($categoryIds)
            ->addUrlRewriteToResult();
        $tabs = [];
        foreach ($collection as $category) {
            if (!$category->getIsActive()) {
                continue;
            }
            $label = (string)$category->getName();
            $icon = 'generic';
            $plain = $this->plain($label);
            foreach (self::ICONS as $keyword => $name) {
                if (str_contains($plain, $keyword)) {
                    $icon = $name;
                    break;
                }
            }
            $tabs[(int)$category->getId()] = [
                'id' => (int)$category->getId(),
                'label' => $label,
                'icon' => $icon,
                'url' => (string)$category->getUrl(),   // category page of the site (title of its row)
            ];
        }
        $order = array_flip($this->config->getCategoryOrder());
        uasort($tabs, fn ($a, $b) => [$order[$a['id']] ?? PHP_INT_MAX, $a['label']] <=> [$order[$b['id']] ?? PHP_INT_MAX, $b['label']]);
        return $tabs;
    }

    /** @return array<int, string> option id => label */
    private function optionLabels(array $optionIds, int $storeId): array
    {
        if (!$optionIds) {
            return [];
        }
        $connection = $this->resource->getConnection();
        $rows = $connection->fetchAll($connection->select()
            ->from($this->resource->getTableName('eav_attribute_option_value'), ['option_id', 'store_id', 'value'])
            ->where('option_id IN (?)', $optionIds)
            ->where('store_id IN (?)', [0, $storeId])
            ->order('store_id ASC'));
        $labels = [];
        foreach ($rows as $row) {
            $labels[(int)$row['option_id']] = (string)$row['value'];   // the store label overrides the admin one
        }
        return $labels;
    }

    /** @return array<int, array{id: int, label: string, popular?: int}> values present on the products, sorted by label; popular = place among the $popular most used */
    private function facet(array $products, callable $valueOf, array $labels, int $popular = 0): array
    {
        $counts = [];
        foreach ($products as $p) {
            // one option id, or several for a multiselect attribute
            foreach ((array)$valueOf($p) as $value) {
                $value = (int)$value;
                if ($value && isset($labels[$value])) {
                    $counts[$value] = ($counts[$value] ?? 0) + 1;
                }
            }
        }
        // the counts stay on the server (no product numbers in the page): only the place of the most used values
        arsort($counts);
        $ranks = array_flip(array_slice(array_keys($counts), 0, $popular));
        $facet = [];
        foreach (array_keys($counts) as $id) {
            $facet[] = ['id' => $id, 'label' => $labels[$id]] + (isset($ranks[$id]) ? ['popular' => $ranks[$id] + 1] : []);
        }
        usort($facet, fn ($a, $b) => strnatcasecmp($this->plain($a['label']), $this->plain($b['label'])));
        return $facet;
    }

    /**
     * Extra filters of the category (di.xml tabFilters, e.g. body and years for tow hitches), offered once the model
     * is chosen; each list is limited by the other selected values. Selected values not available any more are dropped.
     *
     * @param array<int, array> $carProducts products of the chosen brand + model in the category
     * @param array<string, int> $selected attribute code => option id
     * @return array{0: array<int, array>, 1: array<string, int>}
     */
    private function extraFacets(int $tabId, array $carProducts, array $selected, array $labels): array
    {
        $definitions = $this->tabFilters[$tabId] ?? [];
        $selected = $carProducts ? array_intersect_key(array_filter(array_map('intval', $selected)), $definitions) : [];
        $facets = [];
        foreach ($definitions as $code => $definition) {
            $options = [];
            if ($carProducts) {
                $others = array_diff_key($selected, [$code => true]);
                $pool = array_filter($carProducts, fn (array $p) => $this->matchesExtra($p, $others));
                $options = $this->facet($pool, fn (array $p) => $p['attrs'][$code] ?? 0, $labels);
                if (isset($selected[$code]) && !in_array($selected[$code], array_column($options, 'id'), true)) {
                    unset($selected[$code]);
                }
            }
            $facets[] = [
                'code' => (string)$code,
                'label' => (string)($definition['label'] ?? $code),
                'all' => (string)($definition['all'] ?? ''),
                'options' => $options,
            ];
        }
        return [$facets, $selected];
    }

    /** A product without a value for a filter (fits any body / year) stays in the list. */
    private function matchesExtra(array $product, array $selected): bool
    {
        foreach ($selected as $code => $optionId) {
            $values = (array)($product['attrs'][$code] ?? []);
            if ($values && !in_array($optionId, $values, true)) {
                return false;
            }
        }
        return true;
    }

    /**
     * @param int[] $ids
     * @return array<int, array<string, mixed>>
     */
    private function loadItems(array $ids, array $dataset, bool $live): array
    {
        if (!$ids) {
            return [];
        }
        $collection = $this->productCollectionFactory->create()
            ->setStoreId((int)$this->storeManager->getStore()->getId())
            ->addIdFilter($ids)
            ->addAttributeToSelect(['name', 'small_image', 'thumbnail', 'url_key', 'price', 'special_price', 'special_from_date', 'special_to_date'])
            ->addUrlRewrite();
        $byId = [];
        foreach ($collection as $product) {
            $byId[(int)$product->getId()] = $product;
        }

        $items = [];
        foreach ($ids as $id) {
            if (!isset($byId[$id])) {
                continue;
            }
            /** @var Product $product */
            $product = $byId[$id];
            $item = [
                'id' => $id,
                'sku' => (string)$product->getSku(),
                'name' => (string)$product->getName(),
                'url' => $product->getProductUrl(),
                'image' => $this->imageHelper->init($product, 'category_page_grid')->getUrl(),
                'universal' => empty($dataset[$id]['brand']),
                // straight to the cart only without options to choose; the others go to the product page
                'cart' => in_array($product->getTypeId(), ['simple', 'virtual'], true) && !(int)$product->getRequiredOptions(),
            ];
            if ($live) {
                // prices only after the start: before it they are not sent to the page at all
                $priceInfo = $product->getPriceInfo();
                if ($product->getTypeId() === 'bundle') {
                    // sets: all the components, as the site shows it (the minimal price is a single component)
                    $final = (float)$priceInfo->getPrice('final_price')->getMaximalPrice()->getValue();
                    $regular = (float)$priceInfo->getPrice('regular_price')->getMaximalPrice()->getValue();
                } else {
                    $final = (float)$priceInfo->getPrice('final_price')->getAmount()->getValue();
                    $regular = (float)$priceInfo->getPrice('regular_price')->getAmount()->getValue();
                }
                $item += [
                    'out' => empty($dataset[$id]['salable']),
                    'price' => $this->priceCurrency->format($final, false),
                    'priceValue' => round($final, 2),
                    'oldPrice' => $regular > $final ? $this->priceCurrency->format($regular, false) : null,
                    // same rounding as the discount label of the site (Autogedal\Labels ProductLabels)
                    'pct' => $regular > $final && $regular > 0 ? (int)(100 - round($final / $regular * 100)) : 0,
                ];
            }
            $items[] = $item;
        }
        return $items;
    }

    private function plain(string $text): string
    {
        $text = mb_strtolower($text);
        return strtr($text, ['ă' => 'a', 'â' => 'a', 'î' => 'i', 'ș' => 's', 'ş' => 's', 'ț' => 't', 'ţ' => 't']);
    }
}
