<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Models\GroupChallengeArtwork;
use App\Models\GroupChallengeOutcome;
use App\Models\User;
use App\Services\ThumbnailPresenter;
use App\Services\Worlds\WorldRewardService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

class GroupChallengeService
{
    public function __construct(
        private readonly GroupHistoryService $history,
        private readonly GroupActivityService $activity,
        private readonly GroupMediaService $media,
        private readonly NotificationService $notifications,
        private readonly WorldRewardService $worldRewards,
    ) {
    }

    public function create(Group $group, User $actor, array $attributes): GroupChallenge
    {
        $coverPath = null;

        try {
            $challenge = DB::transaction(function () use ($group, $actor, $attributes, &$coverPath): GroupChallenge {
                if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
                    $coverPath = $this->media->storeUploadedEntityImage($group, $attributes['cover_file'], 'challenges');
                }

                return GroupChallenge::query()->create([
                    'group_id' => (int) $group->id,
                    'title' => trim((string) $attributes['title']),
                    'slug' => $this->makeUniqueSlug((string) $attributes['title']),
                    'summary' => $this->nullableString($attributes['summary'] ?? null),
                    'description' => $this->nullableString($attributes['description'] ?? null),
                    'cover_path' => $coverPath ?: $this->nullableString($attributes['cover_path'] ?? null),
                    'visibility' => (string) ($attributes['visibility'] ?? GroupChallenge::VISIBILITY_PUBLIC),
                    'participation_scope' => (string) ($attributes['participation_scope'] ?? GroupChallenge::PARTICIPATION_GROUP_ONLY),
                    'status' => (string) ($attributes['status'] ?? GroupChallenge::STATUS_DRAFT),
                    'start_at' => $attributes['start_at'] ?? null,
                    'end_at' => $attributes['end_at'] ?? null,
                    'rules_text' => $this->nullableString($attributes['rules_text'] ?? null),
                    'submission_instructions' => $this->nullableString($attributes['submission_instructions'] ?? null),
                    'judging_mode' => $this->nullableString($attributes['judging_mode'] ?? null),
                    'linked_collection_id' => $this->normalizeCollectionId($group, $attributes['linked_collection_id'] ?? null),
                    'linked_project_id' => $this->normalizeProjectId($group, $attributes['linked_project_id'] ?? null),
                    'created_by_user_id' => (int) $actor->id,
                    'featured_artwork_id' => null,
                ]);
            });
        } catch (\Throwable $exception) {
            $this->media->deleteIfManaged($coverPath);
            throw $exception;
        }

        $this->history->record(
            $group,
            $actor,
            'challenge_created',
            sprintf('Created challenge "%s".', $challenge->title),
            'group_challenge',
            (int) $challenge->id,
            null,
            $challenge->only(['title', 'status', 'visibility', 'participation_scope'])
        );

        $this->activity->record(
            $group,
            $actor,
            'challenge_created',
            'group_challenge',
            (int) $challenge->id,
            sprintf('%s launched a new challenge draft: %s', $actor->name ?: $actor->username ?: 'A member', $challenge->title),
            $challenge->summary,
            $challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC ? 'public' : 'internal',
        );

        return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
    }

    public function update(GroupChallenge $challenge, User $actor, array $attributes): GroupChallenge
    {
        $coverPath = null;
        $oldCoverPath = $challenge->cover_path;
        $before = [
            ...$challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']),
            'outcomes_count' => $challenge->outcomes()->count(),
        ];

        try {
            DB::transaction(function () use ($challenge, $actor, $attributes, &$coverPath): void {
                if (($attributes['cover_file'] ?? null) instanceof UploadedFile) {
                    $coverPath = $this->media->storeUploadedEntityImage($challenge->group, $attributes['cover_file'], 'challenges');
                }

                $title = trim((string) ($attributes['title'] ?? $challenge->title));
                $featuredArtworkId = array_key_exists('featured_artwork_id', $attributes)
                    ? $this->normalizeArtworkId($challenge->group, $attributes['featured_artwork_id'])
                    : $challenge->featured_artwork_id;

                $challenge->fill([
                    'title' => $title,
                    'slug' => $title !== $challenge->title ? $this->makeUniqueSlug($title, (int) $challenge->id) : $challenge->slug,
                    'summary' => array_key_exists('summary', $attributes) ? $this->nullableString($attributes['summary']) : $challenge->summary,
                    'description' => array_key_exists('description', $attributes) ? $this->nullableString($attributes['description']) : $challenge->description,
                    'cover_path' => $coverPath ?: (array_key_exists('cover_path', $attributes) ? $this->nullableString($attributes['cover_path']) : $challenge->cover_path),
                    'visibility' => (string) ($attributes['visibility'] ?? $challenge->visibility),
                    'participation_scope' => (string) ($attributes['participation_scope'] ?? $challenge->participation_scope),
                    'status' => (string) ($attributes['status'] ?? $challenge->status),
                    'start_at' => $attributes['start_at'] ?? $challenge->start_at,
                    'end_at' => $attributes['end_at'] ?? $challenge->end_at,
                    'rules_text' => array_key_exists('rules_text', $attributes) ? $this->nullableString($attributes['rules_text']) : $challenge->rules_text,
                    'submission_instructions' => array_key_exists('submission_instructions', $attributes) ? $this->nullableString($attributes['submission_instructions']) : $challenge->submission_instructions,
                    'judging_mode' => array_key_exists('judging_mode', $attributes) ? $this->nullableString($attributes['judging_mode']) : $challenge->judging_mode,
                    'linked_collection_id' => array_key_exists('linked_collection_id', $attributes) ? $this->normalizeCollectionId($challenge->group, $attributes['linked_collection_id']) : $challenge->linked_collection_id,
                    'linked_project_id' => array_key_exists('linked_project_id', $attributes) ? $this->normalizeProjectId($challenge->group, $attributes['linked_project_id']) : $challenge->linked_project_id,
                    'featured_artwork_id' => $featuredArtworkId,
                ])->save();

                if (array_key_exists('outcomes', $attributes)) {
                    $canonicalWinnerArtworkId = $this->syncOutcomes($challenge, $actor, (array) ($attributes['outcomes'] ?? []), $featuredArtworkId);

                    if ((int) ($challenge->featured_artwork_id ?? 0) !== (int) ($canonicalWinnerArtworkId ?? 0)) {
                        $challenge->forceFill([
                            'featured_artwork_id' => $canonicalWinnerArtworkId,
                        ])->save();
                    }
                }
            });
        } catch (\Throwable $exception) {
            $this->media->deleteIfManaged($coverPath);
            throw $exception;
        }

        if ($coverPath !== null && $oldCoverPath !== $challenge->cover_path) {
            $this->media->deleteIfManaged($oldCoverPath);
        }

        $challenge->refresh();

        $this->history->record(
            $challenge->group,
            $actor,
            'challenge_updated',
            sprintf('Updated challenge "%s".', $challenge->title),
            'group_challenge',
            (int) $challenge->id,
            $before,
            [
                ...$challenge->only(['title', 'summary', 'description', 'visibility', 'participation_scope', 'status', 'rules_text', 'submission_instructions', 'judging_mode', 'linked_collection_id', 'linked_project_id', 'featured_artwork_id']),
                'outcomes_count' => $challenge->outcomes()->count(),
            ]
        );

        $this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge);

        return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
    }

    public function publish(GroupChallenge $challenge, User $actor): GroupChallenge
    {
        if ($challenge->group->status !== Group::LIFECYCLE_ACTIVE) {
            throw ValidationException::withMessages([
                'group' => 'Archived or suspended groups cannot publish challenges.',
            ]);
        }

        if (! $challenge->start_at || ! $challenge->end_at || $challenge->end_at->lt($challenge->start_at)) {
            throw ValidationException::withMessages([
                'timeline' => 'Challenges need a valid start and end date before they can be published.',
            ]);
        }

        $challenge->forceFill([
            'status' => $challenge->start_at->lte(now()) ? GroupChallenge::STATUS_ACTIVE : GroupChallenge::STATUS_PUBLISHED,
        ])->save();

        $this->history->record(
            $challenge->group,
            $actor,
            'challenge_published',
            sprintf('Published challenge "%s".', $challenge->title),
            'group_challenge',
            (int) $challenge->id,
            ['status' => GroupChallenge::STATUS_DRAFT],
            ['status' => $challenge->status]
        );

        $this->activity->record(
            $challenge->group,
            $actor,
            'challenge_published',
            'group_challenge',
            (int) $challenge->id,
            sprintf('%s launched the challenge %s', $challenge->group->name, $challenge->title),
            $challenge->summary,
            $challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC ? 'public' : 'internal',
        );

        if ($challenge->visibility === GroupChallenge::VISIBILITY_PUBLIC) {
            foreach ($challenge->group->follows()->with('user.profile')->get() as $follow) {
                if ($follow->user) {
                    $this->notifications->notifyGroupChallengePublished($follow->user, $actor, $challenge->group, $challenge);
                }
            }
        }

        $this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge);

        return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
    }

    public function attachArtwork(GroupChallenge $challenge, Artwork $artwork, User $actor): GroupChallenge
    {
        if (! $this->canAttachArtwork($challenge, $artwork, $actor)) {
            throw ValidationException::withMessages([
                'artwork' => 'This artwork is not eligible for this challenge.',
            ]);
        }

        GroupChallengeArtwork::query()->updateOrCreate(
            [
                'group_challenge_id' => (int) $challenge->id,
                'artwork_id' => (int) $artwork->id,
            ],
            [
                'submitted_by_user_id' => (int) $actor->id,
                'sort_order' => (int) $challenge->artworkLinks()->count(),
            ]
        );

        $this->history->record(
            $challenge->group,
            $actor,
            'challenge_artwork_attached',
            sprintf('Attached artwork "%s" to challenge "%s".', $artwork->title, $challenge->title),
            'group_challenge',
            (int) $challenge->id,
            null,
            ['artwork_id' => (int) $artwork->id]
        );

        $this->worldRewards->syncLinkedChallengeRewardsForChallenge($challenge);

        return $challenge->fresh(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);
    }

    public function publicListing(Group $group, ?User $viewer = null, int $limit = 12): array
    {
        return $this->visibleQuery($group, $viewer)
            ->with(['creator.profile', 'linkedCollection', 'linkedProject'])
            ->latest('start_at')
            ->limit($limit)
            ->get()
            ->map(fn (GroupChallenge $challenge): array => $this->mapPublicChallenge($challenge))
            ->values()
            ->all();
    }

    public function activeChallenge(Group $group, ?User $viewer = null): ?array
    {
        $challenge = $this->visibleQuery($group, $viewer)
            ->with(['creator.profile', 'linkedCollection', 'linkedProject'])
            ->whereIn('status', [GroupChallenge::STATUS_ACTIVE, GroupChallenge::STATUS_PUBLISHED])
            ->orderByRaw("CASE status WHEN 'active' THEN 0 ELSE 1 END")
            ->orderBy('start_at')
            ->first();

        return $challenge ? $this->mapPublicChallenge($challenge) : null;
    }

    public function studioListing(Group $group, array $filters = []): array
    {
        $bucket = (string) ($filters['bucket'] ?? 'all');
        $page = max(1, (int) ($filters['page'] ?? 1));
        $perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50);

        $query = GroupChallenge::query()
            ->with(['creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile'])
            ->where('group_id', $group->id);

        if ($bucket !== 'all') {
            $query->where('status', $bucket);
        }

        $paginator = $query->latest('updated_at')->paginate($perPage, ['*'], 'page', $page);

        return [
            'items' => collect($paginator->items())->map(fn (GroupChallenge $challenge): array => $this->mapStudioChallenge($challenge))->values()->all(),
            'meta' => [
                'current_page' => $paginator->currentPage(),
                'last_page' => $paginator->lastPage(),
                'per_page' => $paginator->perPage(),
                'total' => $paginator->total(),
            ],
            'filters' => ['bucket' => $bucket],
            'bucket_options' => [
                ['value' => 'all', 'label' => 'All'],
                ['value' => GroupChallenge::STATUS_DRAFT, 'label' => 'Drafts'],
                ['value' => GroupChallenge::STATUS_PUBLISHED, 'label' => 'Published'],
                ['value' => GroupChallenge::STATUS_ACTIVE, 'label' => 'Active'],
                ['value' => GroupChallenge::STATUS_ENDED, 'label' => 'Ended'],
                ['value' => GroupChallenge::STATUS_ARCHIVED, 'label' => 'Archived'],
            ],
        ];
    }

    public function detailPayload(GroupChallenge $challenge, ?User $viewer = null): array
    {
        $challenge->loadMissing(['group', 'creator.profile', 'linkedCollection', 'linkedProject', 'featuredArtwork.primaryAuthor.profile', 'artworks.primaryAuthor.profile', 'outcomes.artwork.user.profile']);

        $primaryWinnerArtwork = $this->primaryWinnerArtwork($challenge) ?? $challenge->featuredArtwork;

        return array_merge($this->mapPublicChallenge($challenge), [
            'description' => $challenge->description,
            'rules_text' => $challenge->rules_text,
            'submission_instructions' => $challenge->submission_instructions,
            'featured_artwork' => $primaryWinnerArtwork ? [
                'id' => (int) $primaryWinnerArtwork->id,
                'title' => $primaryWinnerArtwork->title,
                'url' => route('art.show', ['id' => $primaryWinnerArtwork->id, 'slug' => $primaryWinnerArtwork->slug ?: $primaryWinnerArtwork->id]),
            ] : null,
            'artworks' => $challenge->artworks->map(fn (Artwork $artwork): array => [
                'id' => (int) $artwork->id,
                'title' => (string) $artwork->title,
                'thumb' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md'),
                'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]),
            ])->values()->all(),
            'outcomes' => $challenge->outcomes->map(fn (GroupChallengeOutcome $outcome): array => $this->mapOutcomeForEditor($outcome))->values()->all(),
            'outcome_sections' => $this->outcomeSectionsPayload($challenge),
            'outcome_counts' => $this->outcomeCounts($challenge),
        ]);
    }

    public function mapPublicChallenge(GroupChallenge $challenge): array
    {
        $challenge->loadMissing(['group', 'outcomes']);

        return [
            'id' => (int) $challenge->id,
            'title' => (string) $challenge->title,
            'slug' => (string) $challenge->slug,
            'summary' => $challenge->summary,
            'status' => (string) $challenge->status,
            'visibility' => (string) $challenge->visibility,
            'participation_scope' => (string) $challenge->participation_scope,
            'cover_url' => $challenge->coverUrl(),
            'start_at' => $challenge->start_at?->toISOString(),
            'end_at' => $challenge->end_at?->toISOString(),
            'rules_text' => $challenge->rules_text,
            'entry_count' => (int) $challenge->artworkLinks()->count(),
            'outcome_counts' => $this->outcomeCounts($challenge),
            'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]),
        ];
    }

    public function mapStudioChallenge(GroupChallenge $challenge): array
    {
        return array_merge($this->mapPublicChallenge($challenge), [
            'description' => $challenge->description,
            'urls' => [
                'public' => $challenge->visibility !== GroupChallenge::VISIBILITY_PRIVATE ? route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]) : null,
                'edit' => route('studio.groups.challenges.edit', ['group' => $challenge->group, 'challenge' => $challenge]),
                'publish' => route('studio.groups.challenges.publish', ['group' => $challenge->group, 'challenge' => $challenge]),
                'attach_artwork' => route('studio.groups.challenges.attach-artwork', ['group' => $challenge->group, 'challenge' => $challenge]),
            ],
        ]);
    }

    private function visibleQuery(Group $group, ?User $viewer = null)
    {
        return GroupChallenge::query()
            ->where('group_id', $group->id)
            ->when(! ($viewer && $group->canViewStudio($viewer)), function ($query): void {
                $query->where('visibility', GroupChallenge::VISIBILITY_PUBLIC)
                    ->where('status', '!=', GroupChallenge::STATUS_DRAFT);
            });
    }

    private function canAttachArtwork(GroupChallenge $challenge, Artwork $artwork, User $actor): bool
    {
        if ($challenge->participation_scope === GroupChallenge::PARTICIPATION_PUBLIC) {
            return (int) $artwork->user_id === (int) $actor->id
                || (int) ($artwork->uploaded_by_user_id ?? 0) === (int) $actor->id
                || (int) ($artwork->primary_author_user_id ?? 0) === (int) $actor->id
                || ((int) $artwork->group_id === (int) $challenge->group_id && $challenge->group->hasActiveMember($actor));
        }

        return $challenge->group->hasActiveMember($actor) && (int) $artwork->group_id === (int) $challenge->group_id;
    }

    private function syncOutcomes(GroupChallenge $challenge, User $actor, array $rows, ?int $fallbackFeaturedArtworkId = null): ?int
    {
        $normalized = collect($rows)
            ->values()
            ->map(function (mixed $row, int $index): ?array {
                if (! is_array($row)) {
                    return null;
                }

                $artworkId = (int) ($row['artwork_id'] ?? 0);
                $outcomeType = trim((string) ($row['outcome_type'] ?? ''));

                if ($artworkId < 1 || $outcomeType === '') {
                    return null;
                }

                return [
                    'artwork_id' => $artworkId,
                    'outcome_type' => $outcomeType,
                    'position' => isset($row['position']) && (int) $row['position'] > 0 ? (int) $row['position'] : null,
                    'sort_order' => max(0, (int) ($row['sort_order'] ?? $index)),
                    'title_override' => $this->nullableString($row['title_override'] ?? null),
                    'note' => $this->nullableString($row['note'] ?? null),
                ];
            })
            ->filter()
            ->values();

        $pairs = $normalized
            ->map(fn (array $row): string => $row['artwork_id'] . '|' . $row['outcome_type']);

        if ($pairs->count() !== $pairs->unique()->count()) {
            throw ValidationException::withMessages([
                'outcomes' => 'Each artwork can only receive a given outcome type once per challenge.',
            ]);
        }

        $artworkIds = $normalized->pluck('artwork_id')->unique()->values();
        $validArtworkIds = $artworkIds->isEmpty()
            ? collect()
            : $challenge->artworkLinks()
                ->whereIn('artwork_id', $artworkIds->all())
                ->pluck('artwork_id')
                ->map(fn ($id): int => (int) $id)
                ->values();

        if ($artworkIds->diff($validArtworkIds)->isNotEmpty()) {
            throw ValidationException::withMessages([
                'outcomes' => 'Challenge outcomes can only reference artworks already attached as challenge entries.',
            ]);
        }

        $artworksById = $artworkIds->isEmpty()
            ? collect()
            : Artwork::query()
                ->whereIn('id', $artworkIds->all())
                ->get(['id', 'user_id'])
                ->keyBy('id');

        GroupChallengeOutcome::query()
            ->where('group_challenge_id', (int) $challenge->id)
            ->delete();

        if ($normalized->isEmpty()) {
            return $fallbackFeaturedArtworkId;
        }

        $challenge->outcomes()->createMany($normalized->map(function (array $row) use ($actor, $artworksById): array {
            /** @var Artwork|null $artwork */
            $artwork = $artworksById->get($row['artwork_id']);

            return [
                'artwork_id' => $row['artwork_id'],
                'user_id' => (int) ($artwork?->user_id ?? 0) > 0 ? (int) $artwork->user_id : null,
                'outcome_type' => $row['outcome_type'],
                'position' => $row['position'],
                'sort_order' => $row['sort_order'],
                'title_override' => $row['title_override'],
                'note' => $row['note'],
                'awarded_by_user_id' => (int) $actor->id,
                'awarded_at' => now(),
            ];
        })->all());

        $winner = $normalized
            ->sortBy([
                fn (array $row): int => $row['outcome_type'] === GroupChallengeOutcome::TYPE_WINNER ? 0 : 1,
                fn (array $row): int => (int) $row['sort_order'],
                fn (array $row): int => (int) ($row['position'] ?? PHP_INT_MAX),
            ])
            ->first(fn (array $row): bool => $row['outcome_type'] === GroupChallengeOutcome::TYPE_WINNER);

        return $winner['artwork_id'] ?? $fallbackFeaturedArtworkId;
    }

    private function primaryWinnerArtwork(GroupChallenge $challenge): ?Artwork
    {
        /** @var GroupChallengeOutcome|null $winner */
        $winner = $challenge->outcomes
            ->first(fn (GroupChallengeOutcome $outcome): bool => $outcome->outcome_type === GroupChallengeOutcome::TYPE_WINNER && $outcome->artwork !== null);

        return $winner?->artwork;
    }

    private function outcomeCounts(GroupChallenge $challenge): array
    {
        $challenge->loadMissing('outcomes');

        return collect(GroupChallengeOutcome::supportedTypes())
            ->mapWithKeys(fn (string $type): array => [$type => $challenge->outcomes->where('outcome_type', $type)->count()])
            ->all();
    }

    private function outcomeSectionsPayload(GroupChallenge $challenge): array
    {
        $challenge->loadMissing(['outcomes.artwork.user.profile']);

        $sections = [];

        foreach (GroupChallengeOutcome::supportedTypes() as $type) {
            $items = $challenge->outcomes
                ->where('outcome_type', $type)
                ->values();

            if ($items->isEmpty()) {
                continue;
            }

            $sections[$type] = [
                'type' => $type,
                'label' => $this->outcomeSectionLabel($type, $items->count()),
                'items' => $items->map(fn (GroupChallengeOutcome $outcome): array => $this->mapOutcomeItem($outcome))->all(),
            ];
        }

        return $sections;
    }

    private function outcomeSectionLabel(string $type, int $count): string
    {
        return match ($type) {
            GroupChallengeOutcome::TYPE_WINNER => $count === 1 ? 'Winner' : 'Winners',
            GroupChallengeOutcome::TYPE_FINALIST => 'Finalists',
            GroupChallengeOutcome::TYPE_RUNNER_UP => $count === 1 ? 'Runner-up' : 'Runner-up',
            GroupChallengeOutcome::TYPE_HONORABLE_MENTION => 'Honorable Mentions',
            GroupChallengeOutcome::TYPE_FEATURED => 'Featured Entries',
            default => GroupChallengeOutcome::labelForType($type),
        };
    }

    private function mapOutcomeForEditor(GroupChallengeOutcome $outcome): array
    {
        return [
            'id' => (int) $outcome->id,
            'artwork_id' => (int) $outcome->artwork_id,
            'outcome_type' => (string) $outcome->outcome_type,
            'position' => $outcome->position,
            'sort_order' => (int) $outcome->sort_order,
            'title_override' => (string) ($outcome->title_override ?? ''),
            'note' => (string) ($outcome->note ?? ''),
            'artwork_title' => (string) ($outcome->artwork?->title ?? ''),
        ];
    }

    private function mapOutcomeItem(GroupChallengeOutcome $outcome): array
    {
        $artwork = $outcome->artwork;
        $creator = $artwork?->user;
        $statusLabel = $outcome->title_override ?: GroupChallengeOutcome::labelForType((string) $outcome->outcome_type);

        return [
            'id' => (int) $outcome->id,
            'artwork_id' => (int) ($artwork?->id ?? 0),
            'outcome_type' => (string) $outcome->outcome_type,
            'position' => $outcome->position,
            'title' => (string) ($artwork?->title ?: 'Untitled artwork'),
            'subtitle' => (string) ($creator?->name ?: $creator?->username ?: ''),
            'description' => (string) ($outcome->note ?: Str::limit(trim(strip_tags((string) ($artwork?->description ?? ''))), 140)),
            'url' => $artwork ? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]) : null,
            'image' => $artwork ? (ThumbnailPresenter::present($artwork, 'md')['url'] ?? $artwork->thumbUrl('md')) : null,
            'status' => (string) $outcome->outcome_type,
            'status_label' => $statusLabel,
            'context_label' => 'Challenge outcome',
            'meta' => array_values(array_filter([
                $outcome->position ? 'Place ' . $outcome->position : null,
                $outcome->awarded_at?->format('M j, Y'),
            ])),
        ];
    }

    private function makeUniqueSlug(string $source, ?int $ignoreId = null): string
    {
        $base = Str::slug(Str::limit($source, 150, '')) ?: 'challenge';
        $slug = $base;
        $suffix = 2;

        while (GroupChallenge::query()->where('slug', $slug)->when($ignoreId !== null, fn ($query) => $query->where('id', '!=', $ignoreId))->exists()) {
            $slug = Str::limit($base, 180, '') . '-' . $suffix;
            $suffix++;
        }

        return $slug;
    }

    private function normalizeCollectionId(Group $group, mixed $collectionId): ?int
    {
        $id = (int) $collectionId;

        return $id > 0 && $group->collections()->where('id', $id)->exists() ? $id : null;
    }

    private function normalizeProjectId(Group $group, mixed $projectId): ?int
    {
        $id = (int) $projectId;

        return $id > 0 && $group->projects()->where('id', $id)->exists() ? $id : null;
    }

    private function normalizeArtworkId(Group $group, mixed $artworkId): ?int
    {
        $id = (int) $artworkId;

        return $id > 0 && $group->artworks()->where('id', $id)->whereNull('deleted_at')->exists() ? $id : null;
    }

    private function nullableString(mixed $value): ?string
    {
        $trimmed = trim((string) $value);

        return $trimmed !== '' ? $trimmed : null;
    }
}