> */ private array $rows = array(); /** * @param Clock $clock Display-time clock. */ public function __construct( private Clock $clock = new SystemClock() ) { } /** * Replace one category snapshot. * * @param string $searchIndex Amazon SearchIndex. * @param list $products Products in upstream order. */ public function replaceCategory( string $searchIndex, array $products ): int { $this->rows[ $searchIndex ] = array_values( $products ); return count( $products ); } /** * Replace one brand snapshot. * * @param string $brand Priority brand. * @param list $products Products in upstream order. */ #[\Override] public function replaceBrand( string $brand, array $products ): int { $key = AmazonDiscountBrand::storageKey( $brand ); $this->rows[ $key ] = array_values( $products ); return count( $products ); } /** * Delete one brand snapshot. * * @param string $brand Priority brand. */ #[\Override] public function deleteBrand( string $brand ): void { unset( $this->rows[ AmazonDiscountBrand::storageKey( $brand ) ] ); } /** * Load sorted rows by category. * * @param list $categories Category order. * @param string $sort Closed sort token. * @param int $perCategory Maximum rows per category. * * @return array> */ public function findGrouped( array $categories, string $sort, int $perCategory ): array { return $this->findCollections( $categories, $sort, $perCategory ); } /** * Load sorted rows by brand. * * @param list $brands Brand order. * @param string $sort Closed sort token. * @param int $perBrand Maximum rows per brand. * * @return array> */ #[\Override] public function findBrandsGrouped( array $brands, string $sort, int $perBrand ): array { $grouped = array(); $now = $this->clock->now(); foreach ( AmazonDiscountBrand::normalize( $brands ) as $brand ) { $rows = $this->rows[ AmazonDiscountBrand::storageKey( $brand ) ] ?? array(); $rows = array_filter( $rows, static fn ( AmazonDiscountProduct $product ): bool => AmazonDiscountBrand::matches( $brand, $product->brand() ) && AmazonDiscountDisplayPolicy::allows( $product, $now ) ); usort( $rows, self::comparator( $sort ) ); $grouped[ $brand ] = array_slice( $rows, 0, max( 1, min( 20, $perBrand ) ) ); } return $grouped; } /** * Load sorted rows by stored collection key. * * @param list $keys Collection keys. * @param string $sort Closed sort token. * @param int $limit Maximum rows per collection. * * @return array> */ private function findCollections( array $keys, string $sort, int $limit ): array { $grouped = array(); $now = $this->clock->now(); foreach ( $keys as $key ) { $rows = $this->rows[ $key ] ?? array(); $rows = array_filter( $rows, static fn ( AmazonDiscountProduct $product ): bool => AmazonDiscountDisplayPolicy::allows( $product, $now ) ); usort( $rows, self::comparator( $sort ) ); $grouped[ $key ] = array_slice( $rows, 0, max( 1, min( 20, $limit ) ) ); } return $grouped; } /** * Count all stored rows. */ public function count(): int { return array_sum( array_map( 'count', $this->rows ) ); } /** * Return the newest fetch timestamp. */ public function latestFetchedAt(): ?DateTimeImmutable { $latest = null; foreach ( $this->rows as $rows ) { foreach ( $rows as $row ) { if ( $latest === null || $row->fetchedAt() > $latest ) { $latest = $row->fetchedAt(); } } } return $latest; } /** * Build the comparator for a closed sort token. * * @param string $sort Sort token. * * @return callable(AmazonDiscountProduct,AmazonDiscountProduct):int */ private static function comparator( string $sort ): callable { return static function ( AmazonDiscountProduct $left, AmazonDiscountProduct $right ) use ( $sort ): int { return match ( $sort ) { 'discount_asc' => ( $left->savingsPercentage() ?? 101 ) <=> ( $right->savingsPercentage() ?? 101 ), 'brand_asc' => self::compareNullableBrand( $left, $right, false ), 'brand_desc' => self::compareNullableBrand( $left, $right, true ), 'title_asc' => strnatcasecmp( $left->title(), $right->title() ), 'title_desc' => strnatcasecmp( $right->title(), $left->title() ), default => ( $right->savingsPercentage() ?? -1 ) <=> ( $left->savingsPercentage() ?? -1 ), }; }; } /** * Compare brands while keeping missing brands last. * * @param AmazonDiscountProduct $left Left row. * @param AmazonDiscountProduct $right Right row. * @param bool $descending Reverse non-null values. */ private static function compareNullableBrand( AmazonDiscountProduct $left, AmazonDiscountProduct $right, bool $descending ): int { if ( $left->brand() === null || $right->brand() === null ) { return (int) ( $left->brand() === null ) <=> (int) ( $right->brand() === null ); } return $descending ? strnatcasecmp( $right->brand(), $left->brand() ) : strnatcasecmp( $left->brand(), $right->brand() ); } }