<?php

declare(strict_types=1);

namespace App\Services\Recommendations;

use App\Jobs\RegenerateUserRecommendationCacheJob;
use App\Models\Artwork;
use App\Models\ArtworkEmbedding;
use App\Models\UserRecommendationCache;
use App\Models\UserNegativeSignal;
use App\Support\AvatarUrl;
use App\Services\Vision\VectorService;
use Carbon\CarbonImmutable;
use Laravel\Scout\Builder as ScoutBuilder;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

final class RecommendationServiceV2
{
    public function __construct(
        private readonly SessionRecoService $sessionReco,
        private readonly VectorService $vectors,
    )
    {
    }

    public function getFeed(int $userId, int $limit = 24, ?string $cursor = null, ?string $algoVersion = null): array
    {
        $safeLimit = max(1, min(50, $limit));
        $resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion);
        $offset = $this->decodeCursorToOffset($cursor);

        $cache = UserRecommendationCache::query()
            ->where('user_id', $userId)
            ->where('algo_version', $resolvedAlgoVersion)
            ->first();

        $cacheItems = $this->extractCacheItems($cache);
        $expectedCacheVersion = $this->currentCacheVersion();
        $isFresh = $cache !== null
            && (string) ($cache->cache_version ?? '') === $expectedCacheVersion
            && $cache->expires_at !== null
            && $cache->expires_at->isFuture();

        $cacheStatus = 'hit';
        if ($cache === null) {
            $cacheStatus = 'miss';
        } elseif (! $isFresh) {
            $cacheStatus = 'stale';
        }

        if ($cache === null || ! $isFresh) {
            RegenerateUserRecommendationCacheJob::dispatch($userId, $resolvedAlgoVersion)
                ->onQueue((string) config('discovery.queue', 'default'));
        }

        $items = $cacheItems;
        if ($items === []) {
            $items = $this->buildRecommendations($userId, $resolvedAlgoVersion);
            $cacheStatus .= '-fallback';
        }

        return $this->buildFeedPageResponse(
            items: $items,
            offset: $offset,
            limit: $safeLimit,
            algoVersion: $resolvedAlgoVersion,
            cacheStatus: $cacheStatus,
            generatedAt: $cache?->generated_at?->toIso8601String()
        );
    }

    public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void
    {
        $resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion);
        $cacheVersion = $this->currentCacheVersion();
        $ttlMinutes = $this->currentCacheTtlMinutes();
        $items = $this->buildRecommendations($userId, $resolvedAlgoVersion);
        $generatedAt = now();

        UserRecommendationCache::query()->updateOrCreate(
            [
                'user_id' => $userId,
                'algo_version' => $resolvedAlgoVersion,
            ],
            [
                'cache_version' => $cacheVersion,
                'recommendations_json' => [
                    'items' => $items,
                    'algo_version' => $resolvedAlgoVersion,
                    'generated_at' => $generatedAt->toIso8601String(),
                ],
                'generated_at' => $generatedAt,
                'expires_at' => now()->addMinutes($ttlMinutes),
            ]
        );
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    public function buildRecommendations(int $userId, string $algoVersion): array
    {
        $poolLimit = max(40, (int) config('discovery.v2.feed_pool_size', 240));
        $profile = $this->sessionReco->mergedProfile($userId, $algoVersion);
        $negativeSignals = $this->negativeSignals($userId);
        $hiddenArtworkIds = $negativeSignals['hidden_artwork_ids'];
        $dislikedTagIds = $negativeSignals['disliked_tag_ids'];
        $dislikedTagSlugs = $negativeSignals['disliked_tag_slugs'];
        $seenArtworkIds = array_values(array_unique(array_merge(
            $profile['seen_artwork_ids'],
            $this->recentDiscoveryArtworkIds($userId)
        )));

        $layerTargets = $this->resolveLayerTargets($poolLimit);
        $layered = array_merge(
            $this->buildPersonalizedLayer($userId, $algoVersion, $profile, $hiddenArtworkIds, $layerTargets['personalized']),
            $this->buildSocialLayer($userId, $hiddenArtworkIds, $layerTargets['social']),
            $this->buildTrendingLayer($userId, $hiddenArtworkIds, $layerTargets['trending']),
            $this->buildExplorationLayer($userId, $algoVersion, $profile, $hiddenArtworkIds, $layerTargets['exploration']),
            $this->buildVectorLayer($profile, $hiddenArtworkIds)
        );

        $deduped = [];
        foreach ($layered as $candidate) {
            $artworkId = (int) ($candidate['artwork_id'] ?? 0);
            if ($artworkId <= 0 || in_array($artworkId, $hiddenArtworkIds, true)) {
                continue;
            }

            if (! isset($deduped[$artworkId])) {
                $deduped[$artworkId] = $candidate;
                continue;
            }

            $deduped[$artworkId]['layer_sources'] = array_values(array_unique(array_merge(
                (array) ($deduped[$artworkId]['layer_sources'] ?? []),
                (array) ($candidate['layer_sources'] ?? []),
            )));
            $deduped[$artworkId]['source'] = (string) $deduped[$artworkId]['source'];
            $deduped[$artworkId]['base_score'] = max(
                (float) ($deduped[$artworkId]['base_score'] ?? 0.0),
                (float) ($candidate['base_score'] ?? 0.0)
            );
            $deduped[$artworkId]['session_seed'] = max(
                (float) ($deduped[$artworkId]['session_seed'] ?? 0.0),
                (float) ($candidate['session_seed'] ?? 0.0)
            );
            $deduped[$artworkId]['vector_seed'] = max(
                (float) ($deduped[$artworkId]['vector_seed'] ?? 0.0),
                (float) ($candidate['vector_seed'] ?? 0.0)
            );
        }

        $candidateRows = $this->loadCandidateRows(array_keys($deduped), $userId, $seenArtworkIds, $dislikedTagIds, $dislikedTagSlugs);
        if ($candidateRows === []) {
            return [];
        }

        $weights = (array) config('discovery.v2.weights', []);
        $selected = [];
        $creatorCounts = [];
        $recentTagCounts = [];
        $maxPerCreator = max(1, (int) config('discovery.v2.max_per_creator', 3));

        foreach ($candidateRows as $row) {
            $artworkId = (int) $row['id'];
            $seed = (array) ($deduped[$artworkId] ?? []);
            $baseScore = (float) ($seed['base_score'] ?? 0.0);
            $sessionBoost = (float) ($row['session_boost'] ?? 0.0) * (float) ($weights['session'] ?? 1.4);
            $socialBoost = (float) ($row['social_boost'] ?? 0.0) * (float) ($weights['social'] ?? 1.1);
            $trendingBoost = (float) ($row['trending_boost'] ?? 0.0) * (float) ($weights['trending'] ?? 0.95);
            $explorationBoost = (float) ($row['exploration_boost'] ?? 0.0) * (float) ($weights['exploration'] ?? 0.7);
            $creatorBoost = (float) ($row['creator_boost'] ?? 0.0) * (float) ($weights['creator'] ?? 0.5);
            $vectorBoost = (float) ($seed['vector_seed'] ?? 0.0) * (float) config('discovery.v3.vector_similarity_weight', 0.8);
            $negativePenalty = (float) ($row['negative_penalty'] ?? 0.0);
            $repetitionPenalty = $this->repetitionPenalty($row, $creatorCounts, $recentTagCounts) * (float) ($weights['repetition_penalty'] ?? 0.45);

            $row['score'] = max(0.0, ($baseScore * (float) ($weights['base'] ?? 1.0)) + $sessionBoost + $socialBoost + $trendingBoost + $explorationBoost + $creatorBoost + $vectorBoost - $negativePenalty - $repetitionPenalty);
            $row['layer_sources'] = array_values(array_unique((array) ($seed['layer_sources'] ?? [])));
            if ($row['layer_sources'] === []) {
                $row['layer_sources'] = [(string) ($seed['source'] ?? $row['source'] ?? 'personalized')];
            }
            $row['source'] = $this->resolveSource($row['layer_sources']);
            $row['vector_similarity_score'] = round((float) ($seed['vector_seed'] ?? 0.0), 6);
            $row['vector_influenced'] = in_array('vector', $row['layer_sources'], true) || ((float) ($seed['vector_seed'] ?? 0.0) > 0.0);
            $row['ranking_signals'] = [
                'base_score' => round($baseScore, 6),
                'session_boost' => round($sessionBoost, 6),
                'social_boost' => round($socialBoost, 6),
                'trending_boost' => round($trendingBoost, 6),
                'exploration_boost' => round($explorationBoost, 6),
                'creator_boost' => round($creatorBoost, 6),
                'vector_similarity_score' => round((float) ($seed['vector_seed'] ?? 0.0), 6),
                'vector_boost' => round($vectorBoost, 6),
                'negative_penalty' => round($negativePenalty, 6),
                'repetition_penalty' => round($repetitionPenalty, 6),
            ];

            $selected[] = $row;
        }

        usort($selected, static fn (array $left, array $right): int => ((float) $right['score']) <=> ((float) $left['score']));

        $output = [];
        $creatorCounts = [];
        $recentTagCounts = [];
        foreach ($selected as $row) {
            $creatorId = (int) ($row['creator_id'] ?? 0);
            if (($creatorCounts[$creatorId] ?? 0) >= $maxPerCreator) {
                continue;
            }

            $output[] = [
                'artwork_id' => (int) $row['id'],
                'score' => round((float) $row['score'], 6),
                'source' => (string) $row['source'],
                'layer_sources' => array_values(array_unique((array) $row['layer_sources'])),
                'vector_influenced' => (bool) ($row['vector_influenced'] ?? false),
                'vector_similarity_score' => round((float) ($row['vector_similarity_score'] ?? 0.0), 6),
                'ranking_signals' => (array) ($row['ranking_signals'] ?? []),
            ];

            $creatorCounts[$creatorId] = ($creatorCounts[$creatorId] ?? 0) + 1;
            foreach ((array) ($row['tag_slugs'] ?? []) as $tagSlug) {
                $recentTagCounts[$tagSlug] = ($recentTagCounts[$tagSlug] ?? 0) + 1;
            }

            if (count($output) >= $poolLimit) {
                break;
            }
        }

        return $output;
    }

    /**
     * @param array<int, int> $hiddenArtworkIds
     * @param array<string, mixed> $profile
     * @return array<int, array<string, mixed>>
     */
    private function buildPersonalizedLayer(int $userId, string $algoVersion, array $profile, array $hiddenArtworkIds, int $target): array
    {
        if ($target <= 0) {
            return [];
        }

        $mergedScores = (array) ($profile['merged_scores'] ?? []);
        $sessionScores = (array) ($profile['session_scores'] ?? []);
        $tagSlugs = $this->topKeysByPrefix($mergedScores, 'tag:', 12);
        $categoryIds = array_map('intval', $this->topKeysByPrefix($mergedScores, 'category:', 8));
        $recentArtworkIds = array_slice(array_map('intval', (array) ($profile['recent_artwork_ids'] ?? [])), 0, 8);
        $candidateIds = [];

        foreach ($this->searchByTags($userId, $tagSlugs, $target * 3) as $artworkId) {
            $candidateIds[] = $artworkId;
        }

        foreach ($recentArtworkIds as $artworkId) {
            $similar = DB::table('artwork_similarities')
                ->where('algo_version', $algoVersion)
                ->where('artwork_id', $artworkId)
                ->orderBy('rank')
                ->orderByDesc('score')
                ->limit(max(8, (int) round($target / 2)))
                ->pluck('similar_artwork_id')
                ->map(static fn (mixed $id): int => (int) $id)
                ->all();

            $candidateIds = array_merge($candidateIds, $similar);
        }

        if ($categoryIds !== []) {
            $categoryCandidates = DB::table('artworks')
                ->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
                ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
                ->whereIn('artwork_category.category_id', $categoryIds)
                ->where('artworks.user_id', '!=', $userId)
                ->whereNull('artworks.deleted_at')
                ->where('artworks.is_public', true)
                ->where('artworks.is_approved', true)
                ->whereNotNull('artworks.published_at')
                ->where('artworks.published_at', '<=', now())
                ->orderByDesc('artwork_stats.ranking_score')
                ->orderByDesc('artworks.trending_score_24h')
                ->limit($target * 2)
                ->pluck('artworks.id')
                ->map(static fn (mixed $id): int => (int) $id)
                ->all();

            $candidateIds = array_merge($candidateIds, $categoryCandidates);
        }

        $candidateIds = array_values(array_unique(array_filter($candidateIds, static fn (int $id): bool => $id > 0 && ! in_array($id, $hiddenArtworkIds, true))));
        $items = [];
        foreach (array_slice($candidateIds, 0, $target * 3) as $artworkId) {
            $sessionSeed = (float) ($sessionScores['artwork:' . $artworkId] ?? 0.0);
            $items[] = [
                'artwork_id' => $artworkId,
                'base_score' => 1.0 + $sessionSeed,
                'session_seed' => $sessionSeed,
                'source' => 'personalized',
                'layer_sources' => ['personalized'],
            ];
        }

        return array_slice($items, 0, $target);
    }

    /**
     * @param array<int, int> $hiddenArtworkIds
     * @return array<int, array<string, mixed>>
     */
    private function buildSocialLayer(int $userId, array $hiddenArtworkIds, int $target): array
    {
        if ($target <= 0) {
            return [];
        }

        $followedCreatorIds = DB::table('user_followers')
            ->where('follower_id', $userId)
            ->pluck('user_id')
            ->map(static fn (mixed $id): int => (int) $id)
            ->all();

        if ($followedCreatorIds === []) {
            return [];
        }

        $ownCreatorArtworks = DB::table('artworks')
            ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
            ->whereIn('artworks.user_id', $followedCreatorIds)
            ->whereNull('artworks.deleted_at')
            ->where('artworks.is_public', true)
            ->where('artworks.is_approved', true)
            ->whereNotNull('artworks.published_at')
            ->where('artworks.published_at', '>=', now()->subDays(30))
            ->orderByDesc('artworks.trending_score_24h')
            ->orderByDesc('artwork_stats.ranking_score')
            ->limit($target * 2)
            ->pluck('artworks.id')
            ->map(static fn (mixed $id): int => (int) $id)
            ->all();

        $likedByFollowed = DB::table('artwork_favourites')
            ->join('artworks', 'artworks.id', '=', 'artwork_favourites.artwork_id')
            ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
            ->whereIn('artwork_favourites.user_id', $followedCreatorIds)
            ->where('artworks.user_id', '!=', $userId)
            ->whereNull('artworks.deleted_at')
            ->where('artworks.is_public', true)
            ->where('artworks.is_approved', true)
            ->orderByDesc('artwork_favourites.created_at')
            ->orderByDesc('artwork_stats.ranking_score')
            ->limit($target * 2)
            ->pluck('artworks.id')
            ->map(static fn (mixed $id): int => (int) $id)
            ->all();

        $items = [];
        foreach (array_slice(array_values(array_unique(array_merge($ownCreatorArtworks, $likedByFollowed))), 0, $target * 2) as $artworkId) {
            if (in_array($artworkId, $hiddenArtworkIds, true)) {
                continue;
            }

            $items[] = [
                'artwork_id' => $artworkId,
                'base_score' => 0.9,
                'session_seed' => 0.0,
                'source' => 'social',
                'layer_sources' => ['social'],
            ];
        }

        return array_slice($items, 0, $target);
    }

    /**
     * @param array<int, int> $hiddenArtworkIds
     * @return array<int, array<string, mixed>>
     */
    private function buildTrendingLayer(int $userId, array $hiddenArtworkIds, int $target): array
    {
        if ($target <= 0) {
            return [];
        }

        $candidateIds = DB::table('artworks')
            ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
            ->where('artworks.user_id', '!=', $userId)
            ->whereNull('artworks.deleted_at')
            ->where('artworks.is_public', true)
            ->where('artworks.is_approved', true)
            ->whereNotNull('artworks.published_at')
            ->where('artworks.published_at', '<=', now())
            ->orderByDesc('artworks.trending_score_1h')
            ->orderByDesc('artworks.trending_score_24h')
            ->orderByDesc('artworks.trending_score_7d')
            ->orderByDesc('artwork_stats.heat_score')
            ->limit($target * 3)
            ->pluck('artworks.id')
            ->map(static fn (mixed $id): int => (int) $id)
            ->all();

        $items = [];
        foreach ($candidateIds as $artworkId) {
            if (in_array($artworkId, $hiddenArtworkIds, true)) {
                continue;
            }

            $items[] = [
                'artwork_id' => $artworkId,
                'base_score' => 0.8,
                'session_seed' => 0.0,
                'source' => 'trending',
                'layer_sources' => ['trending'],
            ];
        }

        return array_slice($items, 0, $target);
    }

    /**
     * @param array<string, mixed> $profile
     * @param array<int, int> $hiddenArtworkIds
     * @return array<int, array<string, mixed>>
     */
    private function buildExplorationLayer(int $userId, string $algoVersion, array $profile, array $hiddenArtworkIds, int $target): array
    {
        if ($target <= 0) {
            return [];
        }

        $mergedScores = (array) ($profile['merged_scores'] ?? []);
        $seenCreatorIds = array_map('intval', (array) ($profile['recent_creator_ids'] ?? []));
        $knownTagSlugs = $this->topKeysByPrefix($mergedScores, 'tag:', 16);
        $freshHours = max(1, (int) config('discovery.v2.fresh_upload_hours', 72));
        $newCreatorDays = max(7, (int) config('discovery.v2.new_creator_days', 45));

        $freshUploads = DB::table('artworks')
            ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
            ->where('artworks.user_id', '!=', $userId)
            ->whereNull('artworks.deleted_at')
            ->where('artworks.is_public', true)
            ->where('artworks.is_approved', true)
            ->where('artworks.published_at', '>=', now()->subHours($freshHours))
            ->when($seenCreatorIds !== [], fn ($query) => $query->whereNotIn('artworks.user_id', $seenCreatorIds))
            ->orderByDesc('artworks.published_at')
            ->orderByDesc('artwork_stats.heat_score')
            ->limit($target * 2)
            ->pluck('artworks.id')
            ->map(static fn (mixed $id): int => (int) $id)
            ->all();

        $newCreators = DB::table('artworks')
            ->join('users', 'users.id', '=', 'artworks.user_id')
            ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
            ->where('artworks.user_id', '!=', $userId)
            ->whereNull('artworks.deleted_at')
            ->where('artworks.is_public', true)
            ->where('artworks.is_approved', true)
            ->where('users.created_at', '>=', now()->subDays($newCreatorDays))
            ->when($seenCreatorIds !== [], fn ($query) => $query->whereNotIn('artworks.user_id', $seenCreatorIds))
            ->orderByDesc('artwork_stats.heat_score')
            ->orderByDesc('artworks.published_at')
            ->limit($target * 2)
            ->pluck('artworks.id')
            ->map(static fn (mixed $id): int => (int) $id)
            ->all();

        $unseenTagCandidates = [];
        if ($knownTagSlugs !== []) {
            $unseenTagCandidates = $this->searchOutsideKnownTags($userId, $knownTagSlugs, $target * 2);
        }

        $items = [];
        foreach (array_slice(array_values(array_unique(array_merge($freshUploads, $newCreators, $unseenTagCandidates))), 0, $target * 3) as $artworkId) {
            if (in_array($artworkId, $hiddenArtworkIds, true)) {
                continue;
            }

            $items[] = [
                'artwork_id' => $artworkId,
                'base_score' => 0.65,
                'session_seed' => 0.0,
                'source' => 'exploration',
                'layer_sources' => ['exploration'],
            ];
        }

        return array_slice($items, 0, $target);
    }

    /**
     * @param array<string, mixed> $profile
     * @param array<int, int> $hiddenArtworkIds
     * @return array<int, array<string, mixed>>
     */
    private function buildVectorLayer(array $profile, array $hiddenArtworkIds): array
    {
        if (! $this->v3Enabled() || ! $this->vectors->isConfigured()) {
            return [];
        }

        $seedArtworkIds = array_slice(array_values(array_unique(array_map('intval', (array) ($profile['recent_artwork_ids'] ?? [])))), 0, max(1, (int) config('discovery.v3.max_seed_artworks', 3)));
        if ($seedArtworkIds === []) {
            return [];
        }

        $candidatePool = max(1, (int) config('discovery.v3.vector_candidate_pool', 60));
        $perSeedLimit = max(6, (int) ceil($candidatePool / max(1, count($seedArtworkIds))));
        $seedArtworks = Artwork::query()->whereIn('id', $seedArtworkIds)->public()->published()->get()->keyBy('id');
        $baseScore = (float) config('discovery.v3.vector_base_score', 0.75);

        $merged = [];
        foreach ($seedArtworkIds as $seedArtworkId) {
            /** @var Artwork|null $seedArtwork */
            $seedArtwork = $seedArtworks->get($seedArtworkId);
            if ($seedArtwork === null) {
                continue;
            }

            try {
                $matches = $this->vectors->similarToArtwork($seedArtwork, $perSeedLimit);
            } catch (\Throwable $e) {
                Log::warning('RecommendationServiceV2 vector layer failed', [
                    'seed_artwork_id' => $seedArtworkId,
                    'error' => $e->getMessage(),
                ]);

                continue;
            }

            foreach ($matches as $match) {
                $artworkId = (int) ($match['id'] ?? 0);
                if ($artworkId <= 0 || in_array($artworkId, $hiddenArtworkIds, true)) {
                    continue;
                }

                $vectorSeed = (float) ($match['score'] ?? 0.0);
                if (! isset($merged[$artworkId])) {
                    $merged[$artworkId] = [
                        'artwork_id' => $artworkId,
                        'base_score' => $baseScore,
                        'session_seed' => 0.0,
                        'vector_seed' => $vectorSeed,
                        'source' => 'vector',
                        'layer_sources' => ['vector'],
                    ];

                    continue;
                }

                $merged[$artworkId]['vector_seed'] = max((float) ($merged[$artworkId]['vector_seed'] ?? 0.0), $vectorSeed);
                $merged[$artworkId]['base_score'] = max((float) ($merged[$artworkId]['base_score'] ?? 0.0), $baseScore);
            }
        }

        return array_slice(array_values($merged), 0, $candidatePool);
    }

    /**
     * @param array<int, int> $candidateIds
     * @param array<int, int> $seenArtworkIds
     * @param array<int, int> $dislikedTagIds
     * @param array<int, string> $dislikedTagSlugs
     * @return array<int, array<string, mixed>>
     */
    private function loadCandidateRows(array $candidateIds, int $userId, array $seenArtworkIds, array $dislikedTagIds, array $dislikedTagSlugs): array
    {
        if ($candidateIds === []) {
            return [];
        }

        /** @var Collection<int, Artwork> $artworks */
        $artworks = Artwork::query()
            ->with([
                'user:id,name,username',
                'user.profile:user_id,avatar_hash',
                'user.statistics:user_id,followers_count,following_count',
                'categories:id,name,slug,content_type_id,parent_id,sort_order',
                'categories.contentType:id,name,slug',
                'tags:id,slug',
                'stats:artwork_id,views,downloads,favorites,comments_count,shares_count,views_1h,favourites_1h,comments_1h,shares_1h,ranking_score,engagement_velocity,heat_score',
            ])
            ->whereIn('id', $candidateIds)
            ->public()
            ->published()
            ->get()
            ->keyBy('id');

        $followedCreatorIds = DB::table('user_followers')
            ->where('follower_id', $userId)
            ->pluck('user_id')
            ->map(static fn (mixed $id): int => (int) $id)
            ->all();

        $followedLikedArtworkIds = DB::table('artwork_favourites')
            ->whereIn('user_id', $followedCreatorIds === [] ? [-1] : $followedCreatorIds)
            ->whereIn('artwork_id', array_keys($artworks->all()))
            ->pluck('artwork_id')
            ->map(static fn (mixed $id): int => (int) $id)
            ->all();

        $weights = (array) config('discovery.v2.weights', []);
        $trendingWeights = (array) config('discovery.v2.trending.period_weights', []);
        $explorationWeights = (array) config('discovery.v2.exploration', []);
        $negativePenaltyWeight = (float) config('discovery.v2.negative_signals.dislike_tag_penalty', 0.75);
        $rows = [];

        foreach ($candidateIds as $artworkId) {
            $artwork = $artworks->get($artworkId);
            if ($artwork === null) {
                continue;
            }

            $tagSlugs = $artwork->tags->pluck('slug')->map(static fn (mixed $slug): string => (string) $slug)->values()->all();
            $tagIds = $artwork->tags->pluck('id')->map(static fn (mixed $id): int => (int) $id)->values()->all();
            $creatorId = (int) ($artwork->user_id ?? 0);
            $stats = $artwork->stats;
            $publishedAt = $artwork->published_at ? CarbonImmutable::parse($artwork->published_at) : null;
            $ageHours = $publishedAt ? max(0.0, $publishedAt->diffInSeconds(now()) / 3600) : 0.0;
            $isSeenArtwork = in_array($artworkId, $seenArtworkIds, true);
            $isNewCreator = $artwork->user?->created_at?->greaterThanOrEqualTo(now()->subDays((int) config('discovery.v2.new_creator_days', 45))) ?? false;
            $hasUnseenTag = count(array_diff($tagSlugs, $this->recentDiscoveryTagSlugs($userId))) > 0;
            $isFreshUpload = $publishedAt !== null && $publishedAt->greaterThanOrEqualTo(now()->subHours((int) config('discovery.v2.fresh_upload_hours', 72)));
            $negativePenalty = 0.0;

            if (array_intersect($tagIds, $dislikedTagIds) !== [] || array_intersect($tagSlugs, $dislikedTagSlugs) !== []) {
                $negativePenalty += $negativePenaltyWeight;
            }

            $trendingBoost =
                ((float) ($artwork->trending_score_1h ?? 0.0) * (float) ($trendingWeights['1h'] ?? 1.0)) +
                ((float) ($artwork->trending_score_24h ?? 0.0) * (float) ($trendingWeights['24h'] ?? 0.7)) +
                ((float) ($artwork->trending_score_7d ?? 0.0) * (float) ($trendingWeights['7d'] ?? 0.45));

            $creatorBoost = min(1.0, ((int) ($artwork->user?->statistics?->followers_count ?? 0)) / 5000)
                + min(1.0, ((float) ($stats?->engagement_velocity ?? 0.0)) / 40)
                + min(1.0, ((float) ($stats?->heat_score ?? 0.0)) / 100);

            $socialBoost = 0.0;
            if (in_array($creatorId, $followedCreatorIds, true)) {
                $socialBoost += (float) ($weights['followed_creator'] ?? 0.85);
            }
            if (in_array($artworkId, $followedLikedArtworkIds, true)) {
                $socialBoost += (float) ($weights['followed_like'] ?? 0.55);
            }

            $explorationBoost = 0.0;
            if (! $isSeenArtwork && $isNewCreator) {
                $explorationBoost += (float) ($explorationWeights['creator_bonus'] ?? 0.6);
            }
            if (! $isSeenArtwork && $hasUnseenTag) {
                $explorationBoost += (float) ($explorationWeights['tag_bonus'] ?? 0.45);
            }
            if ($isFreshUpload) {
                $explorationBoost += (float) ($explorationWeights['freshness_bonus'] ?? 0.55);
            }

            $rows[] = [
                'id' => (int) $artwork->id,
                'creator_id' => $creatorId,
                'tag_slugs' => $tagSlugs,
                'session_boost' => $isSeenArtwork ? 0.15 : 0.35,
                'social_boost' => $socialBoost,
                'trending_boost' => $trendingBoost / 100,
                'exploration_boost' => $explorationBoost,
                'creator_boost' => $creatorBoost / 3,
                'negative_penalty' => $negativePenalty,
                'age_hours' => $ageHours,
            ];
        }

        return $rows;
    }

    /**
     * @param array<int, int> $creatorCounts
     * @param array<string, int> $recentTagCounts
     */
    private function repetitionPenalty(array $row, array $creatorCounts, array $recentTagCounts): float
    {
        $creatorPenalty = ((int) ($creatorCounts[(int) ($row['creator_id'] ?? 0)] ?? 0)) * 0.2;
        $tagPenalty = 0.0;

        foreach ((array) ($row['tag_slugs'] ?? []) as $tagSlug) {
            $tagPenalty += ((int) ($recentTagCounts[$tagSlug] ?? 0)) * 0.05;
        }

        return $creatorPenalty + $tagPenalty;
    }

    /**
     * @return array{hidden_artwork_ids: array<int, int>, disliked_tag_ids: array<int, int>, disliked_tag_slugs: array<int, string>}
     */
    private function negativeSignals(int $userId): array
    {
        $signals = UserNegativeSignal::query()
            ->with('tag:id,slug')
            ->where('user_id', $userId)
            ->get();

        return [
            'hidden_artwork_ids' => $signals->where('signal_type', 'hide_artwork')->pluck('artwork_id')->filter()->map(static fn (mixed $id): int => (int) $id)->values()->all(),
            'disliked_tag_ids' => $signals->where('signal_type', 'dislike_tag')->pluck('tag_id')->filter()->map(static fn (mixed $id): int => (int) $id)->values()->all(),
            'disliked_tag_slugs' => $signals->where('signal_type', 'dislike_tag')->map(static fn (UserNegativeSignal $signal): string => (string) ($signal->tag?->slug ?? ''))->filter()->values()->all(),
        ];
    }

    /**
     * @return array<string, int>
     */
    private function resolveLayerTargets(int $poolLimit): array
    {
        $ratios = (array) config('discovery.v2.layers', []);
        $normalized = [
            'personalized' => max(0.0, (float) ($ratios['personalized'] ?? 0.50)),
            'social' => max(0.0, (float) ($ratios['social'] ?? 0.20)),
            'trending' => max(0.0, (float) ($ratios['trending'] ?? 0.20)),
            'exploration' => max(0.0, (float) ($ratios['exploration'] ?? 0.10)),
        ];

        $sum = array_sum($normalized);
        if ($sum <= 0.0) {
            $sum = 1.0;
        }

        $targets = [];
        $assigned = 0;
        foreach ($normalized as $key => $ratio) {
            $targets[$key] = (int) floor(($ratio / $sum) * $poolLimit);
            $assigned += $targets[$key];
        }

        if ($assigned < $poolLimit) {
            $targets['personalized'] += ($poolLimit - $assigned);
        }

        return $targets;
    }

    /**
     * @param array<int, string> $tagSlugs
     * @return array<int, int>
     */
    private function searchByTags(int $userId, array $tagSlugs, int $poolSize): array
    {
        if ($tagSlugs === []) {
            return [];
        }

        $filterParts = [
            'is_public = true',
            'is_approved = true',
            'author_id != ' . $userId,
        ];

        $tagFilter = implode(' OR ', array_map(
            static fn (string $tagSlug): string => 'tags = "' . addslashes($tagSlug) . '"',
            $tagSlugs
        ));
        $filterParts[] = '(' . $tagFilter . ')';

        try {
            $results = Artwork::search('')
                ->options([
                    'filter' => implode(' AND ', $filterParts),
                    'sort' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'created_at:desc'],
                ])
                ->paginate(min($poolSize, max(1, (int) config('discovery.v2.candidate_pool_max', 300))), 'page', 1);

            return $this->searchResultCollection($results)->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
        } catch (\Throwable $e) {
            Log::warning('RecommendationServiceV2 searchByTags fallback', ['error' => $e->getMessage()]);

            return DB::table('artworks')
                ->join('artwork_tag', 'artwork_tag.artwork_id', '=', 'artworks.id')
                ->join('tags', 'tags.id', '=', 'artwork_tag.tag_id')
                ->whereIn('tags.slug', $tagSlugs)
                ->where('artworks.user_id', '!=', $userId)
                ->whereNull('artworks.deleted_at')
                ->where('artworks.is_public', true)
                ->where('artworks.is_approved', true)
                ->orderByDesc('artworks.trending_score_24h')
                ->orderByDesc('artworks.trending_score_7d')
                ->limit($poolSize)
                ->pluck('artworks.id')
                ->map(static fn (mixed $id): int => (int) $id)
                ->all();
        }
    }

    /**
     * @param array<int, string> $knownTagSlugs
     * @return array<int, int>
     */
    private function searchOutsideKnownTags(int $userId, array $knownTagSlugs, int $poolSize): array
    {
        try {
            $results = Artwork::search('')
                ->options([
                    'filter' => 'is_public = true AND is_approved = true AND author_id != ' . $userId,
                    'sort' => ['created_at:desc', 'trending_score_24h:desc'],
                ])
                ->paginate(min($poolSize, max(1, (int) config('discovery.v2.candidate_pool_max', 300))), 'page', 1);

            return $this->searchResultCollection($results)
                ->filter(function (Artwork $artwork) use ($knownTagSlugs): bool {
                    $artworkTags = collect($artwork->searchableTags ?? $artwork->tags?->pluck('slug')->all() ?? [])->map(static fn (mixed $slug): string => (string) $slug)->all();

                    return count(array_intersect($artworkTags, $knownTagSlugs)) === 0;
                })
                ->pluck('id')
                ->map(static fn (mixed $id): int => (int) $id)
                ->values()
                ->all();
        } catch (\Throwable $e) {
            Log::warning('RecommendationServiceV2 searchOutsideKnownTags fallback', ['error' => $e->getMessage()]);

            return [];
        }
    }

    /**
     * @param array<string, float> $scores
     * @return array<int, string>
     */
    private function topKeysByPrefix(array $scores, string $prefix, int $limit): array
    {
        $filtered = [];
        foreach ($scores as $key => $score) {
            if (! str_starts_with((string) $key, $prefix)) {
                continue;
            }

            $filtered[(string) $key] = (float) $score;
        }

        arsort($filtered);

        return array_values(array_map(
            static fn (string $key): string => str_replace($prefix, '', $key),
            array_slice(array_keys($filtered), 0, $limit)
        ));
    }

    /**
     * @return array<int, int>
     */
    private function recentDiscoveryArtworkIds(int $userId): array
    {
        return DB::table('user_discovery_events')
            ->where('user_id', $userId)
            ->orderByDesc('occurred_at')
            ->limit((int) config('discovery.v2.repetition_window', 24))
            ->pluck('artwork_id')
            ->map(static fn (mixed $id): int => (int) $id)
            ->values()
            ->all();
    }

    /**
     * @return array<int, string>
     */
    private function recentDiscoveryTagSlugs(int $userId): array
    {
        return DB::table('user_discovery_events')
            ->join('artwork_tag', 'artwork_tag.artwork_id', '=', 'user_discovery_events.artwork_id')
            ->join('tags', 'tags.id', '=', 'artwork_tag.tag_id')
            ->where('user_discovery_events.user_id', $userId)
            ->orderByDesc('user_discovery_events.occurred_at')
            ->limit(max(10, (int) config('discovery.v2.repetition_window', 24) * 2))
            ->pluck('tags.slug')
            ->map(static fn (mixed $slug): string => (string) $slug)
            ->unique()
            ->values()
            ->all();
    }

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

        if ($results instanceof ScoutBuilder) {
            return collect();
        }

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

        return collect();
    }

    /**
     * @param array<int, string> $layerSources
     */
    private function resolveSource(array $layerSources): string
    {
        if (in_array('personalized', $layerSources, true)) {
            return 'personalized';
        }
        if (in_array('vector', $layerSources, true)) {
            return 'vector';
        }
        if (in_array('social', $layerSources, true)) {
            return 'social';
        }
        if (in_array('trending', $layerSources, true)) {
            return 'trending';
        }

        return 'exploration';
    }

    private function resolveAlgoVersion(?string $algoVersion = null): string
    {
        if ($algoVersion !== null && $algoVersion !== '') {
            return $algoVersion;
        }

        return (string) config('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
    }

    /**
     * @param array<int, array<string, mixed>> $items
     */
    private function buildFeedPageResponse(
        array $items,
        int $offset,
        int $limit,
        string $algoVersion,
        string $cacheStatus,
        ?string $generatedAt
    ): array {
        $safeOffset = max(0, $offset);
        $pageItems = array_slice($items, $safeOffset, $limit);
        $spilloverItems = array_slice($items, $safeOffset + $limit, 12);
        $ids = array_values(array_unique(array_merge(
            array_map(static fn (array $item): int => (int) ($item['artwork_id'] ?? 0), $pageItems),
            array_map(static fn (array $item): int => (int) ($item['artwork_id'] ?? 0), $spilloverItems),
        )));

        /** @var Collection<int, Artwork> $artworks */
        $artworks = Artwork::query()
            ->with([
                '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,parent_id,sort_order',
                'categories.contentType:id,name,slug',
                'tags:id,name,slug',
            ])
            ->whereIn('id', $ids)
            ->public()
            ->published()
            ->get()
            ->keyBy('id');

        $embeddedArtworkIds = ArtworkEmbedding::query()
            ->whereIn('artwork_id', $ids)
            ->distinct()
            ->pluck('artwork_id')
            ->map(static fn ($artworkId): int => (int) $artworkId)
            ->all();

        $responseItems = [];
        foreach ($pageItems as $item) {
            $artworkId = (int) ($item['artwork_id'] ?? 0);
            $artwork = $artworks->get($artworkId);
            if ($artwork === null) {
                continue;
            }

            $primaryCategory = $artwork->categories->sortBy('sort_order')->first();
            $primaryTag = $artwork->tags->sortBy('name')->first();
            $source = (string) ($item['source'] ?? 'personalized');
            $hasLocalEmbedding = in_array($artwork->id, $embeddedArtworkIds, true);
            $vectorIndexedAt = $artwork->last_vector_indexed_at?->toIso8601String();
            $rankingSignals = (array) ($item['ranking_signals'] ?? []);
            $rankingSignals['local_embedding_present'] = $hasLocalEmbedding;
            $rankingSignals['vector_indexed_at'] = $vectorIndexedAt;
            $publisher = $this->mapPublisherPayload($artwork);
            $isGroupPublisher = ($publisher['type'] ?? null) === 'group';
            $responseItems[] = [
                'id' => $artwork->id,
                'slug' => $artwork->slug,
                'title' => $artwork->title,
                'thumbnail_url' => $artwork->thumb_url,
                'thumbnail_srcset' => $artwork->thumb_srcset,
                'author' => $isGroupPublisher ? ($publisher['name'] ?? 'Skinbase Group') : $artwork->user?->name,
                'username' => $isGroupPublisher ? null : $artwork->user?->username,
                'author_id' => $artwork->user?->id,
                'avatar_url' => $isGroupPublisher
                    ? ($publisher['avatar_url'] ?? null)
                    : AvatarUrl::forUser(
                        (int) ($artwork->user?->id ?? 0),
                        $artwork->user?->profile?->avatar_hash,
                        64
                    ),
                'published_as_type' => $artwork->publishedAsType(),
                'publisher' => $publisher,
                'content_type_name' => $primaryCategory?->contentType?->name ?? '',
                'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
                'category_name' => $primaryCategory?->name ?? '',
                'category_slug' => $primaryCategory?->slug ?? '',
                'width' => $artwork->width,
                'height' => $artwork->height,
                'published_at' => $artwork->published_at?->toIso8601String(),
                'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
                'primary_tag' => $primaryTag !== null ? [
                    'id' => (int) $primaryTag->id,
                    'name' => (string) $primaryTag->name,
                    'slug' => (string) $primaryTag->slug,
                ] : null,
                'tags' => $artwork->tags
                    ->sortBy('name')
                    ->take(3)
                    ->map(static fn ($tag): array => [
                        'id' => (int) $tag->id,
                        'name' => (string) $tag->name,
                        'slug' => (string) $tag->slug,
                    ])
                    ->values()
                    ->all(),
                'score' => (float) ($item['score'] ?? 0.0),
                'source' => $source,
                'reason' => $this->recommendationReason($source, (array) ($item['layer_sources'] ?? []), (string) ($primaryCategory?->name ?? '')),
                'vector_influenced' => (bool) ($item['vector_influenced'] ?? false),
                'vector_similarity_score' => (float) ($item['vector_similarity_score'] ?? 0.0),
                'has_local_embedding' => $hasLocalEmbedding,
                'vector_indexed_at' => $vectorIndexedAt,
                'ranking_signals' => $rankingSignals,
                'algo_version' => $algoVersion,
            ];
        }

        $nextOffset = $safeOffset + $limit;
        $discoverySections = $this->buildDiscoverySections($artworks, $responseItems, $spilloverItems);

        return [
            'data' => $responseItems,
            'sections' => $discoverySections,
            'meta' => [
                'algo_version' => $algoVersion,
                'cursor' => $this->encodeOffsetToCursor($safeOffset),
                'next_cursor' => $nextOffset < count($items) ? $this->encodeOffsetToCursor($nextOffset) : null,
                'limit' => $limit,
                'cache_status' => $cacheStatus,
                'generated_at' => $generatedAt,
                'total_candidates' => count($items),
                'vector_influenced_count' => count(array_filter($responseItems, static fn (array $item): bool => (bool) ($item['vector_influenced'] ?? false))),
                'local_embedding_count' => count(array_filter($responseItems, static fn (array $item): bool => (bool) ($item['has_local_embedding'] ?? false))),
                'vector_indexed_count' => count(array_filter($responseItems, static fn (array $item): bool => (string) ($item['vector_indexed_at'] ?? '') !== '')),
                'engine' => 'v2',
            ],
        ];
    }

    /**
     * @param Collection<int, Artwork> $artworks
     * @param array<int, array<string, mixed>> $responseItems
     * @param array<int, array<string, mixed>> $spilloverItems
     * @return array<int, array<string, mixed>>
     */
    private function buildDiscoverySections(Collection $artworks, array $responseItems, array $spilloverItems): array
    {
        if (! $this->v3Enabled() || ! $this->vectors->isConfigured() || $responseItems === []) {
            return [];
        }

        $sectionConfig = (array) config('discovery.v3.sections', []);
        $similarStyleLimit = max(1, (int) ($sectionConfig['similar_style_limit'] ?? 3));
        $youMayAlsoLikeLimit = max(1, (int) ($sectionConfig['you_may_also_like_limit'] ?? 6));
        $visuallyRelatedLimit = max(1, (int) ($sectionConfig['visually_related_limit'] ?? 6));

        $anchorId = (int) ($responseItems[0]['id'] ?? 0);
        if ($anchorId <= 0) {
            return [];
        }

        /** @var Artwork|null $anchorArtwork */
        $anchorArtwork = $artworks->get($anchorId);
        if ($anchorArtwork === null) {
            return [];
        }

        $sections = [];
        $pageIds = array_values(array_unique(array_map(static fn (array $item): int => (int) ($item['id'] ?? 0), $responseItems)));

        $similarStyleItems = $this->mapSpilloverSectionItems($spilloverItems, $artworks, [], $similarStyleLimit);
        if ($similarStyleItems !== []) {
            $sections[] = [
                'key' => 'similar_style',
                'title' => 'Similar Style',
                'source' => 'hybrid_feed',
                'anchor_artwork_id' => $anchorId,
                'items' => $similarStyleItems,
            ];
        }

        $usedSpilloverIds = array_values(array_unique(array_map(static fn (array $item): int => (int) ($item['id'] ?? 0), $similarStyleItems)));
        $youMayAlsoLikeItems = $this->mapSpilloverSectionItems($spilloverItems, $artworks, $usedSpilloverIds, $youMayAlsoLikeLimit);
        if ($youMayAlsoLikeItems === []) {
            $youMayAlsoLikeItems = $this->mapResponseSectionItems($responseItems, [$anchorId], $youMayAlsoLikeLimit);
        }
        if ($youMayAlsoLikeItems !== []) {
            $sections[] = [
                'key' => 'you_may_also_like',
                'title' => 'You may also like',
                'source' => 'hybrid_feed',
                'anchor_artwork_id' => $anchorId,
                'items' => $youMayAlsoLikeItems,
            ];
        }

        try {
            $items = $this->vectors->similarToArtwork($anchorArtwork, $visuallyRelatedLimit);
        } catch (\Throwable $e) {
            Log::warning('RecommendationServiceV2 discovery sections failed', [
                'anchor_artwork_id' => $anchorId,
                'error' => $e->getMessage(),
            ]);

            return $sections;
        }

        $items = array_values(array_filter($items, static fn (array $item): bool => ! in_array((int) ($item['id'] ?? 0), $pageIds, true)));
        if ($items !== []) {
            $sections[] = [
                'key' => 'visually_related',
                'title' => 'Visually related',
                'source' => 'vector_gateway',
                'anchor_artwork_id' => $anchorId,
                'items' => array_slice($items, 0, $visuallyRelatedLimit),
            ];
        }

        return $sections;
    }

    /**
     * @param array<int, array<string, mixed>> $spilloverItems
     * @param array<int, int> $excludeIds
     * @return array<int, array<string, mixed>>
     */
    private function mapSpilloverSectionItems(array $spilloverItems, Collection $artworks, array $excludeIds, int $limit): array
    {
        if ($spilloverItems === [] || $limit <= 0) {
            return [];
        }

        $mapped = [];
        foreach ($spilloverItems as $item) {
            $artworkId = (int) ($item['artwork_id'] ?? 0);
            if ($artworkId <= 0 || in_array($artworkId, $excludeIds, true)) {
                continue;
            }

            /** @var Artwork|null $artwork */
            $artwork = $artworks->get($artworkId);
            if ($artwork === null) {
                continue;
            }

            $mapped[] = [
                'id' => (int) $artwork->id,
                'title' => (string) $artwork->title,
                'slug' => (string) $artwork->slug,
                'thumb' => $artwork->thumbUrl('md'),
                'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
                'author' => $artwork->user?->name ?? 'Artist',
                'author_avatar' => $artwork->user?->profile?->avatar_url,
                'author_id' => $artwork->user_id,
                'score' => round((float) ($item['score'] ?? 0.0), 5),
                'source' => (string) ($item['source'] ?? 'hybrid_feed'),
                'reason' => $this->recommendationReason(
                    (string) ($item['source'] ?? 'personalized'),
                    (array) ($item['layer_sources'] ?? []),
                    (string) ($artwork->categories->sortBy('sort_order')->first()?->name ?? '')
                ),
            ];

            if (count($mapped) >= $limit) {
                break;
            }
        }

        return $mapped;
    }

    /**
     * @param array<int, array<string, mixed>> $responseItems
     * @param array<int, int> $excludeIds
     * @return array<int, array<string, mixed>>
     */
    private function mapResponseSectionItems(array $responseItems, array $excludeIds, int $limit): array
    {
        if ($responseItems === [] || $limit <= 0) {
            return [];
        }

        $mapped = [];
        foreach ($responseItems as $item) {
            $artworkId = (int) ($item['id'] ?? 0);
            if ($artworkId <= 0 || in_array($artworkId, $excludeIds, true)) {
                continue;
            }

            $mapped[] = [
                'id' => $artworkId,
                'title' => (string) ($item['title'] ?? ''),
                'slug' => (string) ($item['slug'] ?? ''),
                'thumb' => $item['thumbnail_url'] ?? null,
                'url' => (string) ($item['url'] ?? ''),
                'author' => (string) ($item['author'] ?? 'Artist'),
                'author_avatar' => $item['avatar_url'] ?? null,
                'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null,
                'score' => round((float) ($item['score'] ?? 0.0), 5),
                'source' => (string) ($item['source'] ?? 'hybrid_feed'),
                'reason' => (string) ($item['reason'] ?? ''),
            ];

            if (count($mapped) >= $limit) {
                break;
            }
        }

        return $mapped;
    }

    /**
     * @param array<int, string> $layerSources
     */
    private function recommendationReason(string $source, array $layerSources, string $categoryName): string
    {
        if (in_array('vector', $layerSources, true)) {
            return 'Visually similar to art you engaged with';
        }
        if (in_array('social', $layerSources, true)) {
            return 'Popular with creators you follow';
        }
        if (in_array('exploration', $layerSources, true)) {
            return 'Exploring something fresh for you';
        }
        if (in_array('trending', $layerSources, true)) {
            return $categoryName !== '' ? 'Trending in ' . $categoryName . ' right now' : 'Trending across Skinbase right now';
        }

        return $categoryName !== '' ? 'Matched to your current interest in ' . $categoryName : 'Matched to your current interests';
    }

    private function decodeCursorToOffset(?string $cursor): int
    {
        if ($cursor === null || $cursor === '') {
            return 0;
        }

        $decoded = base64_decode(strtr($cursor, '-_', '+/'), true);
        if ($decoded === false) {
            return 0;
        }

        $json = json_decode($decoded, true);
        if (! is_array($json)) {
            return 0;
        }

        return max(0, (int) Arr::get($json, 'offset', 0));
    }

    private function encodeOffsetToCursor(int $offset): string
    {
        $payload = json_encode(['offset' => max(0, $offset)]);

        return is_string($payload)
            ? rtrim(strtr(base64_encode($payload), '+/', '-_'), '=')
            : '';
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function extractCacheItems(?UserRecommendationCache $cache): array
    {
        if ($cache === null) {
            return [];
        }

        $raw = (array) ($cache->recommendations_json ?? []);
        $items = $raw['items'] ?? null;
        if (! is_array($items)) {
            return [];
        }

        $typed = [];
        foreach ($items as $item) {
            if (! is_array($item)) {
                continue;
            }

            $artworkId = (int) ($item['artwork_id'] ?? 0);
            if ($artworkId <= 0) {
                continue;
            }

            $typed[] = [
                'artwork_id' => $artworkId,
                'score' => (float) ($item['score'] ?? 0.0),
                'source' => (string) ($item['source'] ?? 'personalized'),
                'layer_sources' => array_values(array_unique(array_map('strval', (array) ($item['layer_sources'] ?? [])))),
                'vector_influenced' => (bool) ($item['vector_influenced'] ?? false),
                'vector_similarity_score' => (float) ($item['vector_similarity_score'] ?? 0.0),
                'ranking_signals' => (array) ($item['ranking_signals'] ?? []),
            ];
        }

        return $typed;
    }

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

        $group = $artwork->group;
        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 v3Enabled(): bool
    {
        return (bool) config('discovery.v3.enabled', false);
    }

    private function currentCacheVersion(): string
    {
        if ($this->v3Enabled()) {
            return (string) config('discovery.v3.cache_version', 'cache-v3');
        }

        return (string) config('discovery.v2.cache_version', 'cache-v2');
    }

    private function currentCacheTtlMinutes(): int
    {
        if ($this->v3Enabled()) {
            return max(1, (int) config('discovery.v3.cache_ttl_minutes', 5));
        }

        return max(1, (int) config('discovery.v2.cache_ttl_minutes', 15));
    }
}
