<?php

namespace App\Http\Controllers\Api;

use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationDeliveryStatus;
use App\Enums\GuestNotificationState;
use App\Enums\GuestNotificationType;
use App\Enums\PhotoLiveStatus;
use App\Events\GuestPhotoUploaded;
use App\Jobs\ProcessPhotoSecurityScan;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Models\EventMediaAsset;
use App\Models\GuestNotification;
use App\Models\GuestPolicySetting;
use App\Models\Photo;
use App\Models\PhotoShareLink;
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
use App\Services\EventJoinTokenService;
use App\Services\EventTasksCacheService;
use App\Services\GuestNotificationService;
use App\Services\Packages\PackageLimitEvaluator;
use App\Services\Packages\PackageUsageTracker;
use App\Services\PushSubscriptionService;
use App\Services\Storage\EventStorageManager;
use App\Support\ApiError;
use App\Support\ImageHelper;
use App\Support\WatermarkConfigResolver;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class EventPublicController extends BaseController
{
    private const SIGNED_URL_TTL_SECONDS = 1800;

    private const BRANDING_SIGNED_TTL_SECONDS = 3600;

    private ?GuestPolicySetting $guestPolicy = null;

    public function __construct(
        private readonly EventJoinTokenService $joinTokenService,
        private readonly EventStorageManager $eventStorageManager,
        private readonly JoinTokenAnalyticsRecorder $analyticsRecorder,
        private readonly PackageLimitEvaluator $packageLimitEvaluator,
        private readonly PackageUsageTracker $packageUsageTracker,
        private readonly EventTasksCacheService $eventTasksCache,
        private readonly GuestNotificationService $guestNotificationService,
        private readonly PushSubscriptionService $pushSubscriptions,
    ) {}

    /**
     * @return JsonResponse|array{0: object, 1: EventJoinToken}
     */
    private function resolvePublishedEvent(Request $request, string $token, array $columns = ['id']): JsonResponse|array
    {
        $rateLimiterKey = sprintf('event:token:%s:%s', $token, $request->ip());

        $joinToken = $this->joinTokenService->findToken($token, true);

        if (! $joinToken) {
            return $this->handleTokenFailure(
                $request,
                $rateLimiterKey,
                'invalid_token',
                Response::HTTP_NOT_FOUND,
                [
                    'token' => Str::limit($token, 12),
                ],
                $token
            );
        }

        if ($joinToken->revoked_at !== null) {
            return $this->handleTokenFailure(
                $request,
                $rateLimiterKey,
                'token_revoked',
                Response::HTTP_GONE,
                [
                    'token' => Str::limit($token, 12),
                ],
                $token,
                $joinToken
            );
        }

        if ($joinToken->expires_at !== null) {
            $expiresAt = CarbonImmutable::parse($joinToken->expires_at);

            if ($expiresAt->isPast()) {
                return $this->handleTokenFailure(
                    $request,
                    $rateLimiterKey,
                    'token_expired',
                    Response::HTTP_GONE,
                    [
                        'token' => Str::limit($token, 12),
                        'expired_at' => $expiresAt->toAtomString(),
                    ],
                    $token,
                    $joinToken
                );
            }
        }

        if ($joinToken->usage_limit !== null && $joinToken->usage_count >= $joinToken->usage_limit) {
            return $this->handleTokenFailure(
                $request,
                $rateLimiterKey,
                'token_expired',
                Response::HTTP_GONE,
                [
                    'token' => Str::limit($token, 12),
                    'usage_count' => $joinToken->usage_count,
                    'usage_limit' => $joinToken->usage_limit,
                ],
                $token,
                $joinToken
            );
        }

        $columns = array_unique(array_merge($columns, ['status']));

        $event = DB::table('events')
            ->where('id', $joinToken->event_id)
            ->first($columns);

        if (! $event) {
            return $this->handleTokenFailure(
                $request,
                $rateLimiterKey,
                'invalid_token',
                Response::HTTP_NOT_FOUND,
                [
                    'token' => Str::limit($token, 12),
                    'reason' => 'event_missing',
                ],
                $token,
                $joinToken
            );
        }

        if (($event->status ?? null) !== 'published') {
            Log::notice('Join token event not public', [
                'token' => Str::limit($token, 12),
                'event_id' => $event->id ?? null,
                'ip' => $request->ip(),
            ]);

            $this->recordTokenEvent(
                $joinToken,
                $request,
                'event_not_public',
                [
                    'token' => Str::limit($token, 12),
                    'event_id' => $event->id ?? null,
                ],
                $token,
                Response::HTTP_FORBIDDEN
            );

            return ApiError::response(
                'event_not_public',
                'Event Not Public',
                'This event is not publicly accessible.',
                Response::HTTP_FORBIDDEN,
                [
                    'token' => Str::limit($token, 12),
                    'event_id' => $event->id ?? null,
                ]
            );
        }

        RateLimiter::clear($rateLimiterKey);

        if (isset($event->status)) {
            unset($event->status);
        }

        $this->recordTokenEvent(
            $joinToken,
            $request,
            'access_granted',
            [
                'event_id' => $event->id ?? null,
            ],
            $token,
            Response::HTTP_OK
        );

        $throttleResponse = $this->enforceAccessThrottle($joinToken, $request, $token);
        if ($throttleResponse instanceof JsonResponse) {
            return $throttleResponse;
        }

        return [$event, $joinToken];
    }

    /**
     * @return JsonResponse|array{0: Event, 1: EventJoinToken}
     */
    private function resolveGalleryEvent(Request $request, string $token): JsonResponse|array
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$eventRecord, $joinToken] = $result;

        $event = Event::with(['tenant', 'eventPackage.package', 'eventType'])->find($eventRecord->id);

        if (! $event) {
            return ApiError::response(
                'event_not_found',
                'Event Not Found',
                'The event associated with this gallery could not be located.',
                Response::HTTP_NOT_FOUND,
                [
                    'token' => Str::limit($token, 12),
                ]
            );
        }

        $expiresAt = optional($event->eventPackage)->gallery_expires_at;

        if ($expiresAt instanceof Carbon && $expiresAt->isPast()) {
            $this->recordTokenEvent(
                $joinToken,
                $request,
                'gallery_expired',
                [
                    'event_id' => $event->id,
                    'expired_at' => $expiresAt->toIso8601String(),
                ],
                $token,
                Response::HTTP_GONE
            );

            return ApiError::response(
                'gallery_expired',
                'Gallery Expired',
                'The gallery is no longer available for this event.',
                Response::HTTP_GONE,
                [
                    'event_id' => $event->id,
                    'expired_at' => $expiresAt->toIso8601String(),
                ]
            );
        }

        $this->recordTokenEvent(
            $joinToken,
            $request,
            'gallery_access_granted',
            [
                'event_id' => $event->id,
            ],
            $token,
            Response::HTTP_OK
        );

        return [$event, $joinToken];
    }

    private function handleTokenFailure(
        Request $request,
        string $rateLimiterKey,
        string $code,
        int $status,
        array $context = [],
        ?string $rawToken = null,
        ?EventJoinToken $joinToken = null
    ): JsonResponse {
        $policy = $this->guestPolicy();
        $failureLimit = max(1, (int) ($policy->join_token_failure_limit ?? config('join_tokens.failure_limit', 10)));
        $failureDecay = max(1, (int) ($policy->join_token_failure_decay_minutes ?? config('join_tokens.failure_decay_minutes', 5)));

        if (RateLimiter::tooManyAttempts($rateLimiterKey, $failureLimit)) {
            Log::warning('Join token rate limit exceeded', array_merge([
                'ip' => $request->ip(),
            ], $context));

            $this->recordTokenEvent(
                $joinToken,
                $request,
                'token_rate_limited',
                array_merge($context, ['rate_limiter_key' => $rateLimiterKey]),
                $rawToken,
                Response::HTTP_TOO_MANY_REQUESTS
            );

            return ApiError::response(
                'token_rate_limited',
                $this->tokenErrorTitle('token_rate_limited'),
                __('api.join_tokens.invalid_attempts_message'),
                Response::HTTP_TOO_MANY_REQUESTS,
                array_merge($context, ['rate_limiter_key' => $rateLimiterKey])
            );
        }

        RateLimiter::hit($rateLimiterKey, $failureDecay * 60);

        Log::notice('Join token access denied', array_merge([
            'code' => $code,
            'ip' => $request->ip(),
        ], $context));

        $this->recordTokenEvent(
            $joinToken,
            $request,
            $code,
            array_merge($context, ['rate_limiter_key' => $rateLimiterKey]),
            $rawToken,
            $status
        );

        return ApiError::response(
            $code,
            $this->tokenErrorTitle($code),
            $this->tokenErrorMessage($code),
            $status,
            $context
        );
    }

    private function tokenErrorMessage(string $code): string
    {
        return match ($code) {
            'invalid_token' => __('api.join_tokens.invalid_message'),
            'token_expired' => __('api.join_tokens.expired_message'),
            'token_revoked' => __('api.join_tokens.revoked_message'),
            'token_rate_limited' => __('api.join_tokens.rate_limited_message'),
            default => __('api.join_tokens.default_message'),
        };
    }

    private function tokenErrorTitle(string $code): string
    {
        return match ($code) {
            'invalid_token' => __('api.join_tokens.invalid_title'),
            'token_expired' => __('api.join_tokens.expired_title'),
            'token_revoked' => __('api.join_tokens.revoked_title'),
            'token_rate_limited' => __('api.join_tokens.rate_limited_title'),
            default => __('api.join_tokens.default_title'),
        };
    }

    private function recordTokenEvent(
        ?EventJoinToken $joinToken,
        Request $request,
        string $eventType,
        array $context = [],
        ?string $rawToken = null,
        ?int $status = null
    ): void {
        $this->analyticsRecorder->record($joinToken, $eventType, $request, $context, $rawToken, $status);
    }

    private function enforceAccessThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse
    {
        $policy = $this->guestPolicy();
        $limit = (int) ($policy->join_token_access_limit ?? config('join_tokens.access_limit', 0));
        if ($limit <= 0) {
            return null;
        }

        $decay = max(1, (int) ($policy->join_token_access_decay_minutes ?? config('join_tokens.access_decay_minutes', 1)));
        $key = sprintf('event:token:access:%s:%s', $joinToken->getKey(), $request->ip());

        if (RateLimiter::tooManyAttempts($key, $limit)) {
            $this->recordTokenEvent(
                $joinToken,
                $request,
                'access_rate_limited',
                [
                    'limit' => $limit,
                    'decay_minutes' => $decay,
                ],
                $rawToken,
                Response::HTTP_TOO_MANY_REQUESTS
            );

            return ApiError::response(
                'access_rate_limited',
                'Too Many Requests',
                'Too many requests. Please slow down.',
                Response::HTTP_TOO_MANY_REQUESTS,
                [
                    'limit' => $limit,
                    'decay_minutes' => $decay,
                ]
            );
        }

        RateLimiter::hit($key, $decay * 60);

        return null;
    }

    private function enforceDownloadThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse
    {
        $policy = $this->guestPolicy();
        $limit = (int) ($policy->join_token_download_limit ?? config('join_tokens.download_limit', 0));
        if ($limit <= 0) {
            return null;
        }

        $decay = max(1, (int) ($policy->join_token_download_decay_minutes ?? config('join_tokens.download_decay_minutes', 1)));
        $key = sprintf('event:token:download:%s:%s', $joinToken->getKey(), $request->ip());

        if (RateLimiter::tooManyAttempts($key, $limit)) {
            $this->recordTokenEvent(
                $joinToken,
                $request,
                'download_rate_limited',
                [
                    'limit' => $limit,
                    'decay_minutes' => $decay,
                ],
                $rawToken,
                Response::HTTP_TOO_MANY_REQUESTS
            );

            return ApiError::response(
                'download_rate_limited',
                'Download Rate Limited',
                'Download rate limit exceeded. Please wait a moment.',
                Response::HTTP_TOO_MANY_REQUESTS,
                [
                    'limit' => $limit,
                    'decay_minutes' => $decay,
                ]
            );
        }

        RateLimiter::hit($key, $decay * 60);

        return null;
    }

    private function getLocalized($value, $locale, $default = '')
    {
        if (is_string($value) && json_decode($value) !== null) {
            $data = json_decode($value, true);

            return $data[$locale] ?? $data['de'] ?? $default;
        }

        return $value ?: $default;
    }

    /**
     * @param  array{tasks: array<int, array<string, mixed>>, hash: string}  $payload
     */
    private function buildLocalizedTasksPayload(int $eventId, string $locale, ?string $eventDefaultLocale): array
    {
        $fallbacks = $this->localeFallbackChain($locale, $eventDefaultLocale);

        $rows = DB::table('tasks')
            ->leftJoin('task_collection_task', 'tasks.id', '=', 'task_collection_task.task_id')
            ->leftJoin('event_task_collection', 'task_collection_task.task_collection_id', '=', 'event_task_collection.task_collection_id')
            ->leftJoin('event_task', function ($join) use ($eventId) {
                $join->on('tasks.id', '=', 'event_task.task_id')
                    ->where('event_task.event_id', '=', $eventId);
            })
            ->leftJoin('emotions', 'tasks.emotion_id', '=', 'emotions.id')
            ->where(function ($query) use ($eventId) {
                $query->where('event_task_collection.event_id', $eventId)
                    ->orWhereNotNull('event_task.event_id');
            })
            ->select([
                'tasks.id',
                'tasks.title',
                'tasks.description',
                'tasks.example_text',
                'tasks.emotion_id',
                'tasks.sort_order',
                'event_task.sort_order as direct_sort_order',
                'event_task_collection.sort_order as collection_sort_order',
                'emotions.name as emotion_name',
                'emotions.id as emotion_lookup_id',
            ])
            ->orderByRaw('COALESCE(event_task_collection.sort_order, event_task.sort_order, tasks.sort_order, 0)')
            ->orderBy('tasks.sort_order')
            ->distinct('tasks.id')
            ->get();

        $tasks = $rows->map(function ($row) use ($fallbacks) {
            $emotion = null;

            if ($row->emotion_id) {
                $emotionName = $this->firstLocalizedValue($row->emotion_name, $fallbacks, 'Unbekannte Emotion');

                if ($emotionName !== '') {
                    $emotionId = (int) ($row->emotion_lookup_id ?? $row->emotion_id);
                    $emotion = [
                        'slug' => 'emotion-'.$emotionId,
                        'name' => $emotionName,
                    ];
                }
            }

            return [
                'id' => (int) $row->id,
                'title' => $this->firstLocalizedValue($row->title, $fallbacks, 'Unbenannte Aufgabe'),
                'description' => $this->firstLocalizedValue($row->description, $fallbacks, ''),
                'instructions' => $this->firstLocalizedValue($row->example_text, $fallbacks, ''),
                'duration' => 3,
                'is_completed' => false,
                'emotion' => $emotion,
            ];
        })->values()->all();

        return [
            'tasks' => $tasks,
            'hash' => sha1(json_encode($tasks)),
        ];
    }

    private function resolveGuestLocale(Request $request, object $event): array
    {
        $supported = $this->eventTasksCache->supportedLocales();

        $queryLocale = $this->normalizeLocale($request->query('locale', $request->query('lang')), $supported);
        if ($queryLocale) {
            return [$queryLocale, 'query'];
        }

        $headerLocale = $this->normalizeLocale($request->header('X-Locale'), $supported);
        if ($headerLocale) {
            return [$headerLocale, 'header'];
        }

        $acceptedLocale = $this->normalizeLocale($request->getPreferredLanguage($supported), $supported);
        if ($acceptedLocale) {
            return [$acceptedLocale, 'accept-language'];
        }

        $eventLocale = $this->normalizeLocale($event->default_locale ?? null, $supported);
        if ($eventLocale) {
            return [$eventLocale, 'event'];
        }

        $fallbackLocale = $this->normalizeLocale(config('app.fallback_locale'), $supported);
        if ($fallbackLocale) {
            return [$fallbackLocale, 'fallback'];
        }

        return [$supported[0] ?? 'de', 'fallback'];
    }

    private function localeFallbackChain(string $preferred, ?string $eventDefault): array
    {
        $chain = array_filter([
            $preferred,
            $this->normalizeLocale($eventDefault),
            $this->normalizeLocale(config('app.fallback_locale')),
            'de',
            'en',
        ]);

        return array_values(array_unique($chain));
    }

    private function normalizeLocale(?string $value, ?array $allowed = null): ?string
    {
        if ($value === null) {
            return null;
        }

        $normalized = Str::of($value)
            ->lower()
            ->replace('_', '-')
            ->before('-')
            ->trim()
            ->value();

        if ($normalized === '') {
            return null;
        }

        if ($allowed !== null && ! in_array($normalized, $allowed, true)) {
            return null;
        }

        return $normalized;
    }

    private function decodeTranslations(mixed $value): array
    {
        if (is_array($value)) {
            return $value;
        }

        if (is_string($value) && $value !== '') {
            $decoded = json_decode($value, true);

            if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
                return $decoded;
            }
        }

        return [];
    }

    private function firstLocalizedValue(mixed $value, array $locales, string $default = ''): string
    {
        $translations = $this->decodeTranslations($value);

        foreach ($locales as $locale) {
            $candidate = $translations[$locale] ?? null;

            if (is_string($candidate) && trim($candidate) !== '') {
                return $candidate;
            }
        }

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

        if (! empty($translations)) {
            $first = reset($translations);
            if (is_string($first) && trim($first) !== '') {
                return $first;
            }
        }

        return $default;
    }

    private function determineGuestIdentifier(Request $request): ?string
    {
        $guestNameParam = trim((string) $request->query('guest_name', ''));
        $deviceIdHeader = (string) $request->headers->get('X-Device-Id', '');
        $candidate = $guestNameParam !== '' ? $guestNameParam : $deviceIdHeader;
        $normalized = $this->normalizeGuestIdentifier($candidate);

        if ($normalized === '') {
            return null;
        }

        return $normalized;
    }

    private function normalizeGuestIdentifier(string $value): string
    {
        $cleaned = preg_replace('/[^\p{L}\p{N}\s_\-]/u', '', $value) ?? '';
        $trimmed = trim(mb_substr($cleaned, 0, 120));

        return $trimmed;
    }

    /**
     * @return array<string, mixed>
     */
    private function normalizeSettings(array|string|null $settings): array
    {
        if (is_array($settings)) {
            return $settings;
        }

        if (is_string($settings)) {
            $decoded = json_decode($settings, true);
            if (is_array($decoded)) {
                return $decoded;
            }
        }

        return [];
    }

    private function buildDeterministicSeed(?string $identifier): int
    {
        if ($identifier === null || trim($identifier) === '') {
            return random_int(1, PHP_INT_MAX);
        }

        $hash = substr(sha1($identifier), 0, 8);
        $seed = hexdec($hash);

        if (! is_numeric($seed) || $seed <= 0) {
            $seed = abs(crc32($identifier));
        }

        return max(1, (int) $seed);
    }

    private function buildAchievementsPayload(int $eventId, string $token, ?string $guestIdentifier, array $fallbacks): array
    {
        $totalPhotos = (int) DB::table('photos')->where('event_id', $eventId)->count();
        $uniqueGuests = (int) DB::table('photos')->where('event_id', $eventId)->distinct('guest_name')->count('guest_name');
        $tasksSolved = (int) DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count();
        $likesTotal = (int) DB::table('photos')->where('event_id', $eventId)->sum('likes_count');

        $summary = [
            'total_photos' => $totalPhotos,
            'unique_guests' => $uniqueGuests,
            'tasks_solved' => $tasksSolved,
            'likes_total' => $likesTotal,
        ];

        $personal = null;
        if ($guestIdentifier) {
            $personalPhotos = (int) DB::table('photos')
                ->where('event_id', $eventId)
                ->where('guest_name', $guestIdentifier)
                ->count();

            $personalTasks = (int) DB::table('photos')
                ->where('event_id', $eventId)
                ->where('guest_name', $guestIdentifier)
                ->whereNotNull('task_id')
                ->distinct('task_id')
                ->count('task_id');

            $personalLikes = (int) DB::table('photos')
                ->where('event_id', $eventId)
                ->where('guest_name', $guestIdentifier)
                ->sum('likes_count');

            $badgeBlueprints = [
                ['id' => 'first_photo', 'title' => 'Erstes Foto', 'description' => 'Lade dein erstes Foto hoch.', 'target' => 1, 'metric' => 'photos'],
                ['id' => 'five_photos', 'title' => 'Fotoflair', 'description' => 'Fuenf Fotos hochgeladen.', 'target' => 5, 'metric' => 'photos'],
                ['id' => 'first_task', 'title' => 'Aufgabenstarter', 'description' => 'Erste Aufgabe erfuellt.', 'target' => 1, 'metric' => 'tasks'],
                ['id' => 'task_master', 'title' => 'Aufgabenmeister', 'description' => 'Fuenf Aufgaben erfuellt.', 'target' => 5, 'metric' => 'tasks'],
                ['id' => 'crowd_pleaser', 'title' => 'Publikumsliebling', 'description' => 'Sammle zwanzig Likes.', 'target' => 20, 'metric' => 'likes'],
            ];

            $personalBadges = [];
            foreach ($badgeBlueprints as $badge) {
                $value = match ($badge['metric']) {
                    'photos' => $personalPhotos,
                    'tasks' => $personalTasks,
                    default => $personalLikes,
                };

                $personalBadges[] = [
                    'id' => $badge['id'],
                    'title' => $badge['title'],
                    'description' => $badge['description'],
                    'earned' => $value >= $badge['target'],
                    'progress' => $value,
                    'target' => $badge['target'],
                ];
            }

            $personal = [
                'guest_name' => $guestIdentifier,
                'photos' => $personalPhotos,
                'tasks' => $personalTasks,
                'likes' => $personalLikes,
                'badges' => $personalBadges,
            ];
        }

        $topUploads = DB::table('photos')
            ->select('guest_name', DB::raw('COUNT(*) as total'), DB::raw('SUM(likes_count) as likes'))
            ->where('event_id', $eventId)
            ->groupBy('guest_name')
            ->orderByDesc('total')
            ->limit(5)
            ->get()
            ->map(fn ($row) => [
                'guest' => $row->guest_name,
                'photos' => (int) $row->total,
                'likes' => (int) $row->likes,
            ])
            ->values();

        $topLikes = DB::table('photos')
            ->select('guest_name', DB::raw('SUM(likes_count) as likes'), DB::raw('COUNT(*) as total'))
            ->where('event_id', $eventId)
            ->groupBy('guest_name')
            ->orderByDesc('likes')
            ->limit(5)
            ->get()
            ->map(fn ($row) => [
                'guest' => $row->guest_name,
                'likes' => (int) $row->likes,
                'photos' => (int) $row->total,
            ])
            ->values();

        $topPhotoRow = DB::table('photos')
            ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
            ->where('photos.event_id', $eventId)
            ->where('photos.status', 'approved')
            ->orderByDesc('photos.likes_count')
            ->orderByDesc('photos.created_at')
            ->select([
                'photos.id',
                'photos.guest_name',
                'photos.likes_count',
                'photos.file_path',
                'photos.thumbnail_path',
                'photos.created_at',
                'tasks.title as task_title',
            ])
            ->first();

        $topPhoto = $topPhotoRow ? [
            'photo_id' => (int) $topPhotoRow->id,
            'guest' => $topPhotoRow->guest_name,
            'likes' => (int) $topPhotoRow->likes_count,
            'task' => $this->firstLocalizedValue($topPhotoRow->task_title, $fallbacks, '') ?: null,
            'created_at' => $topPhotoRow->created_at,
            'thumbnail' => $this->makeSignedGalleryAssetUrlForId($token, (int) $topPhotoRow->id, 'thumbnail')
                ?? $this->resolveSignedFallbackUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path),
        ] : null;

        $trendingEmotionRow = DB::table('photos')
            ->join('emotions', 'photos.emotion_id', '=', 'emotions.id')
            ->where('photos.event_id', $eventId)
            ->select('emotions.id', 'emotions.name', DB::raw('COUNT(*) as total'))
            ->groupBy('emotions.id', 'emotions.name')
            ->orderByDesc('total')
            ->first();

        $trendingEmotion = $trendingEmotionRow ? [
            'emotion_id' => (int) $trendingEmotionRow->id,
            'name' => $this->firstLocalizedValue($trendingEmotionRow->name, $fallbacks, 'Emotion'),
            'count' => (int) $trendingEmotionRow->total,
        ] : null;

        $timeline = DB::table('photos')
            ->where('event_id', $eventId)
            ->selectRaw('DATE(created_at) as day, COUNT(*) as photos, COUNT(DISTINCT guest_name) as guests')
            ->groupBy('day')
            ->orderBy('day')
            ->limit(14)
            ->get()
            ->map(fn ($row) => [
                'date' => $row->day,
                'photos' => (int) $row->photos,
                'guests' => (int) $row->guests,
            ])
            ->values();

        $feed = DB::table('photos')
            ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
            ->where('photos.event_id', $eventId)
            ->where('photos.status', 'approved')
            ->orderByDesc('photos.created_at')
            ->limit(12)
            ->get([
                'photos.id',
                'photos.guest_name',
                'photos.thumbnail_path',
                'photos.file_path',
                'photos.likes_count',
                'photos.created_at',
                'tasks.title as task_title',
            ])
            ->map(fn ($row) => [
                'photo_id' => (int) $row->id,
                'guest' => $row->guest_name,
                'task' => $this->firstLocalizedValue($row->task_title, $fallbacks, '') ?: null,
                'likes' => (int) $row->likes_count,
                'created_at' => $row->created_at,
                'thumbnail' => $this->makeSignedGalleryAssetUrlForId($token, (int) $row->id, 'thumbnail')
                    ?? $this->resolveSignedFallbackUrl($row->thumbnail_path ?: $row->file_path),
            ])
            ->values();

        return [
            'summary' => $summary,
            'personal' => $personal,
            'leaderboards' => [
                'uploads' => $topUploads,
                'likes' => $topLikes,
            ],
            'highlights' => [
                'top_photo' => $topPhoto,
                'trending_emotion' => $trendingEmotion,
                'timeline' => $timeline,
            ],
            'feed' => $feed,
        ];
    }

    private function resolveSignedFallbackUrl(?string $path): ?string
    {
        if (! $path) {
            return null;
        }

        // If already signed or absolute, return as-is
        if (str_contains($path, 'signature=')
            || str_starts_with($path, 'http://')
            || str_starts_with($path, 'https://')) {
            return $path;
        }

        return null;
    }

    private function translateLocalized(string|array|null $value, string $locale, string $fallback = ''): string
    {
        if (is_array($value)) {
            $primary = $value[$locale] ?? $value['de'] ?? $value['en'] ?? null;

            return $primary ?? (reset($value) ?: $fallback);
        }

        if (is_string($value) && $value !== '') {
            return $value;
        }

        return $fallback;
    }

    private function determineBrandingAllowed(Event $event): bool
    {
        return WatermarkConfigResolver::determineBrandingAllowed($event);
    }

    private function determineWatermarkPolicy(Event $event): string
    {
        return WatermarkConfigResolver::determinePolicy($event);
    }

    private function resolveWatermarkConfig(Event $event): array
    {
        return WatermarkConfigResolver::resolve($event);
    }

    private function buildGalleryBranding(Event $event): array
    {
        return $this->resolveBrandingPayload($event);
    }

    private function normalizeHexColor(?string $value, string $fallback): string
    {
        if (is_string($value)) {
            $trimmed = trim($value);

            if (preg_match('/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/', $trimmed) === 1) {
                return $trimmed;
            }
        }

        return $fallback;
    }

    /**
     * @param  array<int, array|mixed>  $sources
     */
    private function firstStringFromSources(array $sources, array $keys): ?string
    {
        foreach ($sources as $source) {
            foreach ($keys as $key) {
                $value = Arr::get($source ?? [], $key);
                if (is_string($value) && trim($value) !== '') {
                    return trim($value);
                }
            }
        }

        return null;
    }

    /**
     * @param  array<int, array|mixed>  $sources
     */
    private function firstNumberFromSources(array $sources, array $keys): ?float
    {
        foreach ($sources as $source) {
            foreach ($keys as $key) {
                $value = Arr::get($source ?? [], $key);
                if (is_numeric($value)) {
                    return (float) $value;
                }
            }
        }

        return null;
    }

    /**
     * @return array{
     *     primary_color: string,
     *     secondary_color: string,
     *     background_color: string,
     *     surface_color: string,
     *     font_family: ?string,
     *     heading_font: ?string,
     *     body_font: ?string,
     *     font_size: string,
     *     logo_url: ?string,
     *     logo_mode: string,
     *     logo_value: ?string,
     *     logo_position: string,
     *     logo_size: string,
     *     button_style: string,
     *     button_radius: int,
     *     button_primary_color: string,
     *     button_secondary_color: string,
     *     link_color: string,
     *     mode: string,
     *     use_default_branding: bool,
     *     palette: array{primary:string, secondary:string, background:string, surface:string},
     *     typography: array{heading:?string, body:?string, size:string},
     *     logo: array{mode:string, value:?string, position:string, size:string},
     *     buttons: array{style:string, radius:int, primary:string, secondary:string, link_color:string},
     *     icon: ?string
     * }
     */
    private function resolveBrandingPayload(Event $event): array
    {
        $defaults = [
            'primary' => '#FF5A5F',
            'secondary' => '#FFF8F5',
            'background' => '#FFF8F5',
            'surface' => '#FFF8F5',
            'font' => null,
            'size' => 'm',
            'logo_position' => 'left',
            'logo_size' => 'm',
            'button_style' => 'filled',
            'button_radius' => 12,
            'mode' => 'auto',
        ];

        $event->loadMissing('eventPackage.package', 'tenant', 'eventType');
        $brandingAllowed = $this->determineBrandingAllowed($event);

        $eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
        $tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];

        $useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
        $sources = $brandingAllowed
            ? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
            : [[]];

        $primary = $this->normalizeHexColor(
            $this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
            $defaults['primary']
        );
        $secondary = $this->normalizeHexColor(
            $this->firstStringFromSources($sources, ['palette.secondary', 'secondary_color']),
            $defaults['secondary']
        );
        $background = $this->normalizeHexColor(
            $this->firstStringFromSources($sources, ['palette.background', 'background_color']),
            $defaults['background']
        );
        $surface = $this->normalizeHexColor(
            $this->firstStringFromSources($sources, ['palette.surface', 'surface_color']),
            $background ?: $defaults['surface']
        );

        $headingFont = $this->firstStringFromSources($sources, ['typography.heading', 'heading_font', 'font_family']);
        $bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']);
        $fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size'];
        $fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size'];

        $logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']);
        if (! in_array($logoMode, ['emoticon', 'upload'], true)) {
            $logoMode = null;
        }
        $logoPosition = $this->firstStringFromSources($sources, ['logo.position', 'logo_position']) ?? $defaults['logo_position'];
        if (! in_array($logoPosition, ['left', 'right', 'center'], true)) {
            $logoPosition = $defaults['logo_position'];
        }
        $logoSize = $this->firstStringFromSources($sources, ['logo.size', 'logo_size']) ?? $defaults['logo_size'];
        if (! in_array($logoSize, ['s', 'm', 'l'], true)) {
            $logoSize = $defaults['logo_size'];
        }

        $logoRawValue = $this->firstStringFromSources($sources, ['logo.value', 'logo_url', 'icon'])
            ?? ($event->eventType?->icon ?? null);
        if (! $logoMode) {
            $logoMode = $logoRawValue && (preg_match('/^https?:\/\//', $logoRawValue) === 1
                || str_starts_with($logoRawValue, '/storage/')
                || str_starts_with($logoRawValue, 'storage/'))
                ? 'upload'
                : 'emoticon';
        }

        $logoValue = $logoMode === 'upload' ? $this->makeSignedBrandingUrl($logoRawValue) : $logoRawValue;

        $buttonStyle = $this->firstStringFromSources($sources, ['buttons.style', 'button_style']) ?? $defaults['button_style'];
        if (! in_array($buttonStyle, ['filled', 'outline'], true)) {
            $buttonStyle = $defaults['button_style'];
        }

        $buttonRadius = (int) ($this->firstNumberFromSources($sources, ['buttons.radius', 'button_radius']) ?? $defaults['button_radius']);
        $buttonRadius = $buttonRadius > 0 ? $buttonRadius : $defaults['button_radius'];

        $buttonPrimary = $this->normalizeHexColor(
            $this->firstStringFromSources($sources, ['buttons.primary', 'button_primary_color']),
            $primary
        );
        $buttonSecondary = $this->normalizeHexColor(
            $this->firstStringFromSources($sources, ['buttons.secondary', 'button_secondary_color']),
            $secondary
        );
        $linkColor = $this->normalizeHexColor(
            $this->firstStringFromSources($sources, ['buttons.link_color', 'link_color']),
            $secondary
        );

        $mode = $this->firstStringFromSources($sources, ['mode']) ?? $defaults['mode'];
        if (! in_array($mode, ['light', 'dark', 'auto'], true)) {
            $mode = $defaults['mode'];
        }

        return [
            'primary_color' => $primary,
            'secondary_color' => $secondary,
            'background_color' => $background,
            'surface_color' => $surface,
            'font_family' => $bodyFont,
            'heading_font' => $headingFont,
            'body_font' => $bodyFont,
            'font_size' => $fontSize,
            'logo_url' => $logoMode === 'upload' ? $logoValue : null,
            'logo_mode' => $logoMode,
            'logo_value' => $logoValue,
            'logo_position' => $logoPosition,
            'logo_size' => $logoSize,
            'button_style' => $buttonStyle,
            'button_radius' => $buttonRadius,
            'button_primary_color' => $buttonPrimary,
            'button_secondary_color' => $buttonSecondary,
            'link_color' => $linkColor,
            'mode' => $mode,
            'use_default_branding' => $useDefault,
            'palette' => [
                'primary' => $primary,
                'secondary' => $secondary,
                'background' => $background,
                'surface' => $surface,
            ],
            'typography' => [
                'heading' => $headingFont,
                'body' => $bodyFont,
                'size' => $fontSize,
            ],
            'logo' => [
                'mode' => $logoMode,
                'value' => $logoValue,
                'position' => $logoPosition,
                'size' => $logoSize,
            ],
            'buttons' => [
                'style' => $buttonStyle,
                'radius' => $buttonRadius,
                'primary' => $buttonPrimary,
                'secondary' => $buttonSecondary,
                'link_color' => $linkColor,
            ],
            'icon' => $logoMode === 'emoticon' ? $logoValue : ($event->eventType?->icon ?? null),
        ];
    }

    private function encodeGalleryCursor(Photo $photo): string
    {
        return base64_encode(json_encode([
            'id' => $photo->id,
            'created_at' => $photo->created_at?->toIso8601String(),
        ]));
    }

    private function decodeGalleryCursor(?string $cursor): ?array
    {
        if (! $cursor) {
            return null;
        }

        $decoded = json_decode(base64_decode($cursor, true) ?: '', true);

        if (! is_array($decoded) || ! isset($decoded['id'], $decoded['created_at'])) {
            return null;
        }

        try {
            return [
                'id' => (int) $decoded['id'],
                'created_at' => Carbon::parse($decoded['created_at']),
            ];
        } catch (\Throwable $e) {
            return null;
        }
    }

    private function makeGalleryPhotoResource(Photo $photo, string $token): array
    {
        $thumbnailUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'thumbnail');
        $fullUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'full');
        $downloadUrl = $this->makeSignedGalleryDownloadUrl($token, $photo);

        return [
            'id' => $photo->id,
            'thumbnail_url' => $thumbnailUrl ?? $fullUrl,
            'full_url' => $fullUrl,
            'download_url' => $downloadUrl,
            'likes_count' => $photo->likes_count,
            'guest_name' => $photo->guest_name,
            'created_at' => $photo->created_at?->toIso8601String(),
        ];
    }

    private function makeSignedGalleryAssetUrlForId(string $token, int $photoId, string $variant): ?string
    {
        if (! in_array($variant, ['thumbnail', 'full'], true)) {
            return null;
        }

        return URL::temporarySignedRoute(
            'api.v1.gallery.photos.asset',
            now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
            [
                'token' => $token,
                'photo' => $photoId,
                'variant' => $variant,
            ]
        );
    }

    private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string
    {
        return $this->makeSignedGalleryAssetUrlForId($token, (int) $photo->id, $variant);
    }

    private function makeSignedPendingAssetUrl(string $token, int $photoId, string $variant, string $deviceId): ?string
    {
        if (! in_array($variant, ['thumbnail', 'full'], true)) {
            return null;
        }

        return URL::temporarySignedRoute(
            'api.v1.events.pending-photos.asset',
            now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
            [
                'token' => $token,
                'photo' => $photoId,
                'variant' => $variant,
                'device_id' => $deviceId,
            ]
        );
    }

    private function makeSignedBrandingUrl(?string $path): ?string
    {
        if (! $path) {
            return null;
        }

        if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
            return $path;
        }

        $normalized = ltrim($path, '/');
        if (str_starts_with($normalized, 'storage/')) {
            $normalized = substr($normalized, strlen('storage/'));
        }

        if (! $this->isAllowedBrandingPath($normalized)) {
            return null;
        }

        return URL::temporarySignedRoute(
            'api.v1.branding.asset',
            now()->addSeconds(self::BRANDING_SIGNED_TTL_SECONDS),
            ['path' => $normalized]
        );
    }

    public function brandingAsset(Request $request, string $path)
    {
        $cleanPath = ltrim($path, '/');

        if ($cleanPath === '' || str_contains($cleanPath, '..') || ! $this->isAllowedBrandingPath($cleanPath)) {
            return ApiError::response(
                'branding_not_found',
                'Branding Asset Not Found',
                'The requested branding asset could not be found.',
                Response::HTTP_NOT_FOUND,
                ['path' => $path]
            );
        }

        $diskName = 'public';

        try {
            $storage = Storage::disk($diskName);
        } catch (\Throwable $e) {
            Log::warning('Branding asset disk unavailable', [
                'path' => $cleanPath,
                'disk' => $diskName,
                'error' => $e->getMessage(),
            ]);

            return ApiError::response(
                'branding_not_found',
                'Branding Asset Not Found',
                'The requested branding asset could not be found.',
                Response::HTTP_NOT_FOUND,
                ['path' => $path]
            );
        }

        if (! $storage->exists($cleanPath)) {
            return ApiError::response(
                'branding_not_found',
                'Branding Asset Not Found',
                'The requested branding asset could not be found.',
                Response::HTTP_NOT_FOUND,
                ['path' => $path]
            );
        }

        try {
            $stream = $storage->readStream($cleanPath);
            if (! $stream) {
                throw new \RuntimeException('Unable to read branding asset stream.');
            }

            $mime = $storage->mimeType($cleanPath) ?: 'application/octet-stream';
            $size = null;
            try {
                $size = $storage->size($cleanPath);
            } catch (\Throwable $e) {
                $size = null;
            }

            $headers = [
                'Content-Type' => $mime,
                'Cache-Control' => 'private, max-age='.self::BRANDING_SIGNED_TTL_SECONDS,
            ];

            if ($size) {
                $headers['Content-Length'] = $size;
            }

            return response()->stream(function () use ($stream) {
                fpassthru($stream);
                fclose($stream);
            }, 200, $headers);
        } catch (\Throwable $e) {
            Log::warning('Branding asset stream error', [
                'path' => $cleanPath,
                'disk' => $diskName,
                'error' => $e->getMessage(),
            ]);

            return ApiError::response(
                'branding_not_found',
                'Branding Asset Not Found',
                'The requested branding asset could not be loaded.',
                Response::HTTP_NOT_FOUND,
                ['path' => $path]
            );
        }
    }

    private function isAllowedBrandingPath(string $path): bool
    {
        if ($path === '' || str_contains($path, '..')) {
            return false;
        }

        $allowedPrefixes = [
            'branding/',
            'events/',
            'tenant-branding/',
        ];

        foreach ($allowedPrefixes as $prefix) {
            if (str_starts_with($path, $prefix)) {
                return true;
            }
        }

        return false;
    }

    private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
    {
        return URL::temporarySignedRoute(
            'api.v1.gallery.photos.download',
            now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
            [
                'token' => $token,
                'photo' => $photo->id,
            ]
        );
    }

    private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string
    {
        return URL::temporarySignedRoute(
            'api.v1.photo-shares.asset',
            now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
            [
                'slug' => $shareLink->slug,
                'variant' => $variant,
            ],
            absolute: false
        );
    }

    public function gallery(Request $request, string $token)
    {
        $locale = $request->query('locale', app()->getLocale());

        $resolved = $this->resolveGalleryEvent($request, $token);

        if ($resolved instanceof JsonResponse) {
            return $resolved;
        }

        /** @var array{0: Event, 1: EventJoinToken} $resolved */
        [$event, $joinToken] = $resolved;

        if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) {
            return $downloadResponse;
        }

        $branding = $this->buildGalleryBranding($event);
        $expiresAt = optional($event->eventPackage)->gallery_expires_at;
        $settings = is_array($event->settings) ? $event->settings : [];
        $policy = $this->guestPolicy();

        return response()->json([
            'event' => [
                'id' => $event->id,
                'name' => $this->translateLocalized($event->name, $locale, 'Fotospiel Event'),
                'slug' => $event->slug,
                'description' => $this->translateLocalized($event->description, $locale, ''),
                'gallery_expires_at' => $expiresAt?->toIso8601String(),
                'guest_downloads_enabled' => (bool) ($settings['guest_downloads_enabled'] ?? $policy->guest_downloads_enabled),
                'guest_sharing_enabled' => (bool) ($settings['guest_sharing_enabled'] ?? $policy->guest_sharing_enabled),
            ],
            'branding' => $branding,
        ]);
    }

    public function galleryPhotos(Request $request, string $token)
    {
        $resolved = $this->resolveGalleryEvent($request, $token);

        if ($resolved instanceof JsonResponse) {
            return $resolved;
        }

        /** @var array{0: Event, 1: EventJoinToken} $resolved */
        [$event, $joinToken] = $resolved;

        if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) {
            return $downloadResponse;
        }

        $limit = (int) $request->query('limit', 30);
        $limit = max(1, min($limit, 60));

        $cursor = $this->decodeGalleryCursor($request->query('cursor'));

        $query = Photo::query()
            ->where('event_id', $event->id)
            ->where('status', 'approved')
            ->orderByDesc('created_at')
            ->orderByDesc('id');

        if ($cursor) {
            /** @var Carbon $cursorTime */
            $cursorTime = $cursor['created_at'];
            $cursorId = $cursor['id'];

            $query->where(function ($inner) use ($cursorTime, $cursorId) {
                $inner->where('created_at', '<', $cursorTime)
                    ->orWhere(function ($nested) use ($cursorTime, $cursorId) {
                        $nested->where('created_at', '=', $cursorTime)
                            ->where('id', '<', $cursorId);
                    });
            });
        }

        $photos = $query->limit($limit + 1)->get();

        $hasMore = $photos->count() > $limit;
        $items = $photos->take($limit);

        $nextCursor = null;

        if ($hasMore) {
            $cursorPhoto = $photos->slice($limit, 1)->first();
            if ($cursorPhoto instanceof Photo) {
                $nextCursor = $this->encodeGalleryCursor($cursorPhoto);
            }
        }

        return response()->json([
            'data' => $items->map(fn (Photo $photo) => $this->makeGalleryPhotoResource($photo, $token))->all(),
            'next_cursor' => $nextCursor,
        ]);
    }

    public function pendingUploads(Request $request, string $token)
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$event] = $result;
        $deviceId = $this->resolveDeviceIdentifier($request);

        if ($deviceId === 'anonymous') {
            return response()->json([
                'data' => [],
                'meta' => ['total_count' => 0],
            ])->header('Cache-Control', 'no-store');
        }

        $limit = (int) $request->query('limit', 12);
        $limit = max(1, min($limit, 30));

        $baseQuery = Photo::query()
            ->where('event_id', $event->id)
            ->where('status', 'pending')
            ->where('created_by_device_id', $deviceId);

        $totalCount = (clone $baseQuery)->count();

        $photos = $baseQuery
            ->orderByDesc('created_at')
            ->limit($limit)
            ->get(['id', 'created_at', 'status']);

        $data = $photos->map(fn (Photo $photo) => [
            'id' => $photo->id,
            'status' => $photo->status,
            'created_at' => $photo->created_at?->toIso8601String(),
            'thumbnail_url' => $this->makeSignedPendingAssetUrl($token, (int) $photo->id, 'thumbnail', $deviceId),
            'full_url' => $this->makeSignedPendingAssetUrl($token, (int) $photo->id, 'full', $deviceId),
        ])->all();

        return response()->json([
            'data' => $data,
            'meta' => ['total_count' => $totalCount],
        ])->header('Cache-Control', 'no-store');
    }

    public function createShareLink(Request $request, string $token, Photo $photo)
    {
        $resolved = $this->resolvePublishedEvent($request, $token, ['id']);

        if ($resolved instanceof JsonResponse) {
            return $resolved;
        }

        /** @var array{0: object{id:int}} $resolved */
        [$eventRecord] = $resolved;

        if ((int) $photo->event_id !== (int) $eventRecord->id) {
            return ApiError::response(
                'photo_not_shareable',
                'Photo Not Shareable',
                'The selected photo cannot be shared at this time.',
                Response::HTTP_NOT_FOUND,
                [
                    'photo_id' => $photo->id,
                    'event_id' => $eventRecord->id,
                ]
            );
        }

        $deviceId = trim((string) ($request->header('X-Device-Id') ?? $request->input('device_id', '')));
        $policy = $this->guestPolicy();
        $ttlHours = max(1, (int) ($policy->share_link_ttl_hours ?? config('share-links.ttl_hours', 48)));

        $existing = PhotoShareLink::query()
            ->where('photo_id', $photo->id)
            ->when($deviceId !== '', fn ($query) => $query->where('created_by_device_id', $deviceId))
            ->where('expires_at', '>', now())
            ->latest('id')
            ->first();

        if ($existing && ! $existing->isExpired()) {
            $shareLink = $existing;
        } else {
            $shareLink = PhotoShareLink::create([
                'photo_id' => $photo->id,
                'slug' => PhotoShareLink::generateSlug(),
                'expires_at' => now()->addHours($ttlHours),
                'created_by_device_id' => $deviceId ?: null,
                'created_ip' => $request->ip(),
            ]);
        }

        return response()->json([
            'slug' => $shareLink->slug,
            'expires_at' => $shareLink->expires_at?->toIso8601String(),
            'url' => url("/share/{$shareLink->slug}"),
        ])->header('Cache-Control', 'no-store');
    }

    public function shareLink(Request $request, string $slug)
    {
        $shareLink = PhotoShareLink::with(['photo.event', 'photo.emotion', 'photo.task'])
            ->where('slug', $slug)
            ->first();

        if (! $shareLink || $shareLink->isExpired()) {
            return ApiError::response(
                'share_link_expired',
                'Link Expired',
                'This shared photo link is no longer available.',
                Response::HTTP_GONE,
                ['slug' => $slug]
            );
        }

        $shareLink->forceFill(['last_accessed_at' => now()])->save();

        $photo = $shareLink->photo;
        $event = $photo->event;

        if (! $event || in_array($photo->status, ['hidden', 'rejected'], true)) {
            return ApiError::response(
                'photo_not_shareable',
                'Photo Not Shareable',
                'The shared photo is no longer available.',
                Response::HTTP_NOT_FOUND,
                ['slug' => $slug]
            );
        }

        $taskTitle = null;

        if ($photo->task) {
            $taskTitle = $this->translateLocalized($photo->task->title, app()->getLocale(), '');
            if ($taskTitle === '') {
                $taskTitle = null;
            }
        }

        $emotionName = null;
        if ($photo->emotion) {
            $emotionName = $this->translateLocalized($photo->emotion->name, app()->getLocale(), '');
            if ($emotionName === '') {
                $emotionName = is_string($photo->emotion->name) ? $photo->emotion->name : null;
            }
        }

        $photoResource = [
            'id' => $photo->id,
            'title' => $taskTitle,
            'emotion' => $photo->emotion ? [
                'name' => $emotionName,
                'emoji' => $photo->emotion->emoji,
            ] : null,
            'likes_count' => $photo->likes()->count(),
            'created_at' => $photo->created_at?->toIso8601String(),
            'image_urls' => [
                'thumbnail' => $this->makeShareAssetUrl($shareLink, 'thumbnail'),
                'full' => $this->makeShareAssetUrl($shareLink, 'full'),
            ],
        ];

        return response()->json([
            'slug' => $shareLink->slug,
            'expires_at' => $shareLink->expires_at?->toIso8601String(),
            'photo' => $photoResource,
            'event' => $event ? [
                'id' => $event->id,
                'name' => $event->name,
                'city' => $event->city,
            ] : null,
        ])->header('Cache-Control', 'no-store');
    }

    public function shareLinkAsset(Request $request, string $slug, string $variant)
    {
        if (! in_array($variant, ['thumbnail', 'full'], true)) {
            return ApiError::response(
                'invalid_variant',
                'Invalid Variant',
                'The requested asset variant is not supported.',
                Response::HTTP_BAD_REQUEST
            );
        }

        $shareLink = PhotoShareLink::with(['photo.mediaAsset', 'photo.event.tenant'])
            ->where('slug', $slug)
            ->first();

        if (! $shareLink || $shareLink->isExpired()) {
            return ApiError::response(
                'share_link_expired',
                'Link Expired',
                'This shared photo link is no longer available.',
                Response::HTTP_GONE,
                ['slug' => $slug]
            );
        }

        $photo = $shareLink->photo;
        $event = $photo->event;

        if (! $event || in_array($photo->status, ['hidden', 'rejected'], true)) {
            return ApiError::response(
                'photo_not_shareable',
                'Photo Not Shareable',
                'The shared photo is no longer available.',
                Response::HTTP_NOT_FOUND,
                ['slug' => $slug]
            );
        }

        $variantPreference = $variant === 'thumbnail'
            ? ['thumbnail', 'original']
            : ['original'];
        $preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false);

        return $this->streamGalleryPhoto($event, $photo, $variantPreference, 'inline');
    }

    public function galleryPhotoAsset(Request $request, string $token, int $photo, string $variant)
    {
        $resolved = $this->resolveGalleryEvent($request, $token);

        if ($resolved instanceof JsonResponse) {
            return $resolved;
        }

        /** @var array{0: Event, 1: EventJoinToken} $resolved */
        [$event] = $resolved;

        $record = Photo::with('mediaAsset')
            ->where('id', $photo)
            ->where('event_id', $event->id)
            ->where('status', 'approved')
            ->first();

        if (! $record) {
            return ApiError::response(
                'photo_not_found',
                'Photo Not Found',
                'The requested photo is no longer available.',
                Response::HTTP_NOT_FOUND,
                [
                    'photo_id' => $photo,
                    'event_id' => $event->id,
                ]
            );
        }

        $variantPreference = $variant === 'thumbnail'
            ? ['thumbnail', 'original']
            : ['original'];

        return $this->streamGalleryPhoto($event, $record, $variantPreference, 'inline');
    }

    public function pendingPhotoAsset(Request $request, string $token, int $photo, string $variant)
    {
        $resolved = $this->resolveGalleryEvent($request, $token);

        if ($resolved instanceof JsonResponse) {
            return $resolved;
        }

        [$event] = $resolved;
        $deviceId = $this->normalizeGuestIdentifier((string) $request->query('device_id', ''));

        if ($deviceId === '') {
            return ApiError::response(
                'pending_photo_forbidden',
                'Pending Photo Access Denied',
                'The pending photo cannot be accessed.',
                Response::HTTP_FORBIDDEN
            );
        }

        $record = Photo::with('mediaAsset')
            ->where('id', $photo)
            ->where('event_id', $event->id)
            ->where('status', 'pending')
            ->where('created_by_device_id', $deviceId)
            ->first();

        if (! $record) {
            return ApiError::response(
                'photo_not_found',
                'Photo Not Found',
                'The requested photo is no longer available.',
                Response::HTTP_NOT_FOUND,
                [
                    'photo_id' => $photo,
                    'event_id' => $event->id,
                ]
            );
        }

        $variantPreference = $variant === 'thumbnail'
            ? ['thumbnail', 'original']
            : ['original'];

        return $this->streamGalleryPhoto($event, $record, $variantPreference, 'inline');
    }

    public function galleryPhotoDownload(Request $request, string $token, int $photo)
    {
        $resolved = $this->resolveGalleryEvent($request, $token);

        if ($resolved instanceof JsonResponse) {
            return $resolved;
        }

        /** @var array{0: Event, 1: EventJoinToken} $resolved */
        [$event] = $resolved;

        $record = Photo::with('mediaAsset')
            ->where('id', $photo)
            ->where('event_id', $event->id)
            ->where('status', 'approved')
            ->first();

        if (! $record) {
            return ApiError::response(
                'photo_not_found',
                'Photo Not Found',
                'The requested photo is no longer available.',
                Response::HTTP_NOT_FOUND,
                [
                    'photo_id' => $photo,
                    'event_id' => $event->id,
                ]
            );
        }

        return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
    }

    public function event(Request $request, string $token)
    {
        $result = $this->resolvePublishedEvent($request, $token, [
            'id',
            'slug',
            'name',
            'default_locale',
            'created_at',
            'updated_at',
            'event_type_id',
        ]);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$eventRecord, $joinToken] = $result;

        $event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id);

        if (! $event) {
            return ApiError::response(
                'event_not_found',
                'Event Not Found',
                'Das Event konnte nicht gefunden werden.',
                Response::HTTP_NOT_FOUND,
                ['token' => Str::limit($token, 12)]
            );
        }

        $locale = $request->query('locale', $event->default_locale ?? 'de');
        $localizedName = $this->translateLocalized($event->name, $locale, 'Fotospiel Event');

        $eventType = $event->eventType;
        $eventTypeData = $eventType ? [
            'slug' => $eventType->slug,
            'name' => $this->translateLocalized($eventType->name, $locale, 'Event'),
            'icon' => $eventType->icon ?? null,
        ] : [
            'slug' => 'general',
            'name' => $this->getLocalized('Event', $locale, 'Event'),
            'icon' => null,
        ];

        $branding = $this->buildGalleryBranding($event);
        $settings = $this->normalizeSettings($event->settings ?? []);
        $engagementMode = $settings['engagement_mode'] ?? 'tasks';
        $liveShowSettings = Arr::get($settings, 'live_show', []);
        $liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
        $event->loadMissing('photoboothSetting');
        $policy = $this->guestPolicy();

        if ($joinToken) {
            $this->joinTokenService->incrementUsage($joinToken);
        }

        $demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);

        return response()->json([
            'id' => $event->id,
            'slug' => $event->slug,
            'name' => $localizedName,
            'default_locale' => $event->default_locale,
            'created_at' => $event->created_at,
            'updated_at' => $event->updated_at,
            'type' => $eventTypeData,
            'join_token' => $joinToken?->token,
            'demo_read_only' => $demoReadOnly,
            'photobooth_enabled' => (bool) ($event->photoboothSetting?->enabled),
            'branding' => $branding,
            'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility),
            'live_show' => [
                'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual',
            ],
            'engagement_mode' => $engagementMode,
        ])->header('Cache-Control', 'no-store');
    }

    public function package(Request $request, string $token)
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$eventRecord, $joinToken] = $result;

        $event = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])
            ->findOrFail($eventRecord->id);

        if (! $event->tenant) {
            return ApiError::response(
                'event_not_found',
                'Event not accessible',
                'The selected event is no longer available.',
                Response::HTTP_NOT_FOUND,
                ['scope' => 'photos', 'event_id' => $event->id]
            );
        }

        $eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
            $event->tenant,
            $event->id,
            $event
        );

        if (! $eventPackage || ! $eventPackage->package) {
            return response()->json([
                'id' => null,
                'event_id' => $event->id,
                'package_id' => null,
                'package' => null,
                'used_photos' => (int) ($eventPackage?->used_photos ?? 0),
                'used_guests' => (int) ($eventPackage?->used_guests ?? 0),
                'expires_at' => $eventPackage?->gallery_expires_at?->toIso8601String(),
                'limits' => null,
            ])->header('Cache-Control', 'no-store');
        }

        $package = $eventPackage->package;
        $summary = $this->packageLimitEvaluator->summarizeEventPackage($eventPackage);
        $watermarkPolicy = $this->determineWatermarkPolicy($event);
        $brandingAllowed = $this->determineBrandingAllowed($event);
        $watermark = $this->resolveWatermarkConfig($event);

        return response()->json([
            'id' => $eventPackage->id,
            'event_id' => $event->id,
            'package_id' => $eventPackage->package_id,
            'package' => [
                'id' => $eventPackage->package_id,
                'name' => $package?->getNameForLocale(app()->getLocale()) ?? $package?->name,
                'max_photos' => $package?->max_photos,
                'max_guests' => $package?->max_guests,
                'gallery_days' => $package?->gallery_days,
                'watermark_policy' => $watermarkPolicy,
                'branding_allowed' => $brandingAllowed,
            ],
            'used_photos' => (int) $eventPackage->used_photos,
            'used_guests' => (int) $eventPackage->used_guests,
            'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
            'limits' => $summary,
            'watermark' => $watermark,
        ])->header('Cache-Control', 'no-store');
    }

    private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
    {
        $preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false);

        foreach ($variantPreference as $variant) {
            [$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant, $preferOriginals);

            if (! $path) {
                continue;
            }

            $diskName = $this->resolveStorageDisk($disk);

            try {
                $storage = Storage::disk($diskName);
            } catch (\Throwable $e) {
                Log::warning('Gallery asset disk unavailable', [
                    'event_id' => $event->id,
                    'photo_id' => $record->id,
                    'disk' => $diskName,
                    'error' => $e->getMessage(),
                ]);

                continue;
            }

            foreach ($this->storagePathCandidates($path) as $candidate) {
                try {
                    if (! $storage->exists($candidate)) {
                        continue;
                    }

                    $stream = $storage->readStream($candidate);
                    if (! $stream) {
                        continue;
                    }

                    $size = null;
                    try {
                        $size = $storage->size($candidate);
                    } catch (\Throwable $e) {
                        $size = null;
                    }

                    if (! $mime) {
                        try {
                            $mime = $storage->mimeType($candidate);
                        } catch (\Throwable $e) {
                            $mime = null;
                        }
                    }

                    $extension = pathinfo($candidate, PATHINFO_EXTENSION) ?: ($record->mime_type ? explode('/', $record->mime_type)[1] ?? 'jpg' : 'jpg');
                    $suffix = $variant === 'thumbnail' ? '-thumb' : '';
                    $filename = sprintf('fotospiel-event-%s-photo-%s%s.%s', $event->id, $record->id, $suffix, $extension ?: 'jpg');

                    $headers = [
                        'Content-Type' => $mime ?? 'image/jpeg',
                        'Cache-Control' => $disposition === 'attachment'
                            ? 'no-store, max-age=0'
                            : 'private, max-age='.self::SIGNED_URL_TTL_SECONDS,
                        'Content-Disposition' => ($disposition === 'attachment' ? 'attachment' : 'inline').'; filename="'.$filename.'"',
                    ];

                    if ($size) {
                        $headers['Content-Length'] = $size;
                    }

                    return response()->stream(function () use ($stream) {
                        fpassthru($stream);
                        fclose($stream);
                    }, 200, $headers);
                } catch (\Throwable $e) {
                    Log::warning('Gallery asset stream error', [
                        'event_id' => $event->id,
                        'photo_id' => $record->id,
                        'disk' => $diskName,
                        'path' => $candidate,
                        'variant' => $variant,
                        'error' => $e->getMessage(),
                    ]);
                }
            }
        }

        return ApiError::response(
            'photo_unavailable',
            'Photo Unavailable',
            'The requested photo could not be loaded.',
            Response::HTTP_NOT_FOUND,
            [
                'photo_id' => $record->id,
                'event_id' => $event->id,
            ]
        );
    }

    private function resolvePhotoVariant(Photo $record, string $variant, bool $preferOriginals = false): array
    {
        if ($variant === 'thumbnail') {
            $asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'thumbnail')->first();
            $watermarked = $preferOriginals
                ? null
                : EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked_thumbnail')->first();
            $disk = $asset?->disk ?? $record->mediaAsset?->disk;
            $path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
            $mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg';
        } else {
            $watermarked = $preferOriginals
                ? null
                : EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked')->first();
            $asset = $record->mediaAsset ?? $watermarked ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
            $disk = $asset?->disk ?? $record->mediaAsset?->disk;
            $path = $watermarked?->path ?? $asset?->path ?? ($record->file_path ?? null);
            $mime = $watermarked?->mime_type ?? $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
        }

        return [
            $disk ?: config('filesystems.default', 'public'),
            $path,
            $mime,
        ];
    }

    private function resolveStorageDisk(?string $disk): string
    {
        $disk = $disk ?: config('filesystems.default', 'public');

        if (! config("filesystems.disks.{$disk}")) {
            return config('filesystems.default', 'public');
        }

        return $disk;
    }

    private function storagePathCandidates(string $path): array
    {
        $normalized = str_replace('\\', '/', $path);
        $candidates = [$normalized];

        $trimmed = ltrim($normalized, '/');
        if ($trimmed !== $normalized) {
            $candidates[] = $trimmed;
        }

        if (str_starts_with($trimmed, 'storage/')) {
            $candidates[] = substr($trimmed, strlen('storage/'));
        }

        if (str_starts_with($trimmed, 'public/')) {
            $candidates[] = substr($trimmed, strlen('public/'));
        }

        $needle = '/storage/app/public/';
        if (str_contains($normalized, $needle)) {
            $candidates[] = substr($normalized, strpos($normalized, $needle) + strlen($needle));
        }

        return array_values(array_unique(array_filter($candidates)));
    }

    public function notifications(Request $request, string $token)
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id', 'tenant_id']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$event] = $result;
        $guestIdentifier = $this->resolveNotificationIdentifier($request);
        $limit = max(1, min(50, (int) $request->integer('limit', 35)));
        $statusFilter = $request->string('status')->lower()->value();
        $scopeFilter = $request->string('scope')->lower()->value();

        if (! Schema::hasTable('guest_notifications')) {
            return $this->emptyNotificationsResponse($request, $event->id, 'disabled');
        }

        $baseQuery = GuestNotification::query()
            ->where('event_id', $event->id)
            ->active()
            ->notExpired()
            ->visibleToGuest($guestIdentifier);

        if ($statusFilter === 'unread') {
            $baseQuery->where(function ($query) use ($guestIdentifier) {
                $query->whereDoesntHave('receipts', fn ($receipt) => $receipt->where('guest_identifier', $guestIdentifier))
                    ->orWhereHas('receipts', fn ($receipt) => $receipt
                        ->where('guest_identifier', $guestIdentifier)
                        ->where('status', GuestNotificationDeliveryStatus::NEW->value));
            });
        } elseif ($statusFilter === 'read') {
            $baseQuery->whereHas('receipts', fn ($receipt) => $receipt
                ->where('guest_identifier', $guestIdentifier)
                ->where('status', GuestNotificationDeliveryStatus::READ->value));
        } elseif ($statusFilter === 'dismissed') {
            $baseQuery->whereHas('receipts', fn ($receipt) => $receipt
                ->where('guest_identifier', $guestIdentifier)
                ->where('status', GuestNotificationDeliveryStatus::DISMISSED->value));
        }

        if ($scopeFilter === 'uploads') {
            $baseQuery->whereIn('type', [GuestNotificationType::UPLOAD_ALERT->value, GuestNotificationType::PHOTO_ACTIVITY->value]);
        } elseif ($scopeFilter === 'tips') {
            $baseQuery->whereIn('type', [GuestNotificationType::SUPPORT_TIP->value, GuestNotificationType::ACHIEVEMENT_MAJOR->value]);
        } elseif ($scopeFilter === 'general') {
            $baseQuery->whereIn('type', [GuestNotificationType::BROADCAST->value, GuestNotificationType::FEEDBACK_REQUEST->value]);
        }

        $notifications = (clone $baseQuery)
            ->with(['receipts' => fn ($query) => $query->where('guest_identifier', $guestIdentifier)])
            ->orderByDesc('priority')
            ->orderByDesc('id')
            ->limit($limit)
            ->get();

        $unreadCount = (clone $baseQuery)
            ->where(function ($query) use ($guestIdentifier) {
                $query->whereDoesntHave('receipts', fn ($receipt) => $receipt->where('guest_identifier', $guestIdentifier))
                    ->orWhereHas('receipts', fn ($receipt) => $receipt
                        ->where('guest_identifier', $guestIdentifier)
                        ->where('status', GuestNotificationDeliveryStatus::NEW->value));
            })
            ->count();

        $data = $notifications->map(fn (GuestNotification $notification) => $this->formatGuestNotification($notification, $guestIdentifier));

        $etag = sha1(json_encode([
            $event->id,
            $guestIdentifier,
            $unreadCount,
            $notifications->first()?->updated_at?->toAtomString(),
        ]));

        $clientEtags = array_map(fn ($tag) => trim($tag, '"'), $request->getETags());
        if (in_array($etag, $clientEtags, true)) {
            return response('', 304)
                ->header('ETag', $etag)
                ->header('Cache-Control', 'no-store')
                ->header('Vary', 'X-Device-Id, Accept-Language');
        }

        return response()->json([
            'data' => $data,
            'meta' => [
                'unread_count' => $unreadCount,
                'poll_after_seconds' => 90,
            ],
        ])->header('ETag', $etag)
            ->header('Cache-Control', 'no-store')
            ->header('Vary', 'X-Device-Id, Accept-Language');
    }

    private function emptyNotificationsResponse(Request $request, int $eventId, string $reason = 'empty'): JsonResponse
    {
        $etag = sha1(sprintf('event:%d:guest_notifications:%s', $eventId, $reason));

        $clientEtags = array_map(fn ($tag) => trim($tag, '"'), $request->getETags());
        if (in_array($etag, $clientEtags, true)) {
            return response()->json([], Response::HTTP_NOT_MODIFIED)
                ->header('ETag', $etag)
                ->header('Cache-Control', 'no-store')
                ->header('Vary', 'X-Device-Id, Accept-Language');
        }

        return response()->json([
            'data' => [],
            'meta' => [
                'unread_count' => 0,
                'poll_after_seconds' => 120,
                'reason' => $reason,
            ],
        ])->header('ETag', $etag)
            ->header('Cache-Control', 'no-store')
            ->header('Vary', 'X-Device-Id, Accept-Language');
    }

    public function registerPushSubscription(Request $request, string $token)
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$eventRecord] = $result;
        $validated = $request->validate([
            'endpoint' => ['required', 'url', 'max:500'],
            'keys.p256dh' => ['required', 'string', 'max:255'],
            'keys.auth' => ['required', 'string', 'max:255'],
            'expiration_time' => ['nullable'],
            'content_encoding' => ['nullable', 'string', 'max:32'],
        ]);

        $event = Event::findOrFail($eventRecord->id);
        $guestIdentifier = $this->resolveNotificationIdentifier($request);
        $deviceId = $this->resolveDeviceIdentifier($request);

        $payload = [
            'endpoint' => $validated['endpoint'],
            'keys' => [
                'p256dh' => $validated['keys']['p256dh'],
                'auth' => $validated['keys']['auth'],
            ],
            'expiration_time' => $validated['expiration_time'] ?? null,
            'content_encoding' => $validated['content_encoding'] ?? null,
            'language' => $request->getPreferredLanguage() ?? $request->headers->get('Accept-Language'),
            'user_agent' => (string) $request->userAgent(),
        ];

        $subscription = $this->pushSubscriptions->register($event, $guestIdentifier, $deviceId, $payload);

        return response()->json([
            'id' => $subscription->id,
            'status' => $subscription->status,
        ], Response::HTTP_CREATED);
    }

    public function destroyPushSubscription(Request $request, string $token)
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$eventRecord] = $result;

        $validated = $request->validate([
            'endpoint' => ['required', 'url', 'max:500'],
        ]);

        $event = Event::findOrFail($eventRecord->id);
        $revoked = $this->pushSubscriptions->revoke($event, $validated['endpoint']);

        return response()->json([
            'status' => $revoked ? 'revoked' : 'not_found',
        ]);
    }

    public function markNotificationRead(Request $request, string $token, GuestNotification $notification)
    {
        return $this->handleNotificationAction($request, $token, $notification, 'read');
    }

    public function dismissNotification(Request $request, string $token, GuestNotification $notification)
    {
        return $this->handleNotificationAction($request, $token, $notification, 'dismiss');
    }

    private function handleNotificationAction(Request $request, string $token, GuestNotification $notification, string $action)
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$event] = $result;

        if ((int) $notification->event_id !== (int) $event->id) {
            return ApiError::response(
                'notification_not_found',
                'Notification not found',
                'Diese Benachrichtigung gehört nicht zu diesem Event.',
                Response::HTTP_NOT_FOUND
            );
        }

        $guestIdentifier = $this->resolveNotificationIdentifier($request);

        if (! $this->notificationVisibleToGuest($notification, $guestIdentifier)) {
            return ApiError::response(
                'notification_forbidden',
                'Notification unavailable',
                'Diese Nachricht steht nicht zur Verfügung.',
                Response::HTTP_FORBIDDEN
            );
        }

        $receipt = $action === 'read'
            ? $this->guestNotificationService->markAsRead($notification, $guestIdentifier)
            : $this->guestNotificationService->dismiss($notification, $guestIdentifier);

        return response()->json([
            'id' => $notification->id,
            'status' => $receipt->status->value,
            'read_at' => $receipt->read_at?->toAtomString(),
            'dismissed_at' => $receipt->dismissed_at?->toAtomString(),
        ]);
    }

    private function notificationVisibleToGuest(GuestNotification $notification, string $guestIdentifier): bool
    {
        if ($notification->status !== GuestNotificationState::ACTIVE) {
            return false;
        }

        if ($notification->hasExpired()) {
            return false;
        }

        if ($notification->audience_scope === GuestNotificationAudience::ALL) {
            return true;
        }

        return $notification->audience_scope === GuestNotificationAudience::GUEST
            && $notification->target_identifier === $guestIdentifier;
    }

    private function formatGuestNotification(GuestNotification $notification, string $guestIdentifier): array
    {
        $receipt = $notification->receipts->firstWhere('guest_identifier', $guestIdentifier);
        $status = $receipt?->status ?? GuestNotificationDeliveryStatus::NEW;
        $payload = $notification->payload ?? [];

        $cta = null;
        if (is_array($payload) && isset($payload['cta']) && is_array($payload['cta'])) {
            $ctaPayload = $payload['cta'];
            if (! empty($ctaPayload['label']) && ! empty($ctaPayload['href'])) {
                $cta = [
                    'label' => (string) $ctaPayload['label'],
                    'href' => (string) $ctaPayload['href'],
                ];
            }
        }

        return [
            'id' => (int) $notification->id,
            'type' => $notification->type->value,
            'title' => $notification->title,
            'body' => $notification->body,
            'status' => $status->value,
            'created_at' => $notification->created_at?->toAtomString(),
            'read_at' => $receipt?->read_at?->toAtomString(),
            'dismissed_at' => $receipt?->dismissed_at?->toAtomString(),
            'cta' => $cta,
            'payload' => $payload,
        ];
    }

    private function notifyDeviceUploadLimit(Event $event, string $guestIdentifier, string $token, int $limit): void
    {
        $title = 'Upload-Limit erreicht';
        $body = sprintf(
            'Du hast bereits %d Fotos hochgeladen. Wir speichern deine Warteschlange – bitte lösche zuerst alte Uploads.',
            $limit
        );

        $this->guestNotificationService->createNotification(
            $event,
            GuestNotificationType::UPLOAD_ALERT,
            $title,
            $body,
            [
                'audience_scope' => GuestNotificationAudience::GUEST,
                'target_identifier' => $guestIdentifier,
                'payload' => [
                    'cta' => [
                        'label' => 'Warteschlange öffnen',
                        'href' => sprintf('/e/%s/queue', urlencode($token)),
                    ],
                ],
                'expires_at' => now()->addHours(6),
            ]
        );
    }

    private function notifyPhotoLimitReached(Event $event): void
    {
        $title = 'Uploads pausiert – Event-Limit erreicht';

        if ($this->eventHasActiveNotification($event, GuestNotificationType::UPLOAD_ALERT, $title)) {
            return;
        }

        $body = 'Es können aktuell keine neuen Fotos hochgeladen werden. Wir informieren den Host – bleib kurz dran!';

        $this->guestNotificationService->createNotification(
            $event,
            GuestNotificationType::UPLOAD_ALERT,
            $title,
            $body,
            [
                'audience_scope' => GuestNotificationAudience::ALL,
                'expires_at' => now()->addHours(3),
            ]
        );
    }

    private function guestPolicy(): GuestPolicySetting
    {
        return $this->guestPolicy ??= GuestPolicySetting::current();
    }

    private function eventHasActiveNotification(Event $event, GuestNotificationType $type, string $title): bool
    {
        return GuestNotification::query()
            ->where('event_id', $event->id)
            ->where('type', $type->value)
            ->where('title', $title)
            ->where('status', GuestNotificationState::ACTIVE->value)
            ->exists();
    }

    private function resolveNotificationIdentifier(Request $request): string
    {
        $identifier = $this->determineGuestIdentifier($request);

        if ($identifier) {
            return $identifier;
        }

        return $this->resolveDeviceIdentifier($request);
    }

    private function resolveDeviceIdentifier(Request $request): string
    {
        $deviceId = (string) $request->headers->get('X-Device-Id', '');
        $normalized = $this->normalizeGuestIdentifier($deviceId);

        return $normalized !== '' ? $normalized : 'anonymous';
    }

    public function stats(Request $request, string $token)
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id', 'settings']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$event, $joinToken] = $result;
        $eventId = $event->id;
        $eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId);
        $settings = $this->normalizeSettings($event->settings ?? null);
        $engagementMode = $settings['engagement_mode'] ?? 'tasks';

        // Approximate online guests as distinct recent uploaders in last 10 minutes.
        $tenMinutesAgo = CarbonImmutable::now()->subMinutes(10);
        $onlineGuests = DB::table('photos')
            ->where('event_id', $eventId)
            ->where('created_at', '>=', $tenMinutesAgo)
            ->distinct('guest_name')
            ->count('guest_name');

        // Tasks solved as number of photos linked to a task (proxy metric).
        $tasksSolved = $engagementMode === 'photo_only'
            ? 0
            : DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count();

        $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');

        $payload = [
            'online_guests' => $onlineGuests,
            'tasks_solved' => $tasksSolved,
            'latest_photo_at' => $latestPhotoAt,
            'engagement_mode' => $engagementMode,
        ];

        $etag = sha1(json_encode($payload));
        $reqEtag = $request->headers->get('If-None-Match');
        if ($reqEtag && $reqEtag === $etag) {
            return response('', 304);
        }

        return response()->json($payload)
            ->header('Cache-Control', 'no-store')
            ->header('ETag', $etag);
    }

    public function emotions(Request $request, string $token)
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$event] = $result;
        $eventId = (int) $event->id;

        [$locale] = $this->resolveGuestLocale($request, $event);
        $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);

        $cacheKey = sprintf('event:%d:emotions:%s', $eventId, $locale);

        $cached = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($eventId, $fallbacks) {
            $rows = DB::table('emotions')
                ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
                ->join('event_types', 'emotion_event_type.event_type_id', '=', 'event_types.id')
                ->join('events', 'events.event_type_id', '=', 'event_types.id')
                ->where('events.id', $eventId)
                ->select([
                    'emotions.id',
                    'emotions.name',
                    'emotions.icon as emoji',
                    'emotions.description',
                ])
                ->orderBy('emotions.sort_order')
                ->get();

            $data = $rows->map(function ($row) use ($fallbacks) {
                return [
                    'id' => (int) $row->id,
                    'slug' => 'emotion-'.$row->id,
                    'name' => $this->firstLocalizedValue($row->name, $fallbacks, 'Emotion'),
                    'emoji' => $row->emoji,
                    'description' => $this->firstLocalizedValue($row->description, $fallbacks, ''),
                ];
            })->values()->all();

            return [
                'data' => $data,
                'hash' => sha1(json_encode($data)),
            ];
        });

        $payload = $cached['data'];
        $etag = $cached['hash'];
        $reqEtag = $request->headers->get('If-None-Match');
        if ($reqEtag && $reqEtag === $etag) {
            return response('', 304)
                ->header('Cache-Control', 'public, max-age=300')
                ->header('ETag', $etag)
                ->header('Vary', 'Accept-Language, X-Locale')
                ->header('X-Content-Locale', $locale);
        }

        return response()->json($payload)
            ->header('Cache-Control', 'public, max-age=300')
            ->header('ETag', $etag)
            ->header('Vary', 'Accept-Language, X-Locale')
            ->header('X-Content-Locale', $locale);
    }

    public function tasks(Request $request, string $token)
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale', 'settings']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$event, $joinToken] = $result;

        $settings = $this->normalizeSettings($event->settings ?? null);
        $engagementMode = $settings['engagement_mode'] ?? 'tasks';

        [$resolvedLocale] = $this->resolveGuestLocale($request, $event);

        $page = max(1, (int) $request->query('page', 1));
        $perPage = max(1, min(100, (int) $request->query('per_page', 20)));

        if ($engagementMode === 'photo_only') {
            $payload = [
                'data' => [],
                'meta' => [
                    'total' => 0,
                    'per_page' => $perPage,
                    'current_page' => $page,
                    'last_page' => 1,
                    'has_more' => false,
                    'seed' => null,
                ],
                'engagement_mode' => $engagementMode,
            ];

            return response()->json($payload)
                ->header('Cache-Control', 'public, max-age=120')
                ->header('Vary', 'Accept-Language, X-Locale')
                ->header('X-Content-Locale', $resolvedLocale)
                ->header('X-Engagement-Mode', $engagementMode);
        }

        $cached = $this->eventTasksCache->remember((int) $event->id, $resolvedLocale, function () use ($event, $resolvedLocale) {
            return $this->buildLocalizedTasksPayload((int) $event->id, $resolvedLocale, $event->default_locale ?? null);
        });

        $tasks = $cached['tasks'];
        $baseHash = $cached['hash'] ?? sha1(json_encode($tasks));

        // Shuffle per request for unpredictability; stable when seeded by guest/device or explicit seed.
        $seedParam = $request->query('seed');
        $guestIdentifier = $this->determineGuestIdentifier($request);
        $seedValue = is_numeric($seedParam)
            ? (int) $seedParam
            : $this->buildDeterministicSeed(($guestIdentifier ? $guestIdentifier.'|' : '').(string) $event->id);
        $randomizer = new \Random\Randomizer(new \Random\Engine\Mt19937($seedValue));
        $shuffled = $randomizer->shuffleArray($tasks);

        $total = count($shuffled);
        $offset = ($page - 1) * $perPage;
        $data = array_slice($shuffled, $offset, $perPage);
        $lastPage = (int) ceil($total / $perPage);
        $hasMore = $page < $lastPage;

        $etag = sha1($baseHash.':'.$page.':'.$perPage.':'.$seedValue);
        $reqEtag = $request->headers->get('If-None-Match');

        if ($reqEtag && $reqEtag === $etag) {
            return response('', 304)
                ->header('Cache-Control', 'public, max-age=120')
                ->header('Vary', 'Accept-Language, X-Locale')
                ->header('X-Content-Locale', $resolvedLocale)
                ->header('ETag', $etag);
        }

        $payload = [
            'data' => $data,
            'meta' => [
                'total' => $total,
                'per_page' => $perPage,
                'current_page' => $page,
                'last_page' => $lastPage,
                'has_more' => $hasMore,
                'seed' => $seedValue,
            ],
        ];

        return response()->json($payload)
            ->header('Cache-Control', 'public, max-age=120')
            ->header('ETag', $etag)
            ->header('Vary', 'Accept-Language, X-Locale')
            ->header('X-Content-Locale', $resolvedLocale);
    }

    public function photos(Request $request, string $token)
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$event] = $result;
        $eventId = (int) $event->id;

        [$locale] = $this->resolveGuestLocale($request, $event);
        $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);

        $deviceId = (string) $request->header('X-Device-Id', 'anon');
        $filter = $request->query('filter');

        $since = $request->query('since');
        $query = DB::table('photos')
            ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
            ->select([
                'photos.id',
                'photos.file_path',
                'photos.thumbnail_path',
                'photos.likes_count',
                'photos.emotion_id',
                'photos.task_id',
                'photos.guest_name',
                'photos.created_at',
                'photos.ingest_source',
                'tasks.title as task_title',
            ])
            ->where('photos.event_id', $eventId)
            ->where('photos.status', 'approved')
            ->orderByDesc('photos.created_at')
            ->limit(60);

        // MyPhotos filter
        if ($filter === 'photobooth') {
            $query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
        } elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
            $query->where('guest_name', $deviceId);
        }

        if ($since) {
            $query->where('photos.created_at', '>', $since);
        }
        $rows = $query->get()->map(function ($r) use ($fallbacks, $token) {
            $r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
                ?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
            $r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
                ?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? ''));

            // Localize task title if present
            if ($r->task_title) {
                $r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe');
            }

            $r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;

            return $r;
        });
        $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
        $payload = [
            'data' => $rows,
            'latest_photo_at' => $latestPhotoAt,
        ];
        $etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt, $locale]));
        $reqEtag = $request->headers->get('If-None-Match');
        if ($reqEtag && $reqEtag === $etag) {
            return response('', 304)
                ->header('Cache-Control', 'no-store')
                ->header('ETag', $etag)
                ->header('Last-Modified', (string) $latestPhotoAt)
                ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
                ->header('X-Content-Locale', $locale);
        }

        return response()->json($payload)
            ->header('Cache-Control', 'no-store')
            ->header('ETag', $etag)
            ->header('Last-Modified', (string) $latestPhotoAt)
            ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
            ->header('X-Content-Locale', $locale);
    }

    public function photo(Request $request, int $id)
    {
        return ApiError::response(
            'photo_not_found',
            'Photo Not Found',
            'Photo not found or event not public.',
            Response::HTTP_NOT_FOUND,
            ['photo_id' => $id]
        );
    }

    public function like(Request $request, int $id)
    {
        $deviceId = (string) $request->header('X-Device-Id', 'anon');
        $deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64);
        if ($deviceId === '') {
            $deviceId = 'anon';
        }

        $photo = DB::table('photos')
            ->join('events', 'photos.event_id', '=', 'events.id')
            ->where('photos.id', $id)
            ->where('events.status', 'published')
            ->first(['photos.id', 'photos.event_id']);
        if (! $photo) {
            return ApiError::response(
                'photo_not_found',
                'Photo Not Found',
                'Photo not found or event not public.',
                Response::HTTP_NOT_FOUND,
                ['photo_id' => $id]
            );
        }

        // Idempotent like per device
        $exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists();
        if ($exists) {
            $count = (int) DB::table('photos')->where('id', $id)->value('likes_count');

            return response()->json(['liked' => true, 'likes_count' => $count]);
        }

        DB::beginTransaction();
        try {
            DB::table('photo_likes')->insert([
                'photo_id' => $id,
                'guest_name' => $deviceId,
                'ip_address' => 'device',
                'created_at' => now(),
            ]);
            DB::table('photos')->where('id', $id)->update([
                'likes_count' => DB::raw('likes_count + 1'),
                'updated_at' => now(),
            ]);
            DB::commit();
        } catch (\Throwable $e) {
            DB::rollBack();
            Log::warning('like failed', ['error' => $e->getMessage()]);
        }

        $count = (int) DB::table('photos')->where('id', $id)->value('likes_count');

        return response()->json(['liked' => true, 'likes_count' => $count]);
    }

    public function upload(Request $request, string $token)
    {
        $result = $this->resolvePublishedEvent($request, $token, ['id']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$event, $joinToken] = $result;
        $eventId = $event->id;
        $demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);

        if ($demoReadOnly) {
            $this->recordTokenEvent(
                $joinToken,
                $request,
                'demo_read_only',
                ['event_id' => $eventId],
                $token,
                Response::HTTP_FORBIDDEN
            );

            return ApiError::response(
                'demo_read_only',
                'Demo mode',
                'Uploads are disabled in demo mode.',
                Response::HTTP_FORBIDDEN,
                [
                    'event_id' => $eventId,
                ]
            );
        }

        $eventModel = Event::with([
            'tenant',
            'eventPackage.package',
            'eventPackages.package',
            'storageAssignments.storageTarget',
        ])->findOrFail($eventId);
        $policy = $this->guestPolicy();
        $uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility);
        $autoApproveUploads = $uploadVisibility === 'immediate';

        $tenantModel = $eventModel->tenant;

        if (! $tenantModel) {
            return ApiError::response(
                'event_not_found',
                'Event not accessible',
                'The selected event is no longer available.',
                Response::HTTP_NOT_FOUND,
                ['scope' => 'photos', 'event_id' => $eventId]
            );
        }

        $violation = $this->packageLimitEvaluator->assessPhotoUpload($tenantModel, $eventId, $eventModel);
        if ($violation !== null) {
            if (($violation['code'] ?? null) === 'photo_limit_exceeded') {
                $this->notifyPhotoLimitReached($eventModel);
            }

            return ApiError::response(
                $violation['code'],
                $violation['title'],
                $violation['message'],
                $violation['status'],
                $violation['meta']
            );
        }

        $eventPackage = $this->packageLimitEvaluator
            ->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel);

        $deviceId = $this->resolveDeviceIdentifier($request);

        $deviceLimit = max(0, (int) ($policy->per_device_upload_limit ?? 50));
        $deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
        if ($deviceLimit > 0 && $deviceCount >= $deviceLimit) {
            $this->notifyDeviceUploadLimit($eventModel, $deviceId, $token, $deviceLimit);

            $this->recordTokenEvent(
                $joinToken,
                $request,
                'upload_device_limit',
                [
                    'event_id' => $eventId,
                    'device_id' => $deviceId,
                    'device_count' => $deviceCount,
                ],
                $token,
                Response::HTTP_TOO_MANY_REQUESTS
            );

            return ApiError::response(
                'upload_device_limit',
                'Device upload limit reached',
                'This device cannot upload more photos for this event.',
                Response::HTTP_TOO_MANY_REQUESTS,
                [
                    'scope' => 'photos',
                    'event_id' => $eventId,
                    'device_id' => $deviceId,
                    'limit' => $deviceLimit,
                    'used' => $deviceCount,
                ]
            );
        }

        $validated = $request->validate([
            'photo' => ['required', 'image', 'mimes:jpeg,jpg,png,webp,heic,heif,gif', 'max:12288'], // 12 MB, block SVG/other image types
            'emotion_id' => ['nullable', 'integer'],
            'emotion_slug' => ['nullable', 'string'],
            'task_id' => ['nullable', 'integer'],
            'guest_name' => ['nullable', 'string', 'max:255'],
            'live_show_opt_in' => ['nullable', 'boolean'],
        ]);

        $file = $validated['photo'];
        $disk = $this->eventStorageManager->getHotDiskForEvent($eventModel);
        $path = Storage::disk($disk)->putFile("events/{$eventId}/photos", $file);
        $watermarkConfig = WatermarkConfigResolver::resolve($eventModel);

        // Generate thumbnail (JPEG) under photos/thumbs
        $baseName = pathinfo($path, PATHINFO_FILENAME);
        $thumbRel = "events/{$eventId}/photos/thumbs/{$baseName}_thumb.jpg";
        $thumbPath = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbRel, 640, 82);
        $thumbUrl = $thumbPath
            ? $this->resolveDiskUrl($disk, $thumbPath)
            : $this->resolveDiskUrl($disk, $path);

        // Create watermarked copies (non-destructive).
        $watermarkedPath = $path;
        $watermarkedThumb = $thumbPath ?: $path;
        if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) {
            $watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventId}/photos/watermarked/{$baseName}.{$file->getClientOriginalExtension()}", $watermarkConfig) ?? $path;
            if ($thumbPath) {
                $watermarkedThumb = ImageHelper::copyWithWatermark(
                    $disk,
                    $thumbPath,
                    "events/{$eventId}/photos/watermarked/{$baseName}_thumb.jpg",
                    $watermarkConfig
                ) ?? $thumbPath;
            } else {
                $watermarkedThumb = $watermarkedPath;
            }
        }

        $url = $this->resolveDiskUrl($disk, $watermarkedPath);
        $thumbUrl = $this->resolveDiskUrl($disk, $watermarkedThumb);

        $liveShowSettings = Arr::get($eventModel->settings ?? [], 'live_show', []);
        $liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
        $liveModerationMode = $liveShowSettings['moderation_mode'] ?? 'manual';
        $liveOptIn = $request->boolean('live_show_opt_in');
        $liveSubmittedAt = null;
        $liveApprovedAt = null;
        $liveReviewedAt = null;
        $liveStatus = PhotoLiveStatus::NONE->value;

        if ($liveOptIn) {
            $liveSubmittedAt = now();
            if ($liveModerationMode === 'off') {
                $liveStatus = PhotoLiveStatus::APPROVED->value;
                $liveApprovedAt = $liveSubmittedAt;
                $liveReviewedAt = $liveSubmittedAt;
            } else {
                $liveStatus = PhotoLiveStatus::PENDING->value;
            }
        }

        $photoId = DB::table('photos')->insertGetId([
            'event_id' => $eventId,
            'tenant_id' => $tenantModel->id,
            'task_id' => $validated['task_id'] ?? null,
            'guest_name' => $validated['guest_name'] ?? $deviceId,
            'created_by_device_id' => $deviceId !== 'anonymous' ? $deviceId : null,
            'file_path' => $url,
            'thumbnail_path' => $thumbUrl,
            'likes_count' => 0,
            'ingest_source' => Photo::SOURCE_GUEST_PWA,
            'status' => $autoApproveUploads ? 'approved' : 'pending',
            'live_status' => $liveStatus,
            'live_submitted_at' => $liveSubmittedAt,
            'live_approved_at' => $liveApprovedAt,
            'live_reviewed_at' => $liveReviewedAt,
            'live_reviewed_by' => null,
            'live_rejection_reason' => null,

            // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
            'emotion_id' => $this->resolveEmotionId($validated, $eventId),
            'is_featured' => 0,
            'metadata' => null,
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        $storedPath = Storage::disk($disk)->path($path);
        $storedSize = Storage::disk($disk)->exists($path) ? Storage::disk($disk)->size($path) : $file->getSize();
        $asset = $this->eventStorageManager->recordAsset($eventModel, $disk, $path, [
            'variant' => 'original',
            'mime_type' => $file->getClientMimeType(),
            'size_bytes' => $storedSize,
            'checksum' => file_exists($storedPath) ? hash_file('sha256', $storedPath) : hash_file('sha256', $file->getRealPath()),
            'status' => 'hot',
            'processed_at' => now(),
            'photo_id' => $photoId,
        ]);

        $watermarkedAsset = null;
        if ($watermarkedPath !== $path) {
            $watermarkedAsset = $this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedPath, [
                'variant' => 'watermarked',
                'mime_type' => $file->getClientMimeType(),
                'size_bytes' => Storage::disk($disk)->exists($watermarkedPath)
                    ? Storage::disk($disk)->size($watermarkedPath)
                    : null,
                'status' => 'hot',
                'processed_at' => now(),
                'photo_id' => $photoId,
                'meta' => [
                    'source_variant_id' => $asset->id,
                ],
            ]);
        }

        if ($thumbPath) {
            $this->eventStorageManager->recordAsset($eventModel, $disk, $thumbPath, [
                'variant' => 'thumbnail',
                'mime_type' => 'image/jpeg',
                'status' => 'hot',
                'processed_at' => now(),
                'photo_id' => $photoId,
                'size_bytes' => Storage::disk($disk)->exists($thumbPath)
                    ? Storage::disk($disk)->size($thumbPath)
                    : null,
                'meta' => [
                    'source_variant_id' => $asset->id,
                ],
            ]);
        }
        if ($watermarkedThumb !== $thumbPath) {
            $this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [
                'variant' => 'watermarked_thumbnail',
                'mime_type' => 'image/jpeg',
                'status' => 'hot',
                'processed_at' => now(),
                'photo_id' => $photoId,
                'size_bytes' => Storage::disk($disk)->exists($watermarkedThumb)
                    ? Storage::disk($disk)->size($watermarkedThumb)
                    : null,
                'meta' => [
                    'source_variant_id' => $watermarkedAsset?->id ?? $asset->id,
                ],
            ]);
        }

        DB::table('photos')
            ->where('id', $photoId)
            ->update(['media_asset_id' => $asset->id]);

        ProcessPhotoSecurityScan::dispatch($photoId);

        if ($eventPackage) {
            $previousUsed = (int) $eventPackage->used_photos;
            $eventPackage->increment('used_photos');
            $eventPackage->refresh();
            $this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1);
        }

        $message = $autoApproveUploads
            ? 'Photo uploaded and visible.'
            : 'Photo uploaded and pending review.';

        $response = response()->json([
            'id' => $photoId,
            'status' => $autoApproveUploads ? 'approved' : 'pending',
            'message' => $message,
        ], 201);

        $this->recordTokenEvent(
            $joinToken,
            $request,
            'upload_completed',
            [
                'event_id' => $eventId,
                'photo_id' => $photoId,
                'device_id' => $deviceId,
            ],
            $token,
            Response::HTTP_CREATED
        );

        event(new GuestPhotoUploaded(
            $eventModel,
            $photoId,
            $deviceId,
            $validated['guest_name'] ?? null,
        ));

        return $response;
    }

    /**
     * Resolve emotion_id from validation data, supporting both direct ID and slug lookup
     */
    private function resolveEmotionId(array $validated, int $eventId): int
    {
        // 1. Use explicit emotion_id if provided
        if (isset($validated['emotion_id']) && $validated['emotion_id'] !== null) {
            return (int) $validated['emotion_id'];
        }

        // 2. Resolve from emotion_slug if provided (format: 'emotion-{id}')
        if (isset($validated['emotion_slug']) && $validated['emotion_slug']) {
            $slug = $validated['emotion_slug'];
            if (str_starts_with($slug, 'emotion-')) {
                $id = (int) substr($slug, 8); // Remove 'emotion-' prefix
                if ($id > 0) {
                    // Verify emotion exists and is available for this event
                    $exists = DB::table('emotions')
                        ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
                        ->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id')
                        ->where('emotions.id', $id)
                        ->where('events.id', $eventId)
                        ->exists();

                    if ($exists) {
                        return $id;
                    }
                }
            }
        }

        // 3. Fallback: Get first available emotion for this event type
        $defaultEmotion = DB::table('emotions')
            ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
            ->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id')
            ->where('events.id', $eventId)
            ->orderBy('emotions.sort_order')
            ->value('emotions.id');

        return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists)
    }

    public function achievements(Request $request, string $identifier)
    {
        $result = $this->resolvePublishedEvent($request, $identifier, ['id', 'default_locale']);

        if ($result instanceof JsonResponse) {
            return $result;
        }

        [$event] = $result;
        $eventId = (int) $event->id;

        [$locale] = $this->resolveGuestLocale($request, $event);
        $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);

        $guestIdentifier = $this->determineGuestIdentifier($request);

        $cacheKey = sprintf(
            'event:%d:achievements:%s:%s',
            $eventId,
            $locale,
            $guestIdentifier ? sha1($guestIdentifier) : 'public'
        );

        $cached = Cache::remember($cacheKey, now()->addSeconds(60), function () use ($eventId, $identifier, $guestIdentifier, $fallbacks) {
            $payload = $this->buildAchievementsPayload($eventId, $identifier, $guestIdentifier, $fallbacks);

            return [
                'payload' => $payload,
                'hash' => sha1(json_encode($payload)),
            ];
        });

        $payload = $cached['payload'];
        $etag = $cached['hash'];
        $ifNoneMatch = $request->headers->get('If-None-Match');

        if ($ifNoneMatch && $ifNoneMatch === $etag) {
            return response('', 304)
                ->header('Cache-Control', 'public, max-age=60')
                ->header('ETag', $etag)
                ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
                ->header('X-Content-Locale', $locale);
        }

        return response()->json($payload)
            ->header('Cache-Control', 'public, max-age=60')
            ->header('ETag', $etag)
            ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
            ->header('X-Content-Locale', $locale);
    }

    private function resolveDiskUrl(string $disk, string $path): string
    {
        try {
            return Storage::disk($disk)->url($path);
        } catch (\Throwable $e) {
            Log::debug('Falling back to raw path for storage URL', [
                'disk' => $disk,
                'path' => $path,
                'error' => $e->getMessage(),
            ]);

            return $path;
        }
    }
}
