<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Artwork;
use App\Models\Collection as CollectionModel;
use App\Models\Leaderboard;
use App\Models\Tag;
use App\Models\User;
use App\Services\EarlyGrowth\EarlyGrowth;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\Worlds\WorldService;
use App\Support\ArtworkFeaturedImagePath;
use App\Support\AvatarUrl;
use cPad\Plugins\News\Models\NewsArticle;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

/**
 * HomepageService
 *
 * Aggregates all data sections needed for the Nova homepage.
 * All results are cached for CACHE_TTL seconds.
 * Controllers stay thin — only call the aggregator.
 */
final class HomepageService
{
    private const CACHE_TTL = 300; // 5 minutes

    private const DEFAULT_ARTWORK_RAIL_LIMIT = 10;

    private const ARTWORK_SERIALIZATION_RELATIONS = [
        'user:id,name,username',
        'user.profile:user_id,avatar_hash',
        'group:id,name,slug,headline,avatar_path,followers_count',
        'categories:id,name,slug,content_type_id,sort_order',
        'categories.contentType:id,name,slug',
    ];

    public function __construct(
        private readonly ArtworkService $artworks,
        private readonly ArtworkSearchService $search,
        private readonly ArtworkMaturityService $maturity,
        private readonly ArtworkFeaturedImagePath $featuredImages,
        private readonly UserPreferenceService $prefs,
        private readonly RecommendationFeedResolver $feedResolver,
        private readonly GridFiller $gridFiller,
        private readonly CollectionDiscoveryService $collectionDiscovery,
        private readonly CollectionService $collectionService,
        private readonly CollectionSurfaceService $collectionSurfaces,
        private readonly GroupDiscoveryService $groupDiscovery,
        private readonly LeaderboardService $leaderboards,
        private readonly WorldService $worlds,
        private readonly HomepageAnnouncementService $homepageAnnouncements,
    ) {}

    // ─────────────────────────────────────────────────────────────────────────
    // Public aggregator
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Return all homepage section data as a single array ready to JSON-encode.
     */
    public function all(): array
    {
        // Use a stale-while-revalidate pattern: serve fresh cache for a short
        // period and allow serving stale data while the cache is recalculated
        // in the background. This reduces latency for the homepage on cache
        // miss/expiration and keeps the heavy aggregation from blocking
        // responses.
        $ttl = $this->guestPayloadCacheTtl();
        $freshSeconds = min(30, max(5, (int) config('homepage.fresh_seconds', 30)));

        return $this->guestPayloadCache()->flexible(
            $this->guestPayloadCacheKey(),
            [$freshSeconds, $ttl],
            fn (): array => $this->buildGuestPayload(),
        );
    }

    public function warmGuestPayloadCache(): array
    {
        $payload = $this->buildGuestPayload();

        $this->guestPayloadCache()->put(
            $this->guestPayloadCacheKey(),
            $payload,
            $this->guestPayloadCacheTtl(),
        );

        return $payload;
    }

    public function clearGuestPayloadCache(): void
    {
        $this->guestPayloadCache()->forget($this->guestPayloadCacheKey());
    }

    public function clearFeaturedAndMedalCaches(): void
    {
        $this->clearGuestPayloadCache();

        foreach (['visibility-hide', 'visibility-blur', 'visibility-show'] as $segment) {
            Cache::forget("homepage.hero.{$segment}");
            Cache::forget("homepage.community-favorites.8.{$segment}");
            Cache::forget("homepage.hall-of-fame.8.{$segment}");
        }
    }

    public function guestPayloadCacheStoreName(): string
    {
        $configuredStore = (string) config('homepage.cache_store', 'homepage');

        if (is_array(config('cache.stores.'.$configuredStore))) {
            return $configuredStore;
        }

        return (string) config('cache.default', 'database');
    }

    private function buildGuestPayload(): array
    {
        return [
            'hero' => $this->getHeroArtwork(),
            'announcement' => $this->homepageAnnouncements->toHomepagePayload($this->homepageAnnouncements->getActiveForHomepage()),
            'community_favorites' => $this->getCommunityFavorites(),
            'hall_of_fame' => $this->getHallOfFame(),
            'rising' => $this->getRising(),
            'trending' => $this->getTrending(),
            'fresh' => $this->getFreshUploads(),
            'collections_featured' => $this->getFeaturedCollections(),
            'collections_trending' => $this->getTrendingCollections(),
            'collections_editorial' => $this->getEditorialCollections(),
            'collections_community' => $this->getCommunityCollections(),
            'world_spotlight' => $this->worlds->homepageSpotlight(),
            'groups' => $this->getHomepageGroups(),
            'tags' => $this->getPopularTags(),
            'creators' => $this->getCreatorSpotlight(),
            'news' => $this->getNews(),
        ];
    }

    private function guestPayloadCache(): CacheRepository
    {
        return Cache::store($this->guestPayloadCacheStoreName());
    }

    private function guestPayloadCacheKey(): string
    {
        return (string) config('homepage.guest_payload_key', 'homepage.payload.guest');
    }

    private function guestPayloadCacheTtl(): int
    {
        return max(60, (int) config('homepage.guest_payload_ttl_seconds', 1800));
    }

    /**
     * Personalized homepage data for an authenticated user.
     *
     * Sections:
     *   1. user_data        – welcome row counts (messages, notifications, new followers)
     *   2. from_following   – artworks from creators you follow
     *   3. for_you          – personalized recommendation preview
     *   4. trending         – same trending feed as guests
     *   5. by_categories    – fresh uploads in user's favourite categories
     *   6. suggested_creators – creators the user might want to follow
     *   7. tags / creators / news – shared with guest homepage
     */
    public function allForUser(User $user): array
    {
        $prefs = $this->prefs->build($user);

        return [
            'is_logged_in' => true,
            'user_data' => $this->getUserData($user),
            'hero' => $this->getHeroArtwork(),
            'announcement' => $this->homepageAnnouncements->toHomepagePayload($this->homepageAnnouncements->getActiveForHomepage()),
            'community_favorites' => $this->getCommunityFavorites(),
            'hall_of_fame' => $this->getHallOfFame(),
            'for_you' => $this->getForYouPreview($user),
            'from_following' => $this->getFollowingFeed($user, $prefs),
            'rising' => $this->getRising(),
            'trending' => $this->getTrending(),
            'fresh' => $this->getFreshUploads(),
            'collections_featured' => $this->getFeaturedCollections(),
            'collections_recent' => $this->getRecentCollections(),
            'collections_trending' => $this->getTrendingCollections(),
            'collections_editorial' => $this->getEditorialCollections(),
            'collections_community' => $this->getCommunityCollections(),
            'world_spotlight' => $this->worlds->homepageSpotlight($user),
            'groups' => $this->getHomepageGroups($user),
            'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
            'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
            'suggested_creators' => $this->getSuggestedCreators($user, $prefs),
            'tags' => $this->getPopularTags(),
            'creators' => $this->getCreatorSpotlight(),
            'news' => $this->getNews(),
            'preferences' => [
                'top_tags' => $prefs['top_tags'] ?? [],
                'top_categories' => $prefs['top_categories'] ?? [],
            ],
        ];
    }

    /**
     * "For You" homepage preview backed by the personalized feed engine.
     */
    public function getForYouPreview(User $user, int $limit = 12): array
    {
        try {
            $feed = $this->feedResolver->getFeed((int) $user->id, max($limit * 3, $limit));
            $algoVersion = (string) ($feed['meta']['algo_version'] ?? '');
            $discoveryEndpoint = route('api.discovery.events.store');
            $hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork');
            $dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag');

            return $this->filterMissingThumbnailPayloadItems(collect($feed['data'] ?? []))
                ->take($limit)
                ->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array {
                    $reason = (string) ($item['reason'] ?? 'Picked for you');

                    return [
                        'id' => (int) ($item['id'] ?? 0),
                        'title' => (string) ($item['title'] ?? 'Untitled'),
                        'name' => (string) ($item['title'] ?? 'Untitled'),
                        'slug' => (string) ($item['slug'] ?? ''),
                        'author' => (string) ($item['author'] ?? 'Artist'),
                        'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null,
                        'author_username' => (string) ($item['username'] ?? ''),
                        'author_avatar' => $item['avatar_url'] ?? null,
                        'avatar_url' => $item['avatar_url'] ?? null,
                        'published_as_type' => (string) ($item['published_as_type'] ?? ''),
                        'publisher' => is_array($item['publisher'] ?? null) ? $item['publisher'] : null,
                        'thumb' => $item['thumbnail_url'] ?? null,
                        'thumb_url' => $item['thumbnail_url'] ?? null,
                        'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
                        'category_name' => (string) ($item['category_name'] ?? ''),
                        'category_slug' => (string) ($item['category_slug'] ?? ''),
                        'content_type_name' => (string) ($item['content_type_name'] ?? ''),
                        'content_type_slug' => (string) ($item['content_type_slug'] ?? ''),
                        'url' => (string) ($item['url'] ?? ('/art/'.((int) ($item['id'] ?? 0)).'/'.($item['slug'] ?? ''))),
                        'width' => isset($item['width']) ? (int) $item['width'] : null,
                        'height' => isset($item['height']) ? (int) $item['height'] : null,
                        'published_at' => $item['published_at'] ?? null,
                        'primary_tag' => $item['primary_tag'] ?? null,
                        'tags' => is_array($item['tags'] ?? null) ? $item['tags'] : [],
                        'recommendation_source' => (string) ($item['source'] ?? 'mixed'),
                        'recommendation_reason' => $reason,
                        'recommendation_score' => isset($item['score']) ? round((float) $item['score'], 4) : null,
                        'recommendation_algo_version' => (string) ($item['algo_version'] ?? $algoVersion),
                        'recommendation_surface' => 'homepage-for-you',
                        'discovery_endpoint' => $discoveryEndpoint,
                        'hide_artwork_endpoint' => $hideArtworkEndpoint,
                        'dislike_tag_endpoint' => $dislikeTagEndpoint,
                        'metric_badge' => [
                            'label' => $reason,
                            'className' => 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
                        ],
                    ];
                })->values()->all();
        } catch (\Throwable $e) {
            Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);

            return [];
        }
    }

    public function getTrendingCollections(int $limit = 6): array
    {
        $surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.trending_collections', $limit);

        if ($surfaceCollections->isNotEmpty()) {
            return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
        }

        return $this->collectionService->mapCollectionCardPayloads(
            $this->collectionDiscovery->publicTrendingCollections($limit),
            false
        );
    }

    public function getEditorialCollections(int $limit = 6): array
    {
        $surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.editorial_collections', $limit);

        if ($surfaceCollections->isNotEmpty()) {
            return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
        }

        return $this->collectionService->mapCollectionCardPayloads(
            $this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_EDITORIAL, $limit),
            false
        );
    }

    public function getCommunityCollections(int $limit = 6): array
    {
        $surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.community_collections', $limit);

        if ($surfaceCollections->isNotEmpty()) {
            return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
        }

        return $this->collectionService->mapCollectionCardPayloads(
            $this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_COMMUNITY, $limit),
            false
        );
    }

    public function getFeaturedCollections(int $limit = 6): array
    {
        $surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.featured_collections', $limit);

        if ($surfaceCollections->isNotEmpty()) {
            return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
        }

        return $this->collectionService->mapCollectionCardPayloads(
            $this->collectionDiscovery->publicFeaturedCollections($limit),
            false,
        );
    }

    public function getRecentCollections(int $limit = 6): array
    {
        return $this->collectionService->mapCollectionCardPayloads(
            $this->collectionDiscovery->publicRecentCollections($limit),
            false,
        );
    }

    public function getHomepageGroups(?User $viewer = null): array
    {
        if (! $viewer) {
            return Cache::remember('homepage.groups', self::CACHE_TTL, fn (): array => $this->buildHomepageGroups());
        }

        return $this->buildHomepageGroups($viewer);
    }

    private function buildHomepageGroups(?User $viewer = null): array
    {
        $featured = $this->groupDiscovery->surfaceCards($viewer, 'featured', 4);
        $spotlight = $featured[0] ?? null;

        return [
            'spotlight' => $spotlight,
            'featured' => $featured,
            'recruiting' => $this->groupDiscovery->surfaceCards($viewer, 'recruiting', 4),
            'rising' => $this->groupDiscovery->surfaceCards($viewer, 'new_rising', 4),
            'leaderboard' => $this->leaderboards->getLeaderboard(Leaderboard::TYPE_GROUP, Leaderboard::PERIOD_MONTHLY, 5),
            'count' => $this->groupDiscovery->publicGroupCount(),
        ];
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Sections
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Hero artwork: first item from the featured list.
     */
    public function getHeroArtwork(): ?array
    {
        return Cache::remember('homepage.hero.'.$this->viewerCacheSegment(), self::CACHE_TTL, function (): ?array {
            $artwork = $this->artworks->getFeaturedArtworkWinner();

            if (! $artwork instanceof Artwork) {
                $artwork = Artwork::query()
                    ->public()
                    ->published()
                    ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
                    ->withoutMissingThumbnails()
                    ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
                    ->latest('published_at')
                    ->first();
            }

            if ($artwork instanceof Artwork) {
                $artwork->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
            }

            if (! $artwork instanceof Artwork) {
                return null;
            }

            $payload = $this->serializeArtwork($artwork, 'lg');
            $payload['featured_image'] = $this->serializeFeaturedHeroImage($artwork);

            return $payload;
        });
    }

    public function getCommunityFavorites(int $limit = self::DEFAULT_ARTWORK_RAIL_LIMIT): array
    {
        return Cache::remember("homepage.community-favorites.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit): array {
            try {
                $artworks = Artwork::query()
                    ->public()
                    ->published()
                    ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
                    ->withoutMissingThumbnails()
                    ->with(array_merge(self::ARTWORK_SERIALIZATION_RELATIONS, [
                        'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
                    ]))
                    ->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
                    ->select('artworks.*')
                    ->whereRaw('COALESCE(aas.score_30d, 0) > 0')
                    ->orderByRaw('COALESCE(aas.score_30d, 0) DESC')
                    ->orderByDesc('artworks.published_at')
                    ->limit($limit)
                    ->get();

                return $this->fillArtworkRailFromArchive($artworks, $limit)
                    ->map(fn (Artwork $artwork): array => $this->serializeArtworkWithMedalBadge($artwork, 'community_favorites'))
                    ->values()
                    ->all();
            } catch (\Throwable $e) {
                Log::warning('HomepageService::getCommunityFavorites failed', ['error' => $e->getMessage()]);

                return [];
            }
        });
    }

    public function getHallOfFame(int $limit = self::DEFAULT_ARTWORK_RAIL_LIMIT): array
    {
        return Cache::remember("homepage.hall-of-fame.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit): array {
            try {
                $artworks = Artwork::query()
                    ->public()
                    ->published()
                    ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
                    ->withoutMissingThumbnails()
                    ->with(array_merge(self::ARTWORK_SERIALIZATION_RELATIONS, [
                        'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
                    ]))
                    ->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
                    ->select('artworks.*')
                    ->whereRaw('COALESCE(aas.score_total, 0) > 0')
                    ->orderByRaw('COALESCE(aas.score_total, 0) DESC')
                    ->orderByRaw('COALESCE(aas.last_medaled_at, artworks.published_at) DESC')
                    ->limit($limit)
                    ->get();

                return $this->fillArtworkRailFromArchive($artworks, $limit)
                    ->map(fn (Artwork $artwork): array => $this->serializeArtworkWithMedalBadge($artwork, 'hall_of_fame'))
                    ->values()
                    ->all();
            } catch (\Throwable $e) {
                Log::warning('HomepageService::getHallOfFame failed', ['error' => $e->getMessage()]);

                return [];
            }
        });
    }

    /**
     * Rising Now: up to 10 artworks sorted by heat_score (updated every 15 min).
     *
     * Surfaces artworks with the fastest recent engagement growth.
     * Falls back to DB ORDER BY heat_score if Meilisearch is unavailable.
     */
    public function getRising(int $limit = 10): array
    {
        $cutoff = now()->subDays(30)->toDateString();

        return Cache::remember("homepage.rising.{$limit}.{$this->viewerCacheSegment()}", 120, function () use ($limit, $cutoff): array {
            try {
                $results = $this->search->searchWithThumbnailPreference([
                    'filter' => 'is_public = true AND is_approved = true AND created_at >= "'.$cutoff.'"',
                    'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
                ], $limit, true, 1);

                $items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));

                if ($items->isEmpty()) {
                    return $this->getRisingFromDb($limit);
                }

                if ($this->collectionHasNoRisingMomentum($this->searchResultCollection($results))) {
                    return $this->getRisingLowSignalFromDb($limit);
                }

                return $this->fillArtworkRailFromArchive($items, $limit)
                    ->map(fn ($a) => $this->serializeArtwork($a))
                    ->values()
                    ->all();
            } catch (\Throwable $e) {
                Log::warning('HomepageService::getRising Meilisearch unavailable, DB fallback', [
                    'error' => $e->getMessage(),
                ]);

                return $this->getRisingFromDb($limit);
            }
        });
    }

    /**
     * DB-only fallback for rising (Meilisearch unavailable).
     */
    private function getRisingFromDb(int $limit): array
    {
        $artworks = Artwork::public()
            ->published()
            ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
            ->withoutMissingThumbnails()
            ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
            ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
            ->select('artworks.*')
            ->where('artworks.published_at', '>=', now()->subDays(30))
            ->orderByDesc('artwork_stats.heat_score')
            ->orderByDesc('artwork_stats.engagement_velocity')
            ->limit($limit)
            ->get();

        return $this->fillArtworkRailFromArchive($artworks, $limit)
            ->map(fn ($a) => $this->serializeArtwork($a))
            ->values()
            ->all();
    }

    private function getRisingLowSignalFromDb(int $limit): array
    {
        $artworks = Artwork::public()
            ->published()
            ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
            ->withoutMissingThumbnails()
            ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
            ->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
                $join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
            })
            ->select('artworks.*')
            ->where('artworks.published_at', '>=', now()->subDays(30))
            ->orderByRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) DESC')
            ->orderByDesc('artworks.published_at')
            ->orderByDesc('artworks.id')
            ->limit($limit)
            ->get();

        return $this->fillArtworkRailFromArchive($artworks, $limit)
            ->map(fn ($a) => $this->serializeArtwork($a))
            ->values()
            ->all();
    }

    /**
     * Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
     *
     * Uses Meilisearch sorted by the V2 score (updated every 30 min).
     * Falls back to DB ORDER BY ranking_score if Meilisearch is unavailable.
     * Spec §6: ranking_score, last 30 days, highlight high-velocity artworks.
     */
    public function getTrending(int $limit = 10): array
    {
        $cutoff = now()->subDays(30)->toDateString();

        return Cache::remember("homepage.trending.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit, $cutoff): array {
            try {
                $results = $this->search->searchWithThumbnailPreference([
                    'filter' => 'is_public = true AND is_approved = true AND created_at >= "'.$cutoff.'"',
                    'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
                ], $limit, true, 1);

                $items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));

                if ($items->isEmpty()) {
                    return $this->getTrendingFromDb($limit);
                }

                return $this->fillArtworkRailFromArchive($items, $limit)
                    ->map(fn ($a) => $this->serializeArtwork($a))
                    ->values()
                    ->all();
            } catch (\Throwable $e) {
                Log::warning('HomepageService::getTrending Meilisearch unavailable, DB fallback', [
                    'error' => $e->getMessage(),
                ]);

                return $this->getTrendingFromDb($limit);
            }
        });
    }

    /**
     * DB-only fallback for trending (Meilisearch unavailable).
     * Joins artwork_stats to sort by V2 ranking_score.
     */
    private function getTrendingFromDb(int $limit): array
    {
        $artworks = Artwork::public()
            ->published()
            ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
            ->withoutMissingThumbnails()
            ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
            ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
            ->select('artworks.*')
            ->where('artworks.published_at', '>=', now()->subDays(30))
            ->orderByDesc('artwork_stats.ranking_score')
            ->orderByDesc('artwork_stats.engagement_velocity')
            ->limit($limit)
            ->get();

        return $this->fillArtworkRailFromArchive($artworks, $limit)
            ->map(fn ($a) => $this->serializeArtwork($a))
            ->values()
            ->all();
    }

    /**
     * Fresh uploads: latest 10 approved public artworks.
     * EGS: GridFiller ensures the section is never empty even on low-traffic days.
     */
    public function getFreshUploads(int $limit = 10): array
    {
        // Include EGS mode in cache key so toggling EGS updates the section within TTL
        $egsKey = EarlyGrowth::gridFillerEnabled() ? 'egs-'.EarlyGrowth::mode() : 'std';
        $cacheKey = "homepage.fresh.{$limit}.{$egsKey}.{$this->viewerCacheSegment()}";

        return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($limit): array {
            $artworks = Artwork::public()
                ->published()
                ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
                ->withoutMissingThumbnails()
                ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
                ->orderByDesc('published_at')
                ->limit($limit)
                ->get();

            // EGS: fill up to $limit when fresh uploads are sparse
            $artworks = $this->gridFiller->fillCollection($artworks, $limit);

            return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
        });
    }

    /**
     * Top 12 popular tags by usage_count.
     */
    public function getPopularTags(int $limit = 12): array
    {
        return Cache::remember("homepage.tags.{$limit}", self::CACHE_TTL, function () use ($limit): array {
            return Tag::query()
                ->where('is_active', true)
                ->orderByDesc('usage_count')
                ->limit($limit)
                ->get(['id', 'name', 'slug', 'usage_count'])
                ->map(fn ($t) => [
                    'id' => $t->id,
                    'name' => $t->name,
                    'slug' => $t->slug,
                    'count' => (int) $t->usage_count,
                ])
                ->values()
                ->all();
        });
    }

    /**
     * Creator spotlight: top 6 creators by weekly uploads, awards, and engagement.
     * "Weekly uploads" drives ranking per spec; ties broken by total awards then views.
     */
    public function getCreatorSpotlight(int $limit = 6): array
    {
        return Cache::remember("homepage.creators.{$limit}", self::CACHE_TTL, function () use ($limit): array {
            try {
                $since = now()->subWeek();

                $weeklyUploads = Artwork::query()
                    ->selectRaw('user_id, COUNT(*) as weekly_uploads')
                    ->where('is_public', true)
                    ->where('is_approved', true)
                    ->whereNull('deleted_at')
                    ->whereNotNull('published_at')
                    ->where('published_at', '<=', now())
                    ->where('published_at', '>=', $since)
                    ->groupBy('user_id');

                $rows = DB::table('users as u')
                    ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
                    ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
                    ->leftJoinSub($weeklyUploads, 'weekly_uploads', function ($join): void {
                        $join->on('weekly_uploads.user_id', '=', 'u.id');
                    })
                    ->select(
                        'u.id',
                        'u.name',
                        'u.username',
                        'up.avatar_hash',
                        DB::raw('COALESCE(us.uploads_count, 0) as upload_count'),
                        DB::raw('COALESCE(weekly_uploads.weekly_uploads, 0) as weekly_uploads'),
                        DB::raw('COALESCE(us.artwork_views_received_count, 0) as total_views'),
                        DB::raw('COALESCE(us.awards_received_count, 0) as total_awards')
                    )
                    ->whereNull('u.deleted_at')
                    ->where('u.is_active', true)
                    ->where(function ($query): void {
                        $query->where('us.uploads_count', '>', 0)
                            ->orWhere('weekly_uploads.weekly_uploads', '>', 0);
                    })
                    ->orderByDesc('weekly_uploads')
                    ->orderByDesc('total_awards')
                    ->orderByDesc('total_views')
                    ->limit($limit)
                    ->get();

                $userIds = $rows->pluck('id')->all();

                $latestArtworkIds = Artwork::public()
                    ->published()
                    ->withoutMissingThumbnails()
                    ->whereIn('user_id', $userIds)
                    ->whereNotNull('hash')
                    ->whereNotNull('thumb_ext')
                    ->selectRaw('MAX(id) as id')
                    ->groupBy('user_id')
                    ->pluck('id')
                    ->all();

                $thumbsByUser = Artwork::query()
                    ->whereIn('id', $latestArtworkIds)
                    ->get(['id', 'user_id', 'hash', 'thumb_ext'])
                    ->keyBy('user_id');

                return $rows->map(function ($u) use ($thumbsByUser) {
                    $artworkForBg = $thumbsByUser->get($u->id);
                    $bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;

                    return [
                        'id' => $u->id,
                        'name' => $u->name,
                        'uploads' => (int) $u->upload_count,
                        'weekly_uploads' => (int) $u->weekly_uploads,
                        'views' => (int) $u->total_views,
                        'awards' => (int) $u->total_awards,
                        'url' => $u->username ? '/@'.$u->username : '/profile/'.$u->id,
                        'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 128),
                        'bg_thumb' => $bgThumb,
                    ];
                })->values()->all();
            } catch (QueryException $e) {
                Log::warning('HomepageService::getCreatorSpotlight DB error', [
                    'exception' => $e->getMessage(),
                ]);

                return [];
            }
        });
    }

    /**
     * Latest 5 news posts from the forum news category.
     */
    public function getNews(int $limit = 10): array
    {
        return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array {
            try {
                $articles = NewsArticle::query()
                    ->with(['category', 'author'])
                    ->published()
                    ->editorialOrder()
                    ->limit($limit)
                    ->get();

                if ($articles->isNotEmpty()) {
                    return $articles->map(fn (NewsArticle $article) => [
                            'id' => $article->id,
                            'title' => $article->title,
                            'date' => $article->published_at,
                            'url' => route('news.show', ['slug' => $article->slug]),
                            'eyebrow' => $article->category?->name ?: $article->type_label,
                            'type' => $article->type ?? null,
                            'type_label' => $article->type_label ?? null,
                            'category' => $article->category ? ['name' => $article->category->name, 'slug' => $article->category->slug] : null,
                            'is_featured' => (bool) ($article->is_featured ?? false),
                            'is_pinned' => (bool) ($article->is_pinned ?? false),
                            'cover_url' => $article->cover_url ?? null,
                            'cover_mobile_url' => $article->cover_mobile_url ?? null,
                            'cover_srcset' => $article->cover_srcset ?? null,
                            'excerpt' => Str::limit(strip_tags((string) ($article->excerpt ?: $article->rendered_content)), 135),
                            'author' => $article->author ? ['name' => $article->author->name ?? $article->author->username, 'username' => $article->author->username ?? null] : null,
                            'views' => isset($article->views) ? (int) $article->views : 0,
                        ])->values()->all();
                }

                $items = DB::table('forum_threads as t')
                    ->leftJoin('forum_categories as c', 'c.id', '=', 't.category_id')
                    ->select('t.id', 't.title', 't.created_at', 't.slug as thread_slug')
                    ->where(function ($q) {
                        $q->where('t.category_id', 2876)
                            ->orWhereIn('c.slug', ['news', 'forum-news']);
                    })
                    ->whereNull('t.deleted_at')
                    ->orderByDesc('t.created_at')
                    ->limit($limit)
                    ->get();

                return $items->map(fn ($row) => [
                    'id' => $row->id,
                    'title' => $row->title,
                    'date' => $row->created_at,
                    'url' => '/forum/thread/'.$row->id.'-'.($row->thread_slug ?? 'post'),
                ])->values()->all();
            } catch (QueryException $e) {
                Log::warning('HomepageService::getNews DB error', [
                    'exception' => $e->getMessage(),
                ]);

                return [];
            }
        });
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Personalized sections (auth only)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Welcome-row counts: unread messages, unread notifications, new followers.
     * Returns quickly from DB using simple COUNTs; never throws.
     */
    public function getUserData(User $user): array
    {
        try {
            $unreadMessages = DB::table('conversations as c')
                ->join('conversation_participants as cp', 'cp.conversation_id', '=', 'c.id')
                ->join('messages as m', 'm.conversation_id', '=', 'c.id')
                ->where('cp.user_id', $user->id)
                ->where('m.user_id', '!=', $user->id)
                ->whereColumn('m.created_at', '>', 'cp.last_read_at')
                ->distinct('c.id')
                ->count('c.id');
        } catch (\Throwable) {
            $unreadMessages = 0;
        }

        try {
            $unreadNotifications = DB::table('notifications')
                ->where('user_id', $user->id)
                ->whereNull('read_at')
                ->count();
        } catch (\Throwable) {
            $unreadNotifications = 0;
        }

        return [
            'id' => $user->id,
            'name' => $user->name,
            'username' => $user->username,
            'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 64),
            'messages_unread' => (int) $unreadMessages,
            'notifications_unread' => (int) $unreadNotifications,
            'followers_count' => (int) ($user->statistics?->followers_count ?? 0),
        ];
    }

    /**
     * Suggested creators: active public uploaders NOT already followed by the user,
     * ranked by follower count. Optionally filtered to the user's top categories.
     */
    public function getSuggestedCreators(User $user, array $prefs, int $limit = 8): array
    {
        return Cache::remember(
            "homepage.suggested.{$user->id}",
            300,
            function () use ($user, $prefs, $limit): array {
                try {
                    $followingIds = $prefs['followed_creators'] ?? [];

                    $query = DB::table('users as u')
                        ->join('user_profiles as up', 'up.user_id', '=', 'u.id')
                        ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
                        ->select(
                            'u.id',
                            'u.name',
                            'u.username',
                            'up.avatar_hash',
                            DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
                            DB::raw('COALESCE(us.uploads_count, 0) as artworks_count'),
                        )
                        ->where('u.id', '!=', $user->id)
                        ->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
                        ->where('u.is_active', true)
                        ->orderByDesc('followers_count')
                        ->orderByDesc('artworks_count')
                        ->limit($limit);

                    $rows = $query->get();

                    return $rows->map(fn ($u) => [
                        'id' => $u->id,
                        'name' => $u->name,
                        'username' => $u->username,
                        'url' => $u->username ? '/@'.$u->username : '/profile/'.$u->id,
                        'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 64),
                        'followers_count' => (int) $u->followers_count,
                        'artworks_count' => (int) $u->artworks_count,
                    ])->values()->all();
                } catch (\Throwable $e) {
                    Log::warning('HomepageService::getSuggestedCreators failed', ['error' => $e->getMessage()]);

                    return [];
                }
            }
        );
    }

    /**
     * Latest artworks from creators the user follows (max 12).
     */
    public function getFollowingFeed(User $user, array $prefs): array
    {
        $followingIds = $prefs['followed_creators'] ?? [];

        if (empty($followingIds)) {
            return [];
        }

        return Cache::remember(
            "homepage.following.{$user->id}.{$this->viewerCacheSegment()}",
            60, // short TTL – personal data
            function () use ($followingIds): array {
                $artworks = Artwork::public()
                    ->published()
                    ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
                    ->withoutMissingThumbnails()
                    ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
                    ->whereIn('user_id', $followingIds)
                    ->orderByDesc('published_at')
                    ->limit(10)
                    ->get();

                return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
            }
        );
    }

    /**
     * Fresh artworks matching the user's favourite tags (max 12).
     * Powered by Meilisearch.
     */
    public function getByTags(array $tagSlugs): array
    {
        if (empty($tagSlugs)) {
            return [];
        }

        try {
            $results = $this->search->discoverByTags($tagSlugs, 12);
            $items = $this->fillArtworkRailFromArchive(
                $this->searchResultCollection($results),
                12,
                static fn ($query) => $query->whereHas('tags', function ($tagQuery) use ($tagSlugs): void {
                    $tagQuery->whereIn('slug', array_slice($tagSlugs, 0, 5));
                }),
            );

            return $items
                ->map(fn ($a) => $this->serializeArtwork($a))
                ->values()
                ->all();
        } catch (\Throwable $e) {
            Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);

            return [];
        }
    }

    /**
     * Fresh artworks in the user's favourite categories (max 12).
     * Powered by Meilisearch.
     */
    public function getByCategories(array $categorySlugs): array
    {
        if (empty($categorySlugs)) {
            return [];
        }

        try {
            $results = $this->search->discoverByCategories($categorySlugs, 12);
            $items = $this->fillArtworkRailFromArchive(
                $this->searchResultCollection($results),
                12,
                static fn ($query) => $query->whereHas('categories', function ($categoryQuery) use ($categorySlugs): void {
                    $categoryQuery->whereIn('slug', array_slice($categorySlugs, 0, 3));
                }),
            );

            return $items
                ->map(fn ($a) => $this->serializeArtwork($a))
                ->values()
                ->all();
        } catch (\Throwable $e) {
            Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);

            return [];
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Helpers
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * @return Collection<int, Artwork>
     */
    private function searchResultCollection(mixed $results): Collection
    {
        if ($results instanceof Collection) {
            return $results;
        }

        if (is_object($results) && method_exists($results, 'getCollection')) {
            $collection = $results->getCollection();
            if ($collection instanceof Collection) {
                return $collection;
            }
        }

        return collect();
    }

    /**
     * Ensure serialized artwork payloads do not trigger lazy-loading per item.
     *
     * @param  Collection<int, Artwork>  $artworks
     * @return Collection<int, Artwork>
     */
    private function prepareArtworksForSerialization(Collection $artworks): Collection
    {
        if ($artworks->isEmpty()) {
            return $artworks;
        }

        $artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);

        return $artworks
            ->reject(fn ($artwork) => (bool) ($artwork->has_missing_thumbnails ?? false))
            ->values();
    }

    /**
     * Backfill sparse homepage rails with recent archive artworks while preserving lead ordering.
     *
     * @param  Collection<int, Artwork>  $artworks
     * @return Collection<int, Artwork>
     */
    private function fillArtworkRailFromArchive(Collection $artworks, int $limit, ?callable $fallbackConstraint = null): Collection
    {
        $artworks = $this->prepareArtworksForSerialization($artworks)->take($limit)->values();

        if ($artworks->count() >= $limit) {
            return $artworks;
        }

        $needed = $limit - $artworks->count();
        $excludeIds = $artworks
            ->pluck('id')
            ->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
            ->map(fn ($id) => (int) $id)
            ->values()
            ->all();

        $fallback = Artwork::query()
            ->public()
            ->published()
            ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
            ->withoutMissingThumbnails()
            ->with(self::ARTWORK_SERIALIZATION_RELATIONS)
            ->when($fallbackConstraint !== null, fn ($query) => $fallbackConstraint($query))
            ->when(! empty($excludeIds), fn ($query) => $query->whereNotIn('artworks.id', $excludeIds))
            ->orderByDesc('artworks.published_at')
            ->orderByDesc('artworks.id')
            ->limit($needed)
            ->get();

        return $artworks
            ->concat($fallback)
            ->unique('id')
            ->take($limit)
            ->values();
    }

    /**
     * @param  Collection<int, array<string, mixed>>  $items
     * @return Collection<int, array<string, mixed>>
     */
    private function filterMissingThumbnailPayloadItems(Collection $items): Collection
    {
        if ($items->isEmpty()) {
            return $items;
        }

        $ids = $items
            ->pluck('id')
            ->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
            ->map(fn ($id) => (int) $id)
            ->values();

        if ($ids->isEmpty()) {
            return $items;
        }

        $missingIds = Artwork::query()
            ->whereIn('id', $ids)
            ->where('has_missing_thumbnails', true)
            ->pluck('id')
            ->map(fn ($id) => (int) $id)
            ->flip();

        if ($missingIds->isEmpty()) {
            return $items;
        }

        return $items
            ->reject(fn (array $item) => $missingIds->has((int) ($item['id'] ?? 0)))
            ->values();
    }

    private function collectionHasNoRisingMomentum(Collection $items): bool
    {
        if ($items->isEmpty()) {
            return true;
        }

        return $items->every(function ($item): bool {
            $heat = (float) ($item->heat_score ?? $item->stats?->heat_score ?? 0);
            $velocity = (float) ($item->engagement_velocity ?? $item->stats?->engagement_velocity ?? 0);

            return $heat <= 0.0 && $velocity <= 0.0;
        });
    }

    private function risingRecentActivitySubquery()
    {
        $since = now()->startOfHour()->subHours(24);

        return DB::table('artwork_metric_snapshots_hourly as rising_snapshots')
            ->selectRaw('rising_snapshots.artwork_id')
            ->selectRaw('(
                COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0)
                + (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3)
                + (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4)
                + (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5)
                + (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6)
            ) as recent_signal_24h')
            ->where('rising_snapshots.bucket_hour', '>=', $since)
            ->groupBy('rising_snapshots.artwork_id');
    }

    private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
    {
        $awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null;
        $thumbSm = $artwork->thumbUrl('sm');
        $thumbMd = $artwork->thumbUrl('md');
        $thumbLg = $artwork->thumbUrl('lg');
        $thumbXl = $artwork->thumbUrl('xl');
        $thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg);
        $primaryCategory = $artwork->categories->sortBy('sort_order')->first();

        $thumbSrcset = collect([
            $thumbSm ? $thumbSm.' 320w' : null,
            $thumbMd ? $thumbMd.' 640w' : null,
            $thumbLg ? $thumbLg.' 1280w' : null,
            $thumbXl ? $thumbXl.' 1920w' : null,
        ])->filter()->implode(', ');

        $publisher = $this->mapArtworkPublisherPayload($artwork);
        $isGroupPublisher = ($publisher['type'] ?? null) === 'group';
        $authorId = $artwork->user_id;
        $authorName = $isGroupPublisher ? ((string) ($publisher['name'] ?? 'Skinbase Group')) : ($artwork->user?->name ?? 'Artist');
        $authorUsername = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
        $avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
        $authorAvatar = $isGroupPublisher
            ? ($publisher['avatar_url'] ?? null)
            : AvatarUrl::forUser((int) $authorId, $avatarHash, 64);

        return $this->maturity->decoratePayload([
            'id' => $artwork->id,
            'title' => $artwork->title ?? 'Untitled',
            'slug' => $artwork->slug,
            'author' => $authorName,
            'author_id' => $authorId,
            'author_username' => $authorUsername,
            'author_avatar' => $authorAvatar,
            'published_as_type' => $artwork->publishedAsType(),
            'publisher' => $publisher,
            'thumb' => $thumb,
            'thumb_sm' => $thumbSm,
            'thumb_md' => $thumbMd,
            'thumb_lg' => $thumbLg,
            'thumb_xl' => $thumbXl,
            'thumb_srcset' => $thumbSrcset !== '' ? $thumbSrcset : null,
            'category_name' => $primaryCategory->name ?? '',
            'category_slug' => $primaryCategory->slug ?? '',
            'content_type_name' => $primaryCategory?->contentType?->name ?? '',
            'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
            'url' => '/art/'.$artwork->id.'/'.($artwork->slug ?? ''),
            'width' => $artwork->width,
            'height' => $artwork->height,
            'published_at' => $artwork->published_at?->toIso8601String(),
            'medals' => [
                'gold' => (int) ($awardStat?->gold_count ?? 0),
                'silver' => (int) ($awardStat?->silver_count ?? 0),
                'bronze' => (int) ($awardStat?->bronze_count ?? 0),
                'score' => (int) ($awardStat?->score_total ?? 0),
                'score_7d' => (int) ($awardStat?->score_7d ?? 0),
                'score_30d' => (int) ($awardStat?->score_30d ?? 0),
            ],
        ], $artwork, request()->user());
    }

    /**
     * @return array<string, mixed>
     */
    private function serializeFeaturedHeroImage(Artwork $artwork): array
    {
        $variants = $this->featuredImages->variants();
        $variantUrls = [];

        foreach (array_keys($variants) as $variant) {
            $variantUrls[$variant] = $artwork->hasFeaturedThumbnail($variant)
                ? $this->featuredImages->url($artwork, $variant)
                : null;
        }

        $preloadSrcset = collect($variants)
            ->map(function (array $config, string $variant) use ($variantUrls): ?string {
                $url = $variantUrls[$variant] ?? null;

                return $url ? $url.' '.(int) $config['width'].'w' : null;
            })
            ->filter()
            ->implode(', ');

        $xsSources = collect(['mobile_xs', 'mobile_sm'])
            ->map(function (string $variant) use ($variantUrls, $variants): ?string {
                $url = $variantUrls[$variant] ?? null;

                return $url ? $url.' '.(int) ($variants[$variant]['width'] ?? 0).'w' : null;
            })
            ->filter()
            ->implode(', ');

        $mobileSources = collect(['mobile_sm', 'mobile'])
            ->map(function (string $variant) use ($variantUrls, $variants): ?string {
                $url = $variantUrls[$variant] ?? null;

                return $url ? $url.' '.(int) ($variants[$variant]['width'] ?? 0).'w' : null;
            })
            ->filter()
            ->implode(', ');

        $desktopSources = collect(['desktop', 'desktop_xl'])
            ->map(function (string $variant) use ($variantUrls, $variants): ?string {
                $url = $variantUrls[$variant] ?? null;

                return $url ? $url.' '.(int) ($variants[$variant]['width'] ?? 0).'w' : null;
            })
            ->filter()
            ->implode(', ');

        $pictureSources = array_values(array_filter([
            $xsSources !== '' ? ['media' => '(max-width: 479px)', 'srcset' => $xsSources, 'sizes' => '100vw'] : null,
            $mobileSources !== '' ? ['media' => '(max-width: 767px)', 'srcset' => $mobileSources, 'sizes' => '100vw'] : null,
            ! empty($variantUrls['tablet']) ? ['media' => '(max-width: 1279px)', 'srcset' => $variantUrls['tablet'].' '.(int) ($variants['tablet']['width'] ?? 0).'w', 'sizes' => '100vw'] : null,
            $desktopSources !== '' ? ['media' => '(min-width: 1280px)', 'srcset' => $desktopSources, 'sizes' => '100vw'] : null,
        ]));

        return [
            'alt' => $artwork->featuredImageAltText(),
            'variants' => $variantUrls,
            'sources' => $pictureSources,
            'img_src' => $artwork->featuredThumbnailUrl('desktop'),
            'img_srcset' => $preloadSrcset !== '' ? $preloadSrcset : ($artwork->thumb_srcset ?? null),
            'img_sizes' => '100vw',
            'preload_url' => $variantUrls['desktop_xl']
                ?? $variantUrls['desktop']
                ?? $artwork->thumbUrl('xl')
                ?? $artwork->thumbUrl('lg')
                ?? 'https://files.skinbase.org/default/missing_xl.webp',
            'preload_srcset' => $preloadSrcset !== '' ? $preloadSrcset : ($artwork->thumb_srcset ?? null),
            'preload_sizes' => '100vw',
        ];
    }

    /**
     * @return array<string, mixed>|null
     */
    private function mapArtworkPublisherPayload(Artwork $artwork): ?array
    {
        if ($artwork->publishedAsType() !== Artwork::PUBLISHED_AS_GROUP) {
            return null;
        }

        $group = $artwork->relationLoaded('group') ? $artwork->group : $artwork->group()->first();
        if (! $group) {
            return null;
        }

        return [
            'id' => (int) $group->id,
            'type' => 'group',
            'name' => (string) $group->name,
            'slug' => (string) $group->slug,
            'headline' => (string) ($group->headline ?? ''),
            'avatar_url' => $group->avatarUrl(),
            'profile_url' => $group->publicUrl(),
            'followers_count' => (int) ($group->followers_count ?? 0),
        ];
    }

    private function serializeArtworkWithMedalBadge(Artwork $artwork, string $surface): array
    {
        $awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null;
        $payload = $this->serializeArtwork($artwork);
        $score = $surface === 'community_favorites'
            ? (int) ($awardStat?->score_30d ?? 0)
            : (int) ($awardStat?->score_total ?? 0);

        $payload['metric_badge'] = [
            'label' => $surface === 'community_favorites'
                ? '30d medals: '.$score
                : 'All-time medals: '.$score,
            'className' => $surface === 'community_favorites'
                ? 'bg-amber-500/14 text-amber-100 ring-amber-300/30'
                : 'bg-cyan-500/14 text-cyan-100 ring-cyan-300/30',
        ];

        return $payload;
    }

    private function viewerCacheSegment(): string
    {
        return 'visibility-'.$this->maturity->viewerPreferences(request()->user())['visibility'];
    }
}
