<?php

declare(strict_types=1);

namespace App\Services\Profile;

use App\Enums\CreatorMilestoneType;
use App\Jobs\RebuildCreatorJourneyJob;
use App\Models\Artwork;
use App\Models\ArtworkRelation;
use App\Models\CreatorMilestone;
use App\Models\Group;
use App\Models\GroupRelease;
use App\Models\User;
use App\Services\Profile\CreatorComebackService;
use App\Services\Profile\CreatorEraService;
use App\Services\Profile\CreatorStreakService;
use App\Services\Ranking\ArtworkRankingService;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;

final class CreatorJourneyService
{
    private const PUBLIC_CACHE_TTL_SECONDS = 900;
    private const REBUILD_DEBOUNCE_SECONDS = 300;

    public function __construct(
        private readonly ArtworkRankingService $ranking,
        private readonly CreatorComebackService $comebacks,
        private readonly CreatorStreakService $streaks,
        private readonly CreatorEraService $eras,
    ) {
    }

    public function publicPayloadForUser(User|int $user): array
    {
        $resolvedUser = $this->resolveUser($user);
        $userId = (int) $resolvedUser->id;
        $version = $this->cacheVersion($userId);

        return Cache::remember(
            sprintf('creator_journey:public:%d:v%d', $userId, $version),
            now()->addSeconds(self::PUBLIC_CACHE_TTL_SECONDS),
            function () use ($resolvedUser, $userId): array {
                $rows = CreatorMilestone::query()
                    ->where('user_id', $userId)
                    ->where('is_public', true)
                    ->orderByDesc('occurred_at')
                    ->orderByDesc('priority')
                    ->orderByDesc('id')
                    ->get();

                if ($rows->isEmpty()) {
                    $this->rebuildForUser($resolvedUser);

                    $rows = CreatorMilestone::query()
                        ->where('user_id', $userId)
                        ->where('is_public', true)
                        ->orderByDesc('occurred_at')
                        ->orderByDesc('priority')
                        ->orderByDesc('id')
                        ->get();
                }

                // v2: gather eras, evolution, and streak stats
                $eraData       = Schema::hasTable('creator_eras') ? $this->eras->publicErasForUser($userId) : [];
                $evolutionData = Schema::hasTable('artwork_relations') ? $this->evolutionPayloadForUser($userId) : [];
                $artworks      = $this->publicArtworkRows($userId);
                $streakStats   = $this->streaks->computeStreakStats($artworks);

                return $this->formatPublicPayload($resolvedUser, $rows, $eraData, $evolutionData, $streakStats);
            }
        );
    }

    /**
     * @return array{milestones_saved:int}
     */
    public function rebuildForUser(User|int $user): array
    {
        $resolvedUser = $this->resolveUser($user);
        $userId = (int) $resolvedUser->id;
        $computedAt = now();
        $rows = $this->calculateMilestones($resolvedUser, $computedAt);

        DB::transaction(function () use ($userId, $rows): void {
            CreatorMilestone::query()->where('user_id', $userId)->delete();

            if ($rows !== []) {
                DB::table('creator_milestones')->insert($rows);
            }
        });

        // Rebuild eras in the same pass (separate table, transactional independently)
        $artworks = $this->publicArtworkRows($userId);
        $this->eras->rebuildForUser($resolvedUser, $artworks);

        Cache::forget($this->rebuildDebounceKey($userId));
        $this->bumpCacheVersion($userId);

        return ['milestones_saved' => count($rows)];
    }

    public function requestRebuild(int $userId, bool $force = false): void
    {
        if ($userId <= 0) {
            return;
        }

        if (! $force && ! Cache::add($this->rebuildDebounceKey($userId), true, now()->addSeconds(self::REBUILD_DEBOUNCE_SECONDS))) {
            return;
        }

        RebuildCreatorJourneyJob::dispatch([$userId]);
    }

    public function invalidateUser(int $userId): void
    {
        if ($userId <= 0) {
            return;
        }

        $this->bumpCacheVersion($userId);
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function calculateMilestones(User $user, CarbonInterface $computedAt): array
    {
        $artworks = $this->publicArtworkRows((int) $user->id);
        $milestones = [];

        if ($firstUpload = $artworks->sortBy([['published_at', 'asc'], ['id', 'asc']])->first()) {
            $occurredAt = $this->parseDate($firstUpload->published_at);
            $milestones[] = $this->makeMilestoneRow(
                (int) $user->id,
                CreatorMilestoneType::FirstUpload,
                $occurredAt,
                [
                    'title' => 'First upload',
                    'headline' => (string) $firstUpload->title,
                    'summary' => 'Started the public journey with the first published work on Skinbase.',
                    'value' => $this->displayDate($occurredAt),
                    'artwork' => $this->artworkSnapshot($firstUpload),
                ],
                (int) $firstUpload->id,
                $computedAt,
            );
        }

        if ($firstFeatured = $this->firstFeaturedArtwork((int) $user->id)) {
            $occurredAt = $this->parseDate($firstFeatured->featured_at);
            $milestones[] = $this->makeMilestoneRow(
                (int) $user->id,
                CreatorMilestoneType::FirstFeaturedArtwork,
                $occurredAt,
                [
                    'title' => 'First featured artwork',
                    'headline' => (string) $firstFeatured->title,
                    'summary' => 'Earned a first featured slot on the public artwork lineup.',
                    'value' => $this->displayDate($occurredAt),
                    'artwork' => $this->artworkSnapshot($firstFeatured),
                ],
                (int) $firstFeatured->id,
                $computedAt,
            );
        }

        if ($firstGroupRelease = $this->firstGroupRelease((int) $user->id)) {
            $occurredAt = $this->parseDate($firstGroupRelease->released_on);
            $milestones[] = $this->makeMilestoneRow(
                (int) $user->id,
                CreatorMilestoneType::FirstGroupRelease,
                $occurredAt,
                [
                    'title' => 'First group release',
                    'headline' => (string) $firstGroupRelease->release_title,
                    'summary' => 'Joined the first public group release as a credited contributor.',
                    'value' => (string) $firstGroupRelease->group_name,
                    'release' => [
                        'id' => (int) $firstGroupRelease->release_id,
                        'title' => (string) $firstGroupRelease->release_title,
                        'group_name' => (string) $firstGroupRelease->group_name,
                        'url' => url('/groups/' . $firstGroupRelease->group_slug . '/releases/' . $firstGroupRelease->release_slug),
                    ],
                ],
                null,
                $computedAt,
            );
        }

        if ($bestSpike = $this->biggestDownloadSpike($artworks)) {
            $occurredAt = $this->parseDate($bestSpike['occurred_at']);
            $milestones[] = $this->makeMilestoneRow(
                (int) $user->id,
                CreatorMilestoneType::BiggestDownloadSpike,
                $occurredAt,
                [
                    'title' => 'Biggest download spike',
                    'headline' => (string) $bestSpike['artwork']->title,
                    'summary' => 'Captured the strongest one-hour download burst recorded for a public artwork.',
                    'value' => (int) $bestSpike['downloads_in_hour'] . ' downloads in 1 hour',
                    'artwork' => $this->artworkSnapshot($bestSpike['artwork']),
                    'metrics' => [
                        'downloads_in_hour' => (int) $bestSpike['downloads_in_hour'],
                    ],
                ],
                (int) $bestSpike['artwork']->id,
                $computedAt,
            );
        }

        if ($bestPerforming = $this->bestPerformingArtwork($artworks)) {
            $occurredAt = $this->parseDate($bestPerforming->published_at);
            $score = $this->basePerformanceScore($bestPerforming);
            $milestones[] = $this->makeMilestoneRow(
                (int) $user->id,
                CreatorMilestoneType::BestPerformingWork,
                $occurredAt,
                [
                    'title' => 'Best-performing work',
                    'headline' => (string) $bestPerforming->title,
                    'summary' => 'Leads the public catalog on total engagement across views, downloads, favourites, comments, and shares.',
                    'value' => number_format($score, 1) . ' performance points',
                    'artwork' => $this->artworkSnapshot($bestPerforming),
                    'metrics' => $this->artworkMetricSnapshot($bestPerforming) + ['performance_score' => round($score, 2)],
                ],
                (int) $bestPerforming->id,
                $computedAt,
            );
        }

        if ($mostProductiveYear = $this->mostProductiveYear($artworks)) {
            $occurredAt = $this->parseDate($mostProductiveYear['last_published_at']);
            $milestones[] = $this->makeMilestoneRow(
                (int) $user->id,
                CreatorMilestoneType::MostProductiveYear,
                $occurredAt,
                [
                    'title' => 'Most productive year',
                    'headline' => (string) $mostProductiveYear['year'],
                    'summary' => 'Published the highest number of public artworks in a single year.',
                    'value' => (int) $mostProductiveYear['uploads_count'] . ' public uploads',
                    'metrics' => [
                        'year' => (int) $mostProductiveYear['year'],
                        'uploads_count' => (int) $mostProductiveYear['uploads_count'],
                    ],
                ],
                null,
                $computedAt,
            );
        }

        // ── v2: Comeback milestones ────────────────────────────────────────
        foreach ($this->comebacks->calculateComebacks($artworks, (int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) {
            $milestones[] = $row;
        }

        // ── v2: Streak milestones ─────────────────────────────────────────
        foreach ($this->streaks->calculateStreakMilestones($artworks, (int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) {
            $milestones[] = $row;
        }

        // ── v2: Era milestones ────────────────────────────────────────────
        foreach ($this->eras->calculateEraMilestones($user, $artworks, $computedAt, $this->makeMilestoneRow(...)) as $row) {
            $milestones[] = $row;
        }

        // ── v2: Evolution / Before-Now milestones ─────────────────────────
        foreach ($this->evolutionMilestonesForUser((int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) {
            $milestones[] = $row;
        }

        foreach ($this->yearlyRecaps($artworks) as $recap) {
            $occurredAt = $this->parseDate($recap['last_published_at']);
            $milestones[] = $this->makeMilestoneRow(
                (int) $user->id,
                CreatorMilestoneType::YearlyRecap,
                $occurredAt,
                [
                    'title' => $recap['year'] . ' recap',
                    'headline' => $recap['uploads_count'] . ' public uploads',
                    'summary' => $recap['downloads'] . ' downloads, ' . number_format((int) $recap['views']) . ' views, and ' . $recap['favorites'] . ' favourites across the year.',
                    'value' => (string) $recap['year'],
                    'artwork' => $recap['top_artwork'] !== null ? $this->artworkSnapshot($recap['top_artwork']) : null,
                    'metrics' => [
                        'year'              => (int) $recap['year'],
                        'uploads_count'     => (int) $recap['uploads_count'],
                        'views'             => (int) $recap['views'],
                        'downloads'         => (int) $recap['downloads'],
                        'favorites'         => (int) $recap['favorites'],
                        'comments_count'    => (int) $recap['comments_count'],
                        'shares_count'      => (int) $recap['shares_count'],
                        'featured_count'    => (int) $recap['featured_count'],
                        'performance_score' => round((float) $recap['performance_score'], 2),
                        'top_category'      => $recap['top_category'] ?? null,
                        'best_month'        => $recap['best_month'] ?? null,
                        'year_status'       => $recap['year_status'] ?? 'steady',
                    ],
                    'shareable_recap' => [
                        'type'  => 'yearly_recap',
                        'year'  => (int) $recap['year'],
                        'title' => 'My ' . $recap['year'] . ' on Skinbase',
                        'stats' => [
                            'uploads'   => (int) $recap['uploads_count'],
                            'downloads' => (int) $recap['downloads'],
                            'featured'  => (int) $recap['featured_count'],
                        ],
                        'top_artwork' => $recap['top_artwork'] !== null ? [
                            'id'    => (int) $recap['top_artwork']->id,
                            'title' => (string) $recap['top_artwork']->title,
                        ] : null,
                    ],
                ],
                $recap['top_artwork'] !== null ? (int) $recap['top_artwork']->id : null,
                $computedAt,
            );
        }

        return collect($milestones)
            ->sortBy([
                ['occurred_at', 'desc'],
                ['priority', 'desc'],
            ])
            ->values()
            ->all();
    }

    private function publicArtworkRows(int $userId): Collection
    {
        return DB::table('artworks as a')
            ->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'a.id')
            ->where('a.user_id', $userId)
            ->whereNull('a.deleted_at')
            ->where('a.is_public', true)
            ->where('a.is_approved', true)
            ->where(function ($query): void {
                $query->whereNull('a.visibility')
                    ->orWhere('a.visibility', Artwork::VISIBILITY_PUBLIC);
            })
            ->whereNotNull('a.published_at')
            ->where('a.published_at', '<=', now())
            ->orderBy('a.published_at')
            ->orderBy('a.id')
            ->get([
                'a.id',
                'a.title',
                'a.slug',
                'a.published_at',
                'a.created_at',
                's.views as stat_views',
                's.downloads as stat_downloads',
                's.favorites as stat_favorites',
                's.comments_count as stat_comments_count',
                's.shares_count as stat_shares_count',
                's.downloads_1h as stat_downloads_1h',
                's.heat_score_updated_at as stat_heat_score_updated_at',
            ]);
    }

    private function firstFeaturedArtwork(int $userId): ?object
    {
        return DB::table('artwork_features as af')
            ->join('artworks as a', 'a.id', '=', 'af.artwork_id')
            ->where('a.user_id', $userId)
            ->whereNull('a.deleted_at')
            ->where('a.is_public', true)
            ->where('a.is_approved', true)
            ->where(function ($query): void {
                $query->whereNull('a.visibility')
                    ->orWhere('a.visibility', Artwork::VISIBILITY_PUBLIC);
            })
            ->whereNotNull('a.published_at')
            ->whereNull('af.deleted_at')
            ->where('af.is_active', true)
            ->orderBy('af.featured_at')
            ->orderBy('a.id')
            ->first([
                'a.id',
                'a.title',
                'a.slug',
                'a.published_at',
                'af.featured_at',
            ]);
    }

    private function firstGroupRelease(int $userId): ?object
    {
        return DB::table('group_release_contributors as grc')
            ->join('group_releases as gr', 'gr.id', '=', 'grc.group_release_id')
            ->join('groups as g', 'g.id', '=', 'gr.group_id')
            ->where('grc.user_id', $userId)
            ->whereNull('gr.deleted_at')
            ->where('gr.visibility', GroupRelease::VISIBILITY_PUBLIC)
            ->where('gr.status', GroupRelease::STATUS_RELEASED)
            ->where('g.visibility', Group::VISIBILITY_PUBLIC)
            ->where('g.status', Group::LIFECYCLE_ACTIVE)
            ->whereNotNull('gr.released_at')
            ->where('gr.released_at', '<=', now())
            ->orderBy('gr.released_at')
            ->orderBy('gr.id')
            ->first([
                'gr.id as release_id',
                'gr.title as release_title',
                'gr.slug as release_slug',
                'gr.released_at as released_on',
                'g.name as group_name',
                'g.slug as group_slug',
            ]);
    }

    /**
     * @param  Collection<int, object>  $artworks
     * @return array{artwork:object,downloads_in_hour:int,occurred_at:string}|null
     */
    private function biggestDownloadSpike(Collection $artworks): ?array
    {
        $best = null;
        $publicArtworkIds = $artworks->pluck('id')->map(fn ($id): int => (int) $id)->all();

        if ($publicArtworkIds !== [] && DB::getSchemaBuilder()->hasTable('artwork_metric_snapshots_hourly')) {
            $snapshots = DB::table('artwork_metric_snapshots_hourly as ms')
                ->whereIn('ms.artwork_id', $publicArtworkIds)
                ->orderBy('ms.artwork_id')
                ->orderBy('ms.bucket_hour')
                ->get([
                    'ms.artwork_id',
                    'ms.bucket_hour',
                    'ms.downloads_count',
                ]);

            $byArtwork = $artworks->keyBy('id');
            $previous = [];

            foreach ($snapshots as $snapshot) {
                $artwork = $byArtwork->get((int) $snapshot->artwork_id);

                if (! $artwork) {
                    continue;
                }

                $priorCount = $previous[(int) $snapshot->artwork_id] ?? null;

                if ($priorCount !== null) {
                    $delta = max(0, (int) $snapshot->downloads_count - $priorCount['downloads_count']);

                    if ($delta > 0 && ($best === null || $delta > $best['downloads_in_hour'] || ($delta === $best['downloads_in_hour'] && $snapshot->bucket_hour > $best['occurred_at']))) {
                        $best = [
                            'artwork' => $artwork,
                            'downloads_in_hour' => $delta,
                            'occurred_at' => (string) $snapshot->bucket_hour,
                        ];
                    }
                }

                $previous[(int) $snapshot->artwork_id] = [
                    'downloads_count' => (int) $snapshot->downloads_count,
                ];
            }
        }

        if ($best !== null) {
            return $best;
        }

        $fallback = $artworks
            ->filter(fn ($artwork): bool => (int) ($artwork->stat_downloads_1h ?? 0) > 0)
            ->sortBy([
                fn ($artwork): int => -1 * (int) ($artwork->stat_downloads_1h ?? 0),
                fn ($artwork): string => (string) ($artwork->stat_heat_score_updated_at ?? $artwork->published_at ?? ''),
            ])
            ->first();

        if (! $fallback) {
            return null;
        }

        return [
            'artwork' => $fallback,
            'downloads_in_hour' => (int) ($fallback->stat_downloads_1h ?? 0),
            'occurred_at' => (string) ($fallback->stat_heat_score_updated_at ?? $fallback->published_at),
        ];
    }

    private function bestPerformingArtwork(Collection $artworks): ?object
    {
        return $artworks
            ->filter(fn ($artwork): bool => $this->basePerformanceScore($artwork) > 0)
            ->sortBy([
                fn ($artwork): float => -1 * $this->basePerformanceScore($artwork),
                fn ($artwork): string => (string) ($artwork->published_at ?? ''),
            ])
            ->first();
    }

    /**
     * @param  Collection<int, object>  $artworks
     * @return array{year:int,uploads_count:int,last_published_at:string}|null
     */
    private function mostProductiveYear(Collection $artworks): ?array
    {
        return $artworks
            ->groupBy(fn ($artwork): int => (int) date('Y', strtotime((string) $artwork->published_at)))
            ->map(function (Collection $items, int $year): array {
                $lastPublishedAt = $items
                    ->sortByDesc('published_at')
                    ->first()?->published_at;

                return [
                    'year' => $year,
                    'uploads_count' => $items->count(),
                    'last_published_at' => (string) $lastPublishedAt,
                ];
            })
            ->sortBy([
                fn (array $row): int => -1 * (int) $row['uploads_count'],
                fn (array $row): int => -1 * (int) $row['year'],
            ])
            ->first();
    }

    /**
     * @param  Collection<int, object>  $artworks
     * @return array<int, array<string, mixed>>
     */
    private function yearlyRecaps(Collection $artworks): array
    {
        // Fetch featured counts per year once (keyed by year)
        $featuredByYear = $this->featuredCountsByYear($artworks);

        return $artworks
            ->groupBy(fn ($artwork): int => (int) date('Y', strtotime((string) $artwork->published_at)))
            ->map(function (Collection $items, int $year) use ($featuredByYear): array {
                $topArtwork = $items
                    ->sortByDesc(fn ($artwork): float => $this->basePerformanceScore($artwork))
                    ->first();

                $downloads    = $items->sum(fn ($a): int => (int) ($a->stat_downloads ?? 0));
                $uploads      = $items->count();
                $featured     = (int) ($featuredByYear[$year] ?? 0);
                $perfScore    = $items->sum(fn ($a): float => $this->basePerformanceScore($a));

                // Best month: which calendar month had the most uploads
                $bestMonth = $items
                    ->groupBy(fn ($a): string => date('Y-m', strtotime((string) $a->published_at)))
                    ->map(fn (Collection $g): int => $g->count())
                    ->sortDesc()
                    ->keys()
                    ->first();

                // Top category from artwork pivot (best effort — requires subquery or separate call)
                $topCategory = $this->topCategoryForYear($items);

                // Year status label
                $yearStatus = $this->classifyYear($uploads, $featured, $perfScore);

                return [
                    'year'            => $year,
                    'uploads_count'   => $uploads,
                    'views'           => $items->sum(fn ($a): int => (int) ($a->stat_views ?? 0)),
                    'downloads'       => $downloads,
                    'favorites'       => $items->sum(fn ($a): int => (int) ($a->stat_favorites ?? 0)),
                    'comments_count'  => $items->sum(fn ($a): int => (int) ($a->stat_comments_count ?? 0)),
                    'shares_count'    => $items->sum(fn ($a): int => (int) ($a->stat_shares_count ?? 0)),
                    'featured_count'  => $featured,
                    'performance_score' => $perfScore,
                    'last_published_at' => (string) $items->sortByDesc('published_at')->first()?->published_at,
                    'top_artwork'     => $topArtwork,
                    'best_month'      => $bestMonth,
                    'top_category'    => $topCategory,
                    'year_status'     => $yearStatus,
                ];
            })
            ->sortByDesc('year')
            ->values()
            ->all();
    }

    /**
     * @param  Collection<int, object>  $artworks
     * @return array<int, int>  year => featured_count
     */
    private function featuredCountsByYear(Collection $artworks): array
    {
        $artworkIds = $artworks->pluck('id')->map(fn ($id): int => (int) $id)->all();

        if ($artworkIds === [] || ! Schema::hasTable('artwork_features')) {
            return [];
        }

        $yearExpr = DB::connection()->getDriverName() === 'sqlite'
            ? "CAST(strftime('%Y', af.featured_at) AS INTEGER)"
            : 'YEAR(af.featured_at)';

        return DB::table('artwork_features as af')
            ->join('artworks as a', 'a.id', '=', 'af.artwork_id')
            ->whereIn('af.artwork_id', $artworkIds)
            ->whereNull('af.deleted_at')
            ->where('af.is_active', true)
            ->selectRaw("{$yearExpr} as yr, COUNT(*) as cnt")
            ->groupBy('yr')
            ->pluck('cnt', 'yr')
            ->map(fn ($cnt): int => (int) $cnt)
            ->toArray();
    }

    /**
     * @param  Collection<int, object>  $items
     */
    private function topCategoryForYear(Collection $items): ?string
    {
        $artworkIds = $items->pluck('id')->map(fn ($id): int => (int) $id)->all();

        if ($artworkIds === [] || ! Schema::hasTable('artwork_category')) {
            return null;
        }

        $row = DB::table('artwork_category as ac')
            ->join('categories as c', 'c.id', '=', 'ac.category_id')
            ->whereIn('ac.artwork_id', $artworkIds)
            ->selectRaw('c.name, COUNT(*) as cnt')
            ->groupBy('c.id', 'c.name')
            ->orderByDesc('cnt')
            ->first(['c.name']);

        return $row ? (string) $row->name : null;
    }

    private function classifyYear(int $uploads, int $featured, float $perfScore): string
    {
        if ($uploads >= 10 && $featured >= 2) {
            return 'breakout';
        }

        if ($featured >= 1 && $uploads >= 5) {
            return 'steady';
        }

        if ($uploads >= 6 && $featured === 0) {
            return 'experimental';
        }

        if ($uploads <= 2) {
            return 'quiet';
        }

        return 'steady';
    }

    private function formatPublicPayload(
        User $user,
        Collection $rows,
        array $eras = [],
        array $evolution = [],
        array $streakStats = [],
    ): array {
        $items = $rows->map(function (CreatorMilestone $milestone): array {
            $payload = $milestone->payload_json ?? [];

            return [
                'id'               => (int) $milestone->id,
                'type'             => (string) $milestone->type,
                'occurred_at'      => $milestone->occurred_at?->toIso8601String(),
                'occurred_year'    => $milestone->occurred_year,
                'priority'         => (int) $milestone->priority,
                'title'            => (string) ($payload['title'] ?? Str::headline((string) $milestone->type)),
                'headline'         => $payload['headline'] ?? null,
                'summary'          => $payload['summary'] ?? null,
                'value'            => $payload['value'] ?? null,
                'artwork'          => $payload['artwork'] ?? null,
                'release'          => $payload['release'] ?? null,
                'metrics'          => $payload['metrics'] ?? [],
                'metadata'         => $payload['metadata'] ?? null,
                'shareable_recap'  => $payload['shareable_recap'] ?? null,
            ];
        })->values();

        $timeline = $items
            ->reject(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value)
            ->values()
            ->all();

        $yearlyRecaps = $items
            ->filter(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value)
            ->sortByDesc('occurred_year')
            ->values()
            ->all();

        // Build shareable recap payloads from yearly recap milestone payloads
        $shareableRecaps = $items
            ->filter(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value)
            ->sortByDesc('occurred_year')
            ->map(fn (array $item): ?array => $item['shareable_recap'])
            ->filter()
            ->values()
            ->all();

        $highlightTypes = [
            CreatorMilestoneType::BestPerformingWork->value,
            CreatorMilestoneType::BiggestDownloadSpike->value,
            CreatorMilestoneType::MostProductiveYear->value,
            CreatorMilestoneType::FirstFeaturedArtwork->value,
            CreatorMilestoneType::ComebackLegendary->value,
            CreatorMilestoneType::UploadStreak12->value,
            CreatorMilestoneType::ActiveYearStreak5->value,
        ];

        $highlights = $items
            ->filter(fn (array $item): bool => in_array($item['type'], $highlightTypes, true))
            ->sortByDesc('priority')
            ->values()
            ->take(4)
            ->all();

        $latestMilestone = collect($timeline)->first();

        // Streak summary for API
        $streakSummary = [
            'current_monthly_upload_streak' => (int) ($streakStats['current_monthly_streak'] ?? 0),
            'best_monthly_upload_streak'    => (int) ($streakStats['best_monthly_streak'] ?? 0),
            'current_active_year_streak'    => (int) ($streakStats['current_year_streak'] ?? 0),
            'best_active_year_streak'       => (int) ($streakStats['best_year_streak'] ?? 0),
        ];

        return [
            'summary' => [
                'available'            => $items->isNotEmpty(),
                'member_since_year'    => $user->created_at?->year,
                'years_on_skinbase'    => $user->created_at?->diffInYears(now()),
                'milestone_count'      => $items->count(),
                'latest_milestone'     => $latestMilestone,
                'latest_yearly_recap'  => $yearlyRecaps[0] ?? null,
                'generated_at'         => $rows->max(fn (CreatorMilestone $milestone) => $milestone->computed_at?->toIso8601String()),
            ],
            'highlights'       => $highlights,
            'timeline'         => $timeline,
            'yearly_recaps'    => $yearlyRecaps,
            // ── v2 sections ────────────────────────────────────────────────
            'eras'             => $eras,
            'evolution'        => $evolution,
            'streaks'          => $streakSummary,
            'shareable_recaps' => $shareableRecaps,
        ];
    }

    private function evolutionPayloadForUser(int $userId): array
    {
        // Fetch public artwork_relations where the source artwork belongs to this creator.
        // Both source and target must be public for public display.
        $rows = DB::table('artwork_relations as ar')
            ->join('artworks as src', 'src.id', '=', 'ar.source_artwork_id')
            ->join('artworks as tgt', 'tgt.id', '=', 'ar.target_artwork_id')
            ->leftJoin('artwork_stats as ss', 'ss.artwork_id', '=', 'ar.source_artwork_id')
            ->leftJoin('artwork_stats as ts', 'ts.artwork_id', '=', 'ar.target_artwork_id')
            ->where('src.user_id', $userId)
            ->whereNull('src.deleted_at')
            ->whereNull('tgt.deleted_at')
            ->where('src.is_public', true)
            ->where('src.is_approved', true)
            ->where('tgt.is_public', true)
            ->where('tgt.is_approved', true)
            ->whereNotNull('src.published_at')
            ->whereNotNull('tgt.published_at')
            ->orderBy('ar.sort_order')
            ->orderBy('ar.id')
            ->get([
                'ar.id',
                'ar.relation_type',
                'ar.note',
                'src.id as src_id',
                'src.title as src_title',
                'src.slug as src_slug',
                'src.published_at as src_published_at',
                'tgt.id as tgt_id',
                'tgt.title as tgt_title',
                'tgt.slug as tgt_slug',
                'tgt.published_at as tgt_published_at',
            ]);

        return $rows->map(function (object $row): array {
            $srcDate = Carbon::parse($row->src_published_at);
            $tgtDate = Carbon::parse($row->tgt_published_at);
            $yearsBetween = (int) abs($tgtDate->diffInYears($srcDate));

            return [
                'id'              => (int) $row->id,
                'relation_type'   => (string) $row->relation_type,
                'years_between'   => $yearsBetween,
                'note'            => $row->note,
                'source_artwork'  => [
                    'id'           => (int) $row->src_id,
                    'title'        => (string) $row->src_title,
                    'slug'         => (string) $row->src_slug,
                    'url'          => route('art.show', ['id' => (int) $row->src_id, 'slug' => $row->src_slug]),
                    'published_at' => $srcDate->toIso8601String(),
                ],
                'target_artwork'  => [
                    'id'           => (int) $row->tgt_id,
                    'title'        => (string) $row->tgt_title,
                    'slug'         => (string) $row->tgt_slug,
                    'url'          => route('art.show', ['id' => (int) $row->tgt_id, 'slug' => $row->tgt_slug]),
                    'published_at' => $tgtDate->toIso8601String(),
                ],
            ];
        })->values()->all();
    }

    private function evolutionMilestonesForUser(int $userId, CarbonInterface $computedAt, callable $makeMilestoneRow): array
    {
        if (! Schema::hasTable('artwork_relations')) {
            return [];
        }

        $rows = DB::table('artwork_relations as ar')
            ->join('artworks as src', 'src.id', '=', 'ar.source_artwork_id')
            ->join('artworks as tgt', 'tgt.id', '=', 'ar.target_artwork_id')
            ->where('src.user_id', $userId)
            ->whereNull('src.deleted_at')
            ->whereNull('tgt.deleted_at')
            ->where('src.is_public', true)
            ->where('src.is_approved', true)
            ->where('tgt.is_public', true)
            ->where('tgt.is_approved', true)
            ->whereNotNull('src.published_at')
            ->whereNotNull('tgt.published_at')
            ->get(['ar.id', 'ar.relation_type', 'ar.note', 'src.id as src_id', 'src.title as src_title', 'src.slug as src_slug', 'src.published_at as src_pub', 'tgt.id as tgt_id', 'tgt.title as tgt_title', 'tgt.published_at as tgt_pub']);

        $milestones = [];

        foreach ($rows as $row) {
            $srcDate  = Carbon::parse($row->src_pub);
            $tgtDate  = Carbon::parse($row->tgt_pub);
            $years    = max(0, (int) abs($tgtDate->diffInYears($srcDate)));
            $yearStr  = $years >= 1 ? "{$years} " . ($years === 1 ? 'year' : 'years') . ' later' : 'recently';

            $milestones[] = $makeMilestoneRow(
                $userId,
                CreatorMilestoneType::BeforeNow,
                $srcDate->max($tgtDate), // milestone at the newer artwork
                [
                    'title'    => 'Then & Now',
                    'headline' => (string) $row->src_title,
                    'summary'  => "Revisited and {$row->relation_type} \"{$row->tgt_title}\" — {$yearStr}.",
                    'value'    => $yearStr,
                    'artwork'  => [
                        'id'    => (int) $row->src_id,
                        'title' => (string) $row->src_title,
                        'slug'  => (string) $row->src_slug,
                        'url'   => route('art.show', ['id' => (int) $row->src_id, 'slug' => $row->src_slug]),
                    ],
                    'metadata' => [
                        'relation_type'   => $row->relation_type,
                        'years_between'   => $years,
                        'source_artwork_id' => (int) $row->src_id,
                        'target_artwork_id' => (int) $row->tgt_id,
                    ],
                ],
                (int) $row->src_id,
                $computedAt,
            );
        }

        return $milestones;
    }

    /**
     * @param  array<string, mixed>  $payload
     * @return array<string, mixed>
     */
    private function makeMilestoneRow(
        int $userId,
        CreatorMilestoneType $type,
        ?CarbonInterface $occurredAt,
        array $payload,
        ?int $relatedArtworkId,
        CarbonInterface $computedAt,
    ): array {
        $occurredAt = $occurredAt ?? $computedAt;

        return [
            'user_id' => $userId,
            'type' => $type->value,
            'occurred_at' => $occurredAt->toDateTimeString(),
            'occurred_year' => (int) $occurredAt->year,
            'related_artwork_id' => $relatedArtworkId,
            'is_public' => true,
            'priority' => $type->priority(),
            'payload_json' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
            'computed_at' => $computedAt->toDateTimeString(),
            'created_at' => $computedAt->toDateTimeString(),
            'updated_at' => $computedAt->toDateTimeString(),
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function artworkSnapshot(object $artwork): array
    {
        $slug = Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id;

        return [
            'id' => (int) $artwork->id,
            'title' => (string) $artwork->title,
            'slug' => (string) $slug,
            'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
            'published_at' => $this->parseDate($artwork->published_at)?->toIso8601String(),
        ];
    }

    /**
     * @return array<string, int>
     */
    private function artworkMetricSnapshot(object $artwork): array
    {
        return [
            'views' => (int) ($artwork->stat_views ?? 0),
            'downloads' => (int) ($artwork->stat_downloads ?? 0),
            'favorites' => (int) ($artwork->stat_favorites ?? 0),
            'comments_count' => (int) ($artwork->stat_comments_count ?? 0),
            'shares_count' => (int) ($artwork->stat_shares_count ?? 0),
        ];
    }

    private function basePerformanceScore(object $artwork): float
    {
        return $this->ranking->calculateBaseScore((object) [
            'views_all' => (float) ($artwork->stat_views ?? 0),
            'downloads_all' => (float) ($artwork->stat_downloads ?? 0),
            'favourites_all' => (float) ($artwork->stat_favorites ?? 0),
            'comments_count' => (float) ($artwork->stat_comments_count ?? 0),
            'shares_count' => (float) ($artwork->stat_shares_count ?? 0),
        ]);
    }

    private function displayDate(?CarbonInterface $date): ?string
    {
        return $date?->format('M j, Y');
    }

    private function parseDate(mixed $value): ?CarbonInterface
    {
        if ($value instanceof CarbonInterface) {
            return $value;
        }

        if (! is_string($value) || trim($value) === '') {
            return null;
        }

        return Carbon::parse($value);
    }

    private function resolveUser(User|int $user): User
    {
        return $user instanceof User
            ? $user
            : User::query()->findOrFail($user);
    }

    private function cacheVersion(int $userId): int
    {
        return (int) Cache::get($this->cacheVersionKey($userId), 1);
    }

    private function bumpCacheVersion(int $userId): void
    {
        Cache::forever($this->cacheVersionKey($userId), $this->cacheVersion($userId) + 1);
    }

    private function cacheVersionKey(int $userId): string
    {
        return 'creator_journey:version:' . $userId;
    }

    private function rebuildDebounceKey(int $userId): string
    {
        return 'creator_journey:rebuild:debounce:' . $userId;
    }
}