<?php

declare(strict_types=1);

namespace App\Services\Recommendations;

use App\Jobs\RegenerateUserRecommendationCacheJob;
use App\Models\Artwork;
use App\Models\UserInterestProfile;
use App\Models\UserRecommendationCache;
use App\Support\AvatarUrl;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

final class PersonalizedFeedService
{
    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, $userId);
        $weightSet = $this->resolveRankingWeights($resolvedAlgoVersion);
        $offset = $this->decodeCursorToOffset($cursor);

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

        $cacheItems = $this->extractCacheItems($cache);
        $isFresh = $cache !== null && $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->buildColdStartRecommendations($resolvedAlgoVersion, 240, 'fallback');
            $cacheStatus = $cacheStatus . '-fallback';
        }

        return $this->buildFeedPageResponse(
            items: $items,
            offset: $offset,
            limit: $safeLimit,
            algoVersion: $resolvedAlgoVersion,
            weightVersion: (string) $weightSet['version'],
            cacheStatus: $cacheStatus,
            generatedAt: $cache?->generated_at?->toIso8601String()
        );
    }

    public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void
    {
        $resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId);
        $cacheVersion = (string) config('discovery.cache_version', 'cache-v1');
        $ttlMinutes = max(1, (int) config('discovery.cache_ttl_minutes', 60));

        $items = $this->buildRecommendations($userId, $resolvedAlgoVersion, 240);
        $generatedAt = now();
        $expiresAt = now()->addMinutes($ttlMinutes);

        UserRecommendationCache::query()->updateOrCreate(
            [
                'user_id' => $userId,
                'algo_version' => $resolvedAlgoVersion,
            ],
            [
                'cache_version' => $cacheVersion,
                'recommendations_json' => [
                    'items' => $items,
                    'algo_version' => $resolvedAlgoVersion,
                    'weight_version' => (string) $this->resolveRankingWeights($resolvedAlgoVersion)['version'],
                    'generated_at' => $generatedAt->toIso8601String(),
                ],
                'generated_at' => $generatedAt,
                'expires_at' => $expiresAt,
            ]
        );
    }

    /**
     * @return array<int, array{artwork_id:int,score:float,source:string}>
     */
    public function buildRecommendations(int $userId, string $algoVersion, int $maxItems = 240): array
    {
        $profileVersion = (string) config('discovery.profile_version', 'profile-v1');

        $profile = UserInterestProfile::query()
            ->where('user_id', $userId)
            ->where('profile_version', $profileVersion)
            ->where('algo_version', $algoVersion)
            ->first();

        $normalized = $profile !== null ? (array) ($profile->normalized_scores_json ?? []) : [];
        $personalized = $this->buildProfileBasedRecommendations($normalized, $maxItems, $algoVersion);

        if ($personalized === []) {
            return $this->buildColdStartRecommendations($algoVersion, $maxItems, 'cold_start');
        }

        $fallback = $this->buildColdStartRecommendations($algoVersion, $maxItems, 'fallback');

        $combined = [];
        foreach (array_merge($personalized, $fallback) as $item) {
            $artworkId = (int) ($item['artwork_id'] ?? 0);
            if ($artworkId <= 0) {
                continue;
            }

            if (! isset($combined[$artworkId])) {
                $combined[$artworkId] = [
                    'artwork_id' => $artworkId,
                    'score' => (float) ($item['score'] ?? 0.0),
                    'source' => (string) ($item['source'] ?? 'mixed'),
                ];
                continue;
            }

            if ((float) $item['score'] > (float) $combined[$artworkId]['score']) {
                $combined[$artworkId]['score'] = (float) $item['score'];
                $combined[$artworkId]['source'] = (string) ($item['source'] ?? $combined[$artworkId]['source']);
            }
        }

        $candidates = array_values($combined);
        usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);

        return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
    }

    /**
     * @param array<string, mixed> $normalizedScores
     * @return array<int, array{artwork_id:int,score:float,source:string}>
     */
    private function buildProfileBasedRecommendations(array $normalizedScores, int $maxItems, string $algoVersion): array
    {
        $weightSet = $this->resolveRankingWeights($algoVersion);
        $w1 = (float) $weightSet['w1'];
        $w2 = (float) $weightSet['w2'];
        $w3 = (float) $weightSet['w3'];
        $w4 = (float) $weightSet['w4'];

        $categoryAffinities = [];
        foreach ($normalizedScores as $key => $score) {
            if (! is_numeric($score)) {
                continue;
            }

            if (! str_starts_with((string) $key, 'category:')) {
                continue;
            }

            $categoryId = (int) str_replace('category:', '', (string) $key);
            if ($categoryId <= 0) {
                continue;
            }

            $categoryAffinities[$categoryId] = (float) $score;
        }

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

        $rows = 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', array_keys($categoryAffinities))
            ->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.published_at')
            ->limit(max(200, $maxItems * 8))
            ->get([
                'artworks.id',
                'artworks.published_at',
                'artwork_category.category_id',
                DB::raw('COALESCE(artwork_stats.views, 0) as views'),
            ]);

        $scored = [];
        foreach ($rows as $row) {
            $artworkId = (int) $row->id;
            $categoryId = (int) $row->category_id;
            $affinity = (float) ($categoryAffinities[$categoryId] ?? 0.0);
            if ($affinity <= 0.0) {
                continue;
            }

            $publishedAt = CarbonImmutable::parse((string) $row->published_at);
            $ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
            $recency = exp(-$ageDays / 30.0);
            $popularity = log(1 + max(0, (int) $row->views)) / 10.0;
            $novelty = max(0.0, 1.0 - min(1.0, $popularity));

            // Phase 8B blend with versioned weights (manual tuning, no auto-tuning yet).
            $score = ($w1 * $affinity) + ($w2 * $recency) + ($w3 * $popularity) + ($w4 * $novelty);

            if (! isset($scored[$artworkId]) || $score > $scored[$artworkId]['score']) {
                $scored[$artworkId] = [
                    'artwork_id' => $artworkId,
                    'score' => $score,
                    'source' => 'personalized',
                ];
            }
        }

        $candidates = array_values($scored);
        usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);

        return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
    }

    /**
     * @return array<int, array{artwork_id:int,score:float,source:string}>
     */
    private function buildColdStartRecommendations(string $algoVersion, int $maxItems, string $sourceLabel = 'cold_start'): array
    {
        $popularIds = DB::table('artworks')
            ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
            ->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.views')
            ->orderByDesc('artwork_stats.downloads')
            ->orderByDesc('artworks.published_at')
            ->limit(max(40, $maxItems))
            ->pluck('artworks.id')
            ->map(static fn (mixed $id): int => (int) $id)
            ->all();

        $seedIds = array_slice($popularIds, 0, 12);

        $similarIds = [];
        if ($seedIds !== []) {
            $similarIds = DB::table('artwork_similarities')
                ->where('algo_version', $algoVersion)
                ->whereIn('artwork_id', $seedIds)
                ->orderBy('rank')
                ->orderByDesc('score')
                ->limit(max(80, $maxItems * 2))
                ->pluck('similar_artwork_id')
                ->map(static fn (mixed $id): int => (int) $id)
                ->all();
        }

        $candidates = [];
        foreach ($popularIds as $index => $artworkId) {
            $candidates[] = [
                'artwork_id' => $artworkId,
                'score' => max(0.0, 1.0 - ($index * 0.003)),
                'source' => $sourceLabel,
            ];
        }

        foreach ($similarIds as $index => $artworkId) {
            $candidates[] = [
                'artwork_id' => $artworkId,
                'score' => max(0.0, 0.75 - ($index * 0.002)),
                'source' => $sourceLabel,
            ];
        }

        usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);

        return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
    }

    /**
     * @param array<int, array{artwork_id:int,score:float,source:string}> $candidates
     * @return array<int, array{artwork_id:int,score:float,source:string}>
     */
    private function applyDiversityGuard(array $candidates, string $algoVersion, int $maxItems): array
    {
        if ($candidates === []) {
            return [];
        }

        $uniqueCandidates = [];
        foreach ($candidates as $candidate) {
            $artworkId = (int) ($candidate['artwork_id'] ?? 0);
            if ($artworkId <= 0 || isset($uniqueCandidates[$artworkId])) {
                continue;
            }

            $uniqueCandidates[$artworkId] = [
                'artwork_id' => $artworkId,
                'score' => (float) ($candidate['score'] ?? 0.0),
                'source' => (string) ($candidate['source'] ?? 'mixed'),
            ];
        }

        $flattened = array_values($uniqueCandidates);
        $candidateIds = array_map(static fn (array $item): int => (int) $item['artwork_id'], $flattened);

        $nearDuplicatePairs = DB::table('artwork_similarities')
            ->where('algo_version', $algoVersion)
            ->where('score', '>=', 0.97)
            ->whereIn('artwork_id', $candidateIds)
            ->whereIn('similar_artwork_id', $candidateIds)
            ->get(['artwork_id', 'similar_artwork_id']);

        $adjacency = [];
        foreach ($nearDuplicatePairs as $pair) {
            $left = (int) $pair->artwork_id;
            $right = (int) $pair->similar_artwork_id;

            if ($left === $right) {
                continue;
            }

            $adjacency[$left][$right] = true;
            $adjacency[$right][$left] = true;
        }

        $selected = [];
        $selectedSet = [];

        foreach ($flattened as $candidate) {
            $id = (int) $candidate['artwork_id'];

            $isNearDuplicate = false;
            foreach ($selectedSet as $selectedId => $value) {
                if (($adjacency[$id][$selectedId] ?? false) || ($adjacency[$selectedId][$id] ?? false)) {
                    $isNearDuplicate = true;
                    break;
                }
            }

            if ($isNearDuplicate) {
                continue;
            }

            $selected[] = [
                'artwork_id' => $id,
                'score' => round((float) $candidate['score'], 6),
                'source' => (string) $candidate['source'],
            ];
            $selectedSet[$id] = true;

            if (count($selected) >= $maxItems) {
                break;
            }
        }

        return $selected;
    }

    /**
     * @param array<int, array{artwork_id:int,score:float,source:string}> $items
     */
    private function buildFeedPageResponse(
        array $items,
        int $offset,
        int $limit,
        string $algoVersion,
        string $weightVersion,
        string $cacheStatus,
        ?string $generatedAt
    ): array {
        $safeOffset = max(0, $offset);
        $pageItems = array_slice($items, $safeOffset, $limit);

        $ids = array_values(array_unique(array_map(
            static fn (array $item): int => (int) ($item['artwork_id'] ?? 0),
            $pageItems
        )));

        /** @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');

        $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'] ?? 'mixed');
            $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->buildRecommendationReason($artwork, $source),
                'algo_version' => $algoVersion,
            ];
        }

        $nextOffset = $safeOffset + $limit;
        $hasNext = $nextOffset < count($items);

        return [
            'data' => $responseItems,
            'meta' => [
                'algo_version' => $algoVersion,
                'weight_version' => $weightVersion,
                'cursor' => $this->encodeOffsetToCursor($safeOffset),
                'next_cursor' => $hasNext ? $this->encodeOffsetToCursor($nextOffset) : null,
                'limit' => $limit,
                'cache_status' => $cacheStatus,
                'generated_at' => $generatedAt,
                'total_candidates' => count($items),
                'engine' => 'v1',
            ],
        ];
    }

    private function buildRecommendationReason(Artwork $artwork, string $source): string
    {
        $primaryCategory = $artwork->categories->sortBy('sort_order')->first();
        $categoryName = trim((string) ($primaryCategory?->name ?? ''));

        return match ($source) {
            'personalized' => $categoryName !== ''
                ? 'Matched to your interest in ' . $categoryName
                : 'Matched to your recent interests',
            'cold_start' => $categoryName !== ''
                ? 'Popular in ' . $categoryName . ' right now'
                : 'Popular with the community right now',
            'fallback' => $categoryName !== ''
                ? 'Trending in ' . $categoryName
                : 'Trending across Skinbase',
            default => 'Picked for you',
        };
    }

    /**
     * @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 resolveAlgoVersion(?string $algoVersion = null, ?int $userId = null): string
    {
        if ($algoVersion !== null && $algoVersion !== '') {
            return $algoVersion;
        }

        $forcedAlgoVersion = trim((string) config('discovery.rollout.force_algo_version', ''));
        if ($forcedAlgoVersion !== '') {
            return $forcedAlgoVersion;
        }

        $defaultAlgoVersion = (string) config('discovery.algo_version', 'clip-cosine-v1');
        $rolloutEnabled = (bool) config('discovery.rollout.enabled', false);
        if (! $rolloutEnabled || $userId === null || $userId <= 0) {
            return $defaultAlgoVersion;
        }

        $baselineAlgoVersion = (string) config('discovery.rollout.baseline_algo_version', $defaultAlgoVersion);
        $candidateAlgoVersion = (string) config('discovery.rollout.candidate_algo_version', $defaultAlgoVersion);
        if ($candidateAlgoVersion === '' || $candidateAlgoVersion === $baselineAlgoVersion) {
            return $baselineAlgoVersion;
        }

        $activeGate = (string) config('discovery.rollout.active_gate', 'g10');
        $gates = (array) config('discovery.rollout.gates', []);
        $gate = (array) ($gates[$activeGate] ?? []);
        $rolloutPercentage = (int) ($gate['percentage'] ?? 0);
        $rolloutPercentage = max(0, min(100, $rolloutPercentage));

        if ($rolloutPercentage <= 0) {
            return $baselineAlgoVersion;
        }

        if ($rolloutPercentage >= 100) {
            return $candidateAlgoVersion;
        }

        $bucket = abs((int) crc32((string) $userId)) % 100;

        return $bucket < $rolloutPercentage
            ? $candidateAlgoVersion
            : $baselineAlgoVersion;
    }

    /**
     * @return array{version:string,w1:float,w2:float,w3:float,w4:float}
     */
    public function resolveRankingWeights(string $algoVersion): array
    {
        $defaults = (array) config('discovery.ranking.default_weights', []);
        $byAlgo = (array) config('discovery.ranking.algo_weight_sets', []);
        $override = (array) ($byAlgo[$algoVersion] ?? []);

        $resolved = array_merge($defaults, $override);

        $weights = [
            'version' => (string) ($resolved['version'] ?? 'rank-w-v1'),
            'w1' => max(0.0, (float) ($resolved['w1'] ?? 0.65)),
            'w2' => max(0.0, (float) ($resolved['w2'] ?? 0.20)),
            'w3' => max(0.0, (float) ($resolved['w3'] ?? 0.10)),
            'w4' => max(0.0, (float) ($resolved['w4'] ?? 0.05)),
        ];

        $sum = $weights['w1'] + $weights['w2'] + $weights['w3'] + $weights['w4'];
        if ($sum > 0.0) {
            $weights['w1'] /= $sum;
            $weights['w2'] /= $sum;
            $weights['w3'] /= $sum;
            $weights['w4'] /= $sum;
        }

        return $weights;
    }

    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)]);
        if (! is_string($payload)) {
            return '';
        }

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

    /**
     * @return array<int, array{artwork_id:int,score:float,source:string}>
     */
    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'] ?? 'mixed'),
            ];
        }

        return $typed;
    }
}
