<?php

declare(strict_types=1);

namespace App\Services\Recommendation;

use App\DTOs\UserRecoProfileDTO;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Arr;

/**
 * RecommendationService – Phase 1 "For You" feed pipeline.
 *
 * Pipeline:
 *   1. Load UserRecoProfileDTO (cached, built by UserPreferenceBuilder)
 *   2. Generate 200 Meilisearch candidates filtered by user's top tags
 *      – Exclude user's own artworks
 *      – Exclude already-favourited artworks (PHP post-filter)
 *   3. PHP reranking:
 *        score = (tag_overlap  × w_tag_overlap)
 *              + (creator_affinity × w_creator_affinity)
 *              + (popularity_boost × w_popularity)
 *              + (freshness_boost  × w_freshness)
 *   4. Diversity controls:
 *        – max N results per creator per page  (config recommendations.max_per_creator)
 *        – penalise repeated tag-pattern clusters
 *   5. Cursor-based pagination, top 40 per page
 *   6. Cold-start fallback (trending + fresh blend) when user has no signals
 *
 * Caching:
 *   – key: for_you:{user_id}:{cursor_hash}   TTL: config recommendations.ttl.for_you_feed
 */
final class RecommendationService
{
    private const DEFAULT_PAGE_SIZE   = 40;
    private const COLD_START_LIMIT    = 40;

    public function __construct(
        private readonly UserPreferenceBuilder $prefBuilder
    ) {}

    // ─── Public API ───────────────────────────────────────────────────────────

    /**
     * Return a fully ranked, paginated "For You" feed for the given user.
     *
     * @return array{
     *   data: array<int, array<string, mixed>>,
     *   meta: array<string, mixed>
     * }
     */
    public function forYouFeed(User $user, int $limit = self::DEFAULT_PAGE_SIZE, ?string $cursor = null): array
    {
        $safeLimit  = max(1, min(50, $limit));
        $cursorHash = $cursor ? md5($cursor) : '0';
        $cacheKey   = "for_you:{$user->id}:{$cursorHash}";
        $ttl        = (int) config('recommendations.ttl.for_you_feed', 5 * 60);

        return Cache::remember($cacheKey, $ttl, function () use ($user, $safeLimit, $cursor) {
            return $this->build($user, $safeLimit, $cursor);
        });
    }

    /**
     * Convenience method for the homepage preview (first N items, no cursor).
     *
     * @return array<int, array<string, mixed>>
     */
    public function forYouPreview(User $user, int $limit = 12): array
    {
        $result = $this->forYouFeed($user, $limit);
        return $result['data'] ?? [];
    }

    // ─── Build pipeline ───────────────────────────────────────────────────────

    /**
     * @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
     */
    private function build(User $user, int $limit, ?string $cursor): array
    {
        $profile = $this->prefBuilder->build($user);

        $userId = (int) $user->id;

        if (! $profile->hasSignals()) {
            return $this->coldStart($userId, $limit, $cursor);
        }

        $poolSize   = (int) config('recommendations.candidate_pool_size', 200);
        $tagSlugs   = array_slice($profile->topTagSlugs, 0, 10);

        // ── 1. Meilisearch candidate retrieval ────────────────────────────────
        $candidates = $this->fetchCandidates($tagSlugs, $userId, $poolSize);

        if ($candidates->isEmpty()) {
            return $this->coldStart($userId, $limit, $cursor);
        }

        // ── 2. Exclude already-favourited artworks ────────────────────────────
        $favoritedIds = $this->getFavoritedIds((int) $user->id);
        $candidates   = $candidates->whereNotIn('id', $favoritedIds)->values();

        // ── 3. Enrich: load tags + stats for all candidates (2 IN queries) ────
        $candidates->load(['tags:id,slug', 'stats']);

        // ── 4. PHP reranking ──────────────────────────────────────────────────
        $scored = $this->rerank($candidates, $profile);

        // ── 5. Diversity controls ─────────────────────────────────────────────
        $diversified = $this->applyDiversity($scored);

        // ── 6. Paginate ───────────────────────────────────────────────────────
        return $this->paginate($diversified, $limit, $cursor, $profile);
    }

    // ─── Meilisearch retrieval ────────────────────────────────────────────────

    /**
     * @return Collection<int, Artwork>
     */
    private function fetchCandidates(array $tagSlugs, int $userId, int $poolSize): Collection
    {
        $filterParts = [
            'is_public = true',
            'is_approved = true',
            'author_id != ' . $userId,
        ];

        if ($tagSlugs !== []) {
            $tagFilter = implode(' OR ', array_map(
                fn (string $t): string => 'tags = "' . addslashes($t) . '"',
                $tagSlugs
            ));
            $filterParts[] = '(' . $tagFilter . ')';
        }

        try {
            $results = Artwork::search('')
                ->options([
                    'filter' => implode(' AND ', $filterParts),
                    'sort'   => ['trending_score_7d:desc', 'created_at:desc'],
                ])
                ->paginate($poolSize, 'page', 1);

            return $results->getCollection();
        } catch (\Throwable $e) {
            Log::warning('RecommendationService: Meilisearch unavailable, using DB fallback', [
                'error' => $e->getMessage(),
            ]);

            return $this->dbFallbackCandidates($userId, $tagSlugs, $poolSize);
        }
    }

    /**
     * DB fallback when Meilisearch is unavailable.
     *
     * @return Collection<int, Artwork>
     */
    private function dbFallbackCandidates(int $userId, array $tagSlugs, int $poolSize): Collection
    {
        $query = Artwork::public()
            ->published()
            ->where('user_id', '!=', $userId)
            ->orderByDesc('trending_score_7d')
            ->orderByDesc('published_at')
            ->limit($poolSize);

        if ($tagSlugs !== []) {
            $query->whereHas('tags', fn ($q) => $q->whereIn('slug', $tagSlugs));
        }

        return $query->get();
    }

    // ─── Reranking ────────────────────────────────────────────────────────────

    /**
     * Score each candidate and return a sorted array of [score, artwork].
     *
     * @param  Collection<int, Artwork> $candidates
     * @return array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}>
     */
    private function rerank(Collection $candidates, UserRecoProfileDTO $profile): array
    {
        $weights = (array) config('recommendations.weights', []);
        $wTag    = (float) ($weights['tag_overlap']      ?? 0.40);
        $wCre    = (float) ($weights['creator_affinity'] ?? 0.25);
        $wPop    = (float) ($weights['popularity']       ?? 0.20);
        $wFresh  = (float) ($weights['freshness']        ?? 0.15);

        $userTagSet = array_flip($profile->topTagSlugs); // slug → index (fast lookup)

        $scored = [];

        foreach ($candidates as $artwork) {
            $artworkTagSlugs = $artwork->tags->pluck('slug')->all();
            $artworkTagSet   = array_flip($artworkTagSlugs);

            // ── Tag overlap (Jaccard-like) ─────────────────────────────────────
            $commonTags = count(array_intersect_key($userTagSet, $artworkTagSet));
            $totalTags  = max(1, count($userTagSet) + count($artworkTagSet) - $commonTags);
            $tagOverlap = $commonTags / $totalTags;

            // ── Creator affinity ──────────────────────────────────────────────
            $creatorAffinity = $profile->followsCreator((int) $artwork->user_id) ? 1.0 : 0.0;

            // ── Popularity boost (log-normalised views) ───────────────────────
            $views      = max(0, (int) ($artwork->stats?->views ?? 0));
            $popularity = min(1.0, log(1 + $views) / 12.0);

            // ── Freshness boost (exponential decay over 30 days) ─────────────
            $publishedAt = $artwork->published_at ?? $artwork->created_at ?? now();
            $ageDays     = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
            $freshness   = exp(-$ageDays / 30.0);

            $score = ($wTag   * $tagOverlap)
                   + ($wCre   * $creatorAffinity)
                   + ($wPop   * $popularity)
                   + ($wFresh * $freshness);

            $scored[] = [
                'score'      => $score,
                'artwork'    => $artwork,
                'tag_slugs'  => $artworkTagSlugs,
            ];
        }

        usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);

        return $scored;
    }

    // ─── Diversity controls ───────────────────────────────────────────────────

    /**
     * Apply per-creator cap and tag variety enforcement.
     *
     * @param  array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}> $scored
     * @return array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}>
     */
    private function applyDiversity(array $scored): array
    {
        $maxPerCreator = (int) config('recommendations.max_per_creator', 3);
        $minUniqueTags = (int) config('recommendations.min_unique_tags', 5);

        $creatorCount = [];
        $seenTagSlugs = [];
        $result       = [];
        $deferred     = []; // items over per-creator cap (added back at end)

        foreach ($scored as $item) {
            $creatorId = (int) $item['artwork']->user_id;

            if (($creatorCount[$creatorId] ?? 0) >= $maxPerCreator) {
                $deferred[] = $item;
                continue;
            }

            $result[] = $item;
            $creatorCount[$creatorId] = ($creatorCount[$creatorId] ?? 0) + 1;

            foreach ($item['tag_slugs'] as $slug) {
                $seenTagSlugs[$slug] = true;
            }
        }

        // Check tag variety in first 20 – if insufficient, inject from deferred
        if (count($seenTagSlugs) < $minUniqueTags && $deferred !== []) {
            foreach ($deferred as $item) {
                $newTags = array_diff($item['tag_slugs'], array_keys($seenTagSlugs));
                if ($newTags !== []) {
                    $result[] = $item;
                    foreach ($newTags as $slug) {
                        $seenTagSlugs[$slug] = true;
                    }

                    if (count($seenTagSlugs) >= $minUniqueTags) {
                        break;
                    }
                }
            }
        }

        return $result;
    }

    // ─── Cold-start fallback ──────────────────────────────────────────────────

    /**
     * @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
     */
    private function coldStart(int $userId, int $limit, ?string $cursor): array
    {
        $offset = $this->decodeCursor($cursor);

        try {
            $results = Artwork::search('')
                ->options([
                    'filter' => 'is_public = true AND is_approved = true AND author_id != ' . $userId,
                    'sort'   => ['trending_score_7d:desc', 'created_at:desc'],
                ])
                ->paginate(self::COLD_START_LIMIT + $offset, 'page', 1);

            $artworks = $results->getCollection()->slice($offset, $limit)->values();
        } catch (\Throwable) {
            $artworks = Artwork::public()
                ->published()
                ->where('user_id', '!=', $userId)
                ->orderByDesc('trending_score_7d')
                ->orderByDesc('published_at')
                ->skip($offset)
                ->limit($limit)
                ->get();
        }

        $nextOffset = $offset + $limit;
        $hasMore    = $artworks->count() >= $limit;

        return [
            'data' => $artworks->map(fn (Artwork $a): array => $this->serializeArtwork($a))->values()->all(),
            'meta' => [
                'source'      => 'cold_start',
                'cursor'      => $this->encodeCursor($offset),
                'next_cursor' => $hasMore ? $this->encodeCursor($nextOffset) : null,
                'limit'       => $limit,
            ],
        ];
    }

    // ─── Pagination ───────────────────────────────────────────────────────────

    /**
     * @param  array<int, array{score: float, artwork: Artwork, tag_slugs: string[]}> $diversified
     * @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
     */
    private function paginate(array $diversified, int $limit, ?string $cursor, UserRecoProfileDTO $profile): array
    {
        $offset  = $this->decodeCursor($cursor);
        $pageItems = array_slice($diversified, $offset, $limit);
        $total   = count($diversified);
        $nextOffset = $offset + $limit;

        $data = array_map(
            fn (array $item): array => array_merge(
                $this->serializeArtwork($item['artwork']),
                ['score' => round((float) $item['score'], 5), 'source' => 'personalised']
            ),
            $pageItems
        );

        return [
            'data' => array_values($data),
            'meta' => [
                'source'           => 'personalised',
                'cursor'           => $this->encodeCursor($offset),
                'next_cursor'      => $nextOffset < $total ? $this->encodeCursor($nextOffset) : null,
                'limit'            => $limit,
                'total_candidates' => $total,
                'has_signals'      => true,
            ],
        ];
    }

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

    /** @return array<int, int> */
    private function getFavoritedIds(int $userId): array
    {
        return DB::table('artwork_favourites')
            ->where('user_id', $userId)
            ->pluck('artwork_id')
            ->map(fn ($id) => (int) $id)
            ->all();
    }

    /** @return array<string, mixed> */
    private function serializeArtwork(Artwork $artwork): array
    {
        return [
            'id'            => $artwork->id,
            'title'         => $artwork->title ?? 'Untitled',
            'slug'          => $artwork->slug  ?? '',
            'thumbnail_url' => $artwork->thumbUrl('md'),
            'author'        => $artwork->user?->name ?? 'Artist',
            'author_id'     => $artwork->user_id,
            'url'           => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''),
            'width'         => $artwork->width,
            'height'        => $artwork->height,
            'published_at'  => $artwork->published_at?->toIso8601String(),
        ];
    }

    private function decodeCursor(?string $cursor): int
    {
        if (! $cursor) {
            return 0;
        }

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

        $json = json_decode($decoded, true);
        return max(0, (int) Arr::get((array) $json, 'offset', 0));
    }

    private function encodeCursor(int $offset): string
    {
        $payload = json_encode(['offset' => max(0, $offset)]);
        return rtrim(strtr(base64_encode((string) $payload), '+/', '-_'), '=');
    }
}
