<?php

namespace App\Http\Controllers;

use App\Jobs\CompressVideoJob;
use App\Jobs\NasSyncVideoJob;
use App\Mail\NewVideoNotification;
use App\Mail\VideoUploaded;
use App\Notifications\NewVideoUploaded as NewVideoUploadedNotification;
use App\Models\AuditLog;
use App\Models\Playlist;
use App\Models\Setting;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use App\Models\VideoSlide;
use App\Services\GeoIpService;
use FFMpeg\FFMpeg;
use FFMpeg\FFProbe;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class VideoController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
    }

    public function index()
    {
        $filter = request('filter', 'all');

        // ── Playlists-only browse ──────────────────────────────────
        if ($filter === 'playlists') {
            $playlists = Playlist::where('visibility', 'public')
                ->withCount('videos')
                ->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
                ->latest()
                ->limit(60)
                ->get();
            return view('videos.index', compact('playlists', 'filter') + [
                'videos' => collect(), 'shorts' => collect(), 'matches' => collect(),
            ]);
        }

        // ── Shorts-only browse ────────────────────────────────────
        if ($filter === 'shorts') {
            $videos = Video::public()->shorts()->latest()->limit(60)->get();
            return view('videos.index', compact('videos', 'filter') + [
                'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
            ]);
        }

        // ── Sports/Matches-only browse ────────────────────────────
        if ($filter === 'match') {
            $videos = Video::public()->where('type', 'match')->notShorts()->latest()->limit(60)->get();
            return view('videos.index', compact('videos', 'filter') + [
                'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
            ]);
        }

        // ── Filtered single-type views ────────────────────────────
        if (in_array($filter, ['music', 'latest'])) {
            $query = Video::public()->notShorts();
            if ($filter === 'music') $query->where('type', 'music');
            else $query->latest();
            $videos = $query->limit(50)->get();
            return view('videos.index', compact('videos', 'filter') + [
                'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
            ]);
        }

        // ── All (home) — mixed chronological feed ─────────────────
        $videos = Video::public()->latest()->limit(60)->get();

        $playlistQuery = Playlist::withCount('videos')
            ->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
            ->latest()->limit(40);
        $playlistQuery->where('visibility', 'public');

        $playlists = $playlistQuery->where('is_default', false)->get()
            ->filter(fn($pl) => $pl->videos_count > 0)
            ->values();

        // Tag each item with its kind so the view can pick the right card
        $feedItems = $videos->map(fn($v) => ['kind' => 'video', 'item' => $v, 'date' => $v->created_at])
            ->concat($playlists->map(fn($p) => ['kind' => 'playlist', 'item' => $p, 'date' => $p->created_at]))
            ->sortByDesc('date')
            ->values();

        return view('videos.index', compact('feedItems', 'filter') + [
            'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
        ]);
    }

    public function search(Request $request)
    {
        $query = $request->get('q', '');

        if (empty($query)) {
            return redirect()->route('videos.index');
        }

        $matchCondition = function ($q) use ($query) {
            $q->where('title', 'like', "%{$query}%")
              ->orWhere('description', 'like', "%{$query}%");
        };

        $allVideos = Video::public()->where($matchCondition)->latest()->get();
        $videos = $allVideos->where('is_shorts', false)->values();
        $shorts = $allVideos->where('is_shorts', true)->values();

        $playlistQuery = Playlist::where('name', 'like', "%{$query}%")
            ->withCount('videos')
            ->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
            ->latest()
            ->limit(12);

        $playlistQuery->where('visibility', 'public');

        $playlists = $playlistQuery->get();

        return view('videos.index', compact('videos', 'shorts', 'query', 'playlists'));
    }

    public function create()
    {
        return view('videos.create');
    }

    public function store(Request $request)
    {
        try {
        $audioExtensions = ['mp3', 'm4a', 'aac', 'flac', 'wav'];
        $uploadedExt = strtolower($request->file('video')?->getClientOriginalExtension() ?? '');
        $isAudioUpload = in_array($uploadedExt, $audioExtensions);

        $request->validate([
            'title'       => 'required|string|max:255',
            'description' => 'nullable|string',
            'video'       => $isAudioUpload
                ? 'required|file'
                : 'required|file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv',
            'thumbnail'   => $isAudioUpload
                ? 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480'
                : 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480',
            'slides'      => $isAudioUpload ? 'required|array|min:1' : 'nullable',
            'slides.*'    => 'image|mimes:jpg,jpeg,png,webp|max:20480',
            'visibility'          => 'nullable|in:public,unlisted,private',
            'type'                => 'nullable|in:generic,music,match',
            'download_access'     => 'nullable|in:disabled,everyone,registered,subscribers',
            'primary_language'    => 'nullable|string|max:10',
            'extra_track_files'   => 'nullable|array',
            'extra_track_files.*' => 'file',
            'extra_track_languages'   => 'nullable|array',
            'extra_track_languages.*' => 'nullable|string|max:10',
            'extra_track_titles'        => 'nullable|array',
            'extra_track_titles.*'      => 'nullable|string|max:255',
            'extra_track_descriptions'  => 'nullable|array',
            'extra_track_descriptions.*'=> 'nullable|string',
            // Optional per-extra-track slides. Sent as extra_track_slides[i][] = file.
            // If absent for a given index, the track inherits the primary's slides
            // at render time via Video::slidesForTrack().
            'extra_track_slides'        => 'nullable|array',
            'extra_track_slides.*'      => 'nullable|array',
            'extra_track_slides.*.*'    => 'image|mimes:jpg,jpeg,png,webp|max:20480',
        ]);

        $videoFile = $request->file('video');
        $filename = self::generateFilename($videoFile->getClientOriginalExtension());
        $path = $videoFile->storeAs('public/videos', $filename);

        // Get file info
        $fileSize = $videoFile->getSize();
        $mimeType = $videoFile->getMimeType();

        $thumbnailPath = null;
        $slideFiles    = [];

        if ($isAudioUpload && $request->hasFile('slides')) {
            // Audio upload: save all slides; first slide becomes the thumbnail
            foreach ($request->file('slides') as $slideFile) {
                $fname = self::generateFilename($slideFile->getClientOriginalExtension());
                $slideFile->storeAs('public/thumbnails', $fname);
                $slideFiles[] = $fname;
            }
            $thumbnailPath = 'public/thumbnails/' . $slideFiles[0];
        } elseif ($request->hasFile('thumbnail')) {
            $thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
            $thumbnailPath = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
        } elseif (! $isAudioUpload) {
            try {
                $ffmpeg = FFMpeg::create();
                $videoPath = storage_path('app/'.$path);

                if (file_exists($videoPath)) {
                    $video = $ffmpeg->open($videoPath);
                    $frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(1));

                    $thumbFilename = Str::uuid().'.jpg';
                    $thumbFullPath = storage_path('app/public/thumbnails/'.$thumbFilename);

                    if (! file_exists(storage_path('app/public/thumbnails'))) {
                        mkdir(storage_path('app/public/thumbnails'), 0755, true);
                    }

                    $frame->save($thumbFullPath);
                    $thumbnailPath = 'public/thumbnails/'.$thumbFilename;
                }
            } catch (\Exception $e) {
                \Log::error('FFmpeg thumbnail error: '.$e->getMessage());
            }
        }

        $width = null;
        $height = null;
        $orientation = 'landscape';

        if (! $isAudioUpload) {
            try {
                $ffprobe = FFProbe::create();
                $videoPath = storage_path('app/'.$path);

                if (file_exists($videoPath)) {
                    $streams = $ffprobe->streams($videoPath);
                    $videoStream = $streams->videos()->first();

                    if ($videoStream) {
                        $width = $videoStream->get('width');
                        $height = $videoStream->get('height');

                        if ($width && $height) {
                            if ($height > $width) $orientation = 'portrait';
                            elseif ($width > $height) $orientation = 'landscape';
                            else $orientation = 'square';
                        }
                    }
                }
            } catch (\Exception $e) {
                \Log::error('FFprobe error: '.$e->getMessage());
            }
        }

        $video = Video::create([
            'user_id' => Auth::id(),
            'title' => $request->title,
            'description' => \App\Support\HtmlSanitizer::clean($request->description),
            'filename' => $filename,
            'path' => $path,
            'thumbnail' => $thumbnailPath ? basename($thumbnailPath) : null,
            'size' => $fileSize,
            'mime_type' => $mimeType,
            'orientation' => $orientation,
            'width' => $width,
            'height' => $height,
            'status' => $isAudioUpload ? 'ready' : 'processing',
            'visibility'      => $request->visibility ?? 'public',
            'type'            => $isAudioUpload ? 'music' : ($request->type ?? 'generic'),
            'download_access' => $request->input('download_access', 'disabled'),
            'share_token'     => Str::random(32),
            'language'        => $request->input('primary_language') ?: null,
        ]);

        // Save individual slide records for audio uploads with multiple images
        foreach ($slideFiles as $position => $fname) {
            VideoSlide::create([
                'video_id' => $video->id,
                'filename' => $fname,
                'position' => $position,
            ]);
        }

        $nas = app(\App\Services\NasSyncService::class);

        $nasUploadSucceeded = false;

        if ($nas->isEnabled()) {
            // ── NAS-primary: push directly to NAS, delete local temp files ──
            try {
                $video->load('slides');

                // Build slide abs-paths map for audio uploads
                $slideAbsPaths = [];
                foreach ($slideFiles as $pos => $fname) {
                    $slideAbsPaths[$pos] = storage_path('app/public/thumbnails/' . $fname);
                }

                $tempThumbAbs = $thumbnailPath ? storage_path('app/' . $thumbnailPath) : null;
                // For audio, thumbnail IS slide 0 — don't pass separately (handled via slides)
                if ($isAudioUpload) $tempThumbAbs = null;

                $nas->uploadDirectToNas(
                    $video,
                    storage_path('app/' . $path),
                    $tempThumbAbs,
                    $slideAbsPaths
                );
                $video->refresh();
                $nasUploadSucceeded = true;
            } catch (\Throwable $e) {
                \Log::error('uploadDirectToNas failed (falling back to local): ' . $e->getMessage());
                // NAS went down mid-upload — organise the surviving local files below
            }
        }

        if ($nasUploadSucceeded) {
            // For non-audio: HLS generation still runs (downloads from NAS, keeps HLS local)
            if (! $isAudioUpload) {
                \App\Jobs\GenerateHlsJob::dispatch($video->fresh())
                    ->onQueue('video-processing')
                    ->onConnection('database');
            }
        } else {
            // ── Local storage: move into NAS-mirrored local directory schema ──
            try {
                $video->load('slides');
                $nas->organizeLocalFiles($video);
                $video->refresh();
            } catch (\Throwable $e) {
                \Log::error('organizeLocalFiles failed: ' . $e->getMessage());
            }

            // Compress + HLS pipeline for local storage
            if (! $isAudioUpload) {
                CompressVideoJob::dispatch($video)
                    ->onQueue('video-processing')
                    ->onConnection('database');
            }
        }

        // ── Extra language audio tracks ───────────────────────────────────────
        if ($isAudioUpload && $request->hasFile('extra_track_files')) {
            $nas        = app(\App\Services\NasSyncService::class);
            $trackFiles  = $request->file('extra_track_files');
            $trackLangs  = $request->input('extra_track_languages', []);
            $trackTitles = $request->input('extra_track_titles', []);
            $trackDescs  = $request->input('extra_track_descriptions', []);
            $trackSlides = $request->file('extra_track_slides') ?: [];

            foreach ($trackFiles as $i => $trackFile) {
                if (! $trackFile || ! $trackFile->isValid()) continue;

                $lang  = $trackLangs[$i] ?? 'en';
                $ext   = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
                $title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
                $desc  = !empty($trackDescs[$i]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : null;

                // Create a placeholder record to get the DB ID for the filename
                $track = VideoAudioTrack::create([
                    'video_id'    => $video->id,
                    'language'    => $lang,
                    'label'       => strtoupper($lang),
                    'title'       => $title,
                    'description' => $desc,
                    'path'        => '__pending__',
                    'filename'    => '__pending__',
                ]);

                if ($nas->isEnabled()) {
                    try {
                        // Extra music track → its own folder under tracks/{lang-id}/audio.{ext}
                        $trackDir = $nas->trackDir($video, $track);
                        $nas->mkdirp($trackDir);
                        $canonical = "audio.{$ext}";
                        $nasPath   = "{$trackDir}/{$canonical}";

                        $tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
                        $tempAbs  = storage_path('app/' . $tempPath);
                        $nas->putFile($tempAbs, $nasPath);
                        @unlink($tempAbs);

                        $track->update([
                            'path'     => $nasPath,
                            'filename' => $canonical,
                        ]);
                    } catch (\Throwable $e) {
                        \Log::error("Extra track NAS upload failed: " . $e->getMessage());
                        // Fall back to local storage
                        $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
                    }
                } else {
                    $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
                }

                // ── Optional per-track slides ──────────────────────────────
                // The track only owns slides that were uploaded for it. If none
                // were uploaded, the player falls back to the primary's at render
                // time via Video::slidesForTrack — no row needed here.
                $files = $trackSlides[$i] ?? null;
                if (is_array($files) && count($files) > 0) {
                    $this->storeTrackSlides($video, $track, $files, $nas);
                }
            }
        }

        // ── Synced lyrics generation (audio/music uploads only) ───────────────
        if ($isAudioUpload) {
            \App\Jobs\GenerateLyricsJob::dispatch($video->id, null)->onConnection('database');
            foreach ($video->audioTracks()->pluck('id') as $tid) {
                \App\Jobs\GenerateLyricsJob::dispatch($video->id, (int) $tid)->onConnection('database');
            }
        }

        $video->load('user');

        $userEmail = Auth::user()->email;
        $userName  = Auth::user()->name;

        $uploader         = Auth::user();
        $subscribers      = $uploader->subscribers()->get();
        $subscriberEmails = $subscribers->pluck('email')->toArray();

        // In-app DB notifications (fast — just inserts, no network)
        if ($video->visibility === 'public') {
            foreach ($subscribers as $subscriber) {
                try {
                    $subscriber->notify(new NewVideoUploadedNotification($video, $uploader));
                } catch (\Throwable $e) {
                    \Log::error('In-app notification error: '.$e->getMessage());
                }
            }
        }

        app()->terminating(function () use ($video, $userEmail, $userName, $uploader, $subscriberEmails) {
            // Confirm upload to the uploader
            try {
                Mail::to($userEmail)->send(new VideoUploaded($video, $userName));
            } catch (\Throwable $e) {
                \Log::error('Email error: '.$e->getMessage());
            }

            // Email subscribers (only for public videos)
            if ($video->visibility === 'public' && count($subscriberEmails) > 0) {
                foreach ($subscriberEmails as $email) {
                    try {
                        Mail::to($email)->send(new NewVideoNotification($video, $uploader));
                    } catch (\Throwable $e) {
                        \Log::error('Subscriber notification error: '.$e->getMessage());
                    }
                }
            }
        });

        AuditLog::record('video.uploaded', [
            'subject_type'  => 'Video',
            'subject_id'    => (string) $video->id,
            'subject_label' => $video->title,
            'details'       => ['type' => $video->type, 'visibility' => $video->visibility],
        ]);

        return response()->json([
            'success' => true,
            'video_id' => $video->id,
            'redirect' => route('videos.show', $video),
        ]);
        } catch (\Throwable $e) {
            \Log::error('Video store error: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
            return response()->json([
                'success' => false,
                'message' => $e->getMessage(),
            ], 500);
        }
    }

    public function showByToken(Request $request, string $token)
    {
        $video = Video::where('share_token', $token)->firstOrFail();
        if (! $video->canView(Auth::user())) {
            abort(404);
        }
        return $this->show($request, $video);
    }

    public function show(Request $request, Video $video)
    {
        if (! $video->canView(Auth::user())) {
            $message = $video->isPrivate()
                ? 'This video is private.'
                : 'This video is no longer available.';
            return redirect('/')->with('toast_error', $message);
        }

        $ip  = $request->header('CF-Connecting-IP')
            ?? $request->header('X-Real-IP')
            ?? $request->ip();
        $geo = GeoIpService::lookup($ip);

        // Persistent client-side device ID (set in the response cookie below).
        // Survives IP/country changes so a guest on a VPN doesn't look like several different guests.
        $viewDid = $request->cookie('_did') ?: (string) Str::uuid();

        // Device fingerprint hash (set client-side by /fp.js after first paint).
        // Stronger than the cookie alone — survives cookie clears, incognito, browser swaps.
        // Null on the very first visit; the JS will call /identify to backfill it.
        $viewFp = $request->cookie('_fp');
        $viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null;

        if (Auth::check()) {
            $exists = \DB::table('video_views')
                ->where('user_id', Auth::id())
                ->where('video_id', $video->id)
                ->where('watched_at', '>', now()->subHour())
                ->exists();

            if (! $exists) {
                \DB::table('video_views')->insert([
                    'user_id'      => Auth::id(),
                    'video_id'     => $video->id,
                    'ip_address'   => $ip,
                    'country'      => $geo['country'],
                    'country_name' => $geo['country_name'],
                    'user_agent'   => substr((string) $request->userAgent(), 0, 512),
                    'device_id'    => $viewDid,
                    'device_hash'  => $viewFp,
                    'watched_at'   => now(),
                ]);
            }
        } else {
            // Guest: prefer the fingerprint hash for dedup (strongest signal); fall back to device_id cookie
            $exists = \DB::table('video_views')
                ->whereNull('user_id')
                ->where('video_id', $video->id)
                ->where('watched_at', '>', now()->subHour())
                ->where(function ($q) use ($viewFp, $viewDid) {
                    if ($viewFp) $q->where('device_hash', $viewFp);
                    else         $q->where('device_id', $viewDid);
                })
                ->exists();

            if (! $exists) {
                \DB::table('video_views')->insert([
                    'user_id'      => null,
                    'video_id'     => $video->id,
                    'ip_address'   => $ip,
                    'country'      => $geo['country'],
                    'country_name' => $geo['country_name'],
                    'user_agent'   => substr((string) $request->userAgent(), 0, 512),
                    'device_id'    => $viewDid,
                    'device_hash'  => $viewFp,
                    'watched_at'   => now(),
                ]);
            }
        }

        $video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews', 'slides', 'audioTracks']);

        // Version-aware share metadata: when the link carries ?track={id}, the OG/Twitter
        // tags and <title> reflect that language track (so a shared English version shows
        // the English title, not the primary). Falls back to the primary when unset.
        $shareTitle       = $video->title;
        $shareDescription = $video->description;
        if ($shareTrackId = (int) $request->input('track', 0)) {
            $shareTrack = $video->audioTracks->firstWhere('id', $shareTrackId);
            if ($shareTrack) {
                if (! empty($shareTrack->title))       $shareTitle       = $shareTrack->title;
                if (! empty($shareTrack->description)) $shareDescription = $shareTrack->description;
            }
        }

        $playlist = null;
        $nextVideo = null;
        $previousVideo = null;
        $playlistVideos = null;

        $playlistParam = $request->query('playlist');
        if ($playlistParam) {
            $playlist = Playlist::where('share_token', $playlistParam)->first();
            if ($playlist && $playlist->canViewViaToken(Auth::user())) {
                // Load the videos ONCE with their owners eager-loaded, then
                // compute prev/next in PHP. The old code fired 4+ separate
                // queries for prev/next/list — the sidebar lag the user
                // reported was almost entirely those extra round-trips.
                $playlistVideos = $playlist->videos()->with('user')->orderBy('position')->get();
                [$previousVideo, $nextVideo] = $playlist->neighborsFromCollection($playlistVideos, $video);

                // Count the playlist view (deduped per device, 1-hour window)
                // after the response is flushed so we don't pay the round-trip
                // on the hot path.
                dispatch(function () use ($playlist, $request) {
                    $playlist->bumpViewIfNew($request);
                })->afterResponse();
            }
        }

        $recommendedVideos = Video::public()
            ->where('id', '!=', $video->id)
            ->latest()
            ->limit(20)
            ->get();

        $view = match ($video->type) {
            'match' => 'videos.types.match',
            'music' => 'videos.types.music',
            default => 'videos.types.generic',
        };

        // Refresh the persistent device-ID cookie (5-year window) — same value used above for video_views dedup
        return response()
            ->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos', 'shareTitle', 'shareDescription', 'shareTrackId'))
            ->header('Cache-Control', 'no-store, no-cache, must-revalidate')
            ->withCookie(cookie('_did', $viewDid, 60 * 24 * 365 * 5));
    }

    public function playerData(Video $video, Request $request)
    {
        if (! $video->canView(Auth::user())) {
            abort(403);
        }

        $coverUrl = $video->thumbnail
            ? route('media.thumbnail', $video->thumbnail)
            : asset('storage/images/logo.png');

        // Per-track slide map (key "0" = primary). Each entry already has the
        // sharing fallback applied by Video::slidesForTrack — a track without its
        // own slides borrows the primary's (or a sibling's) automatically.
        $slideMap = ['0' => $video->slidesForTrack(null)
            ->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all()];
        foreach ($video->audioTracks as $_t) {
            $slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
                ->map(fn ($s) => route('media.thumbnail', $s->filename))->values()->all();
        }
        $slides = $slideMap['0'];

        $allLangData = \App\Data\Languages::all();
        $audioTracks = $video->audioTracks->map(fn ($t) => [
            'id'          => $t->id,
            'language'    => $t->language,
            'label'       => $t->label,
            'flag'        => $allLangData[$t->language]['flag'] ?? null,
            'stream_url'  => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp,
            'title'       => $t->title ?? '',
            'description' => $t->description ?? '',
            'dl_url'      => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
        ])->values()->all();

        // Synced lyrics embedded inline (no separate request), keyed by track id; "0" = primary.
        // Local mirror only — must not block this hot path on NAS I/O.
        $nasLyrics = app(\App\Services\NasSyncService::class);
        $lyricsMap = ['0' => $nasLyrics->getLocalLyrics($video, null)];
        foreach ($video->audioTracks as $t) {
            $lyricsMap[(string) $t->id] = $nasLyrics->getLocalLyrics($video, $t);
        }

        return response()->json([
            'id'            => $video->id,
            'key'           => $video->getRouteKey(),
            'type'          => $video->type,
            'has_hls'       => (bool) $video->has_hls,
            'hls_url'       => $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null,
            'stream_url'    => route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp,
            'cover_url'     => $coverUrl,
            'slides'        => $slides,
            'slide_map'     => $slideMap,
            'title'         => $video->title,
            'author'        => $video->user->name ?? '',
            'duration'      => $video->duration,
            'description'   => $video->description ?? '',
            'download_url'  => route('videos.downloadMp3', $video),
            'language'      => $video->language,
            'language_flag' => $video->language ? ($allLangData[$video->language]['flag'] ?? null) : null,
            'audio_tracks'  => $audioTracks,
            'lyrics'        => $lyricsMap,
        ]);
    }

    /**
     * Owner-triggered lyrics generation for the current audio track (?track={id},
     * 0/absent = primary). Dispatches the GPU pipeline to the queue; the player
     * polls player-data and shows the lyrics once the file lands.
     */
    public function generateLyrics(Request $request, Video $video)
    {
        if (Auth::id() !== $video->user_id) {
            abort(403);
        }
        if (\App\Models\Setting::get('lyrics_enabled', 'true') !== 'true') {
            return response()->json(['error' => 'Lyrics generation is currently disabled by the administrator.'], 422);
        }
        if (! $this->isAudioOnlyFile($video)) {
            return response()->json(['error' => 'Lyrics are only for audio tracks.'], 422);
        }

        $trackId = (int) $request->input('track', 0);
        if ($trackId) {
            $track = $video->audioTracks()->find($trackId);
            if (! $track) {
                return response()->json(['error' => 'Track not found.'], 404);
            }
        }

        \App\Jobs\GenerateLyricsJob::dispatch($video->id, $trackId ?: null)->onConnection('database');

        return response()->json(['status' => 'queued']);
    }

    /**
     * Owner-triggered delete of the saved lyrics for a track. Wipes the local
     * mirror + the NAS copy and removes any in-flight progress / temp files,
     * so the next Generate produces a fresh result.
     */
    public function deleteLyrics(Request $request, Video $video)
    {
        if (Auth::id() !== $video->user_id) {
            abort(403);
        }

        $trackId = (int) $request->input('track', 0);
        $track   = null;
        if ($trackId) {
            $track = $video->audioTracks()->find($trackId);
            if (! $track) {
                return response()->json(['error' => 'Track not found.'], 404);
            }
        }

        app(\App\Services\NasSyncService::class)->deleteLyrics($video, $track);

        // Clear any in-flight progress / temp artifacts so a queued job that
        // fires later can't repopulate stale output.
        @unlink(\App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null));
        @unlink(storage_path('app/tmp/lyrics_' . $video->id . '_' . ($trackId ?: 'primary') . '.json'));

        return response()->json(['status' => 'deleted']);
    }

    /**
     * Live progress for an in-flight lyrics generation, driving the player's
     * progress bar. Returns {status: ready|failed|processing|none, pct, stage}.
     */
    public function lyricsProgress(Request $request, Video $video)
    {
        if (! $video->canView(Auth::user())) {
            abort(403);
        }

        $trackId = (int) $request->input('track', 0);
        $track   = null;
        if ($trackId) {
            $track = $video->audioTracks()->find($trackId);
            if (! $track) return response()->json(['status' => 'none']);
        }

        $data = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $track);
        if (is_array($data)) {
            $st = $data['status'] ?? 'ready';
            if ($st === 'ready' && ! empty($data['lines'])) return response()->json(['status' => 'ready', 'pct' => 100]);
            if ($st === 'failed') return response()->json(['status' => 'failed']);
        }

        // Live percentage from the pipeline's progress file.
        $progFile = \App\Jobs\GenerateLyricsJob::progressPath($video->id, $trackId ?: null);
        if (is_file($progFile)) {
            $p = json_decode((string) file_get_contents($progFile), true);
            if (is_array($p)) {
                return response()->json([
                    'status' => 'processing',
                    'pct'    => (int) ($p['pct'] ?? 1),
                    'stage'  => $p['stage'] ?? 'Working',
                ]);
            }
        }

        // A 'processing' lyrics marker but no progress file yet → just queued.
        if (is_array($data)) return response()->json(['status' => 'processing', 'pct' => 1, 'stage' => 'Queued']);

        return response()->json(['status' => 'none']);
    }

    /**
     * Owner-edited lyrics save. Receives the (possibly corrected) lines for a
     * track; preserves precise word timing for lines that weren't changed and
     * redistributes timing evenly across the new words for edited lines.
     */
    public function saveLyrics(Request $request, Video $video)
    {
        if (Auth::id() !== $video->user_id) {
            abort(403);
        }

        $trackId = (int) $request->input('track', 0);
        $track   = null;
        if ($trackId) {
            $track = $video->audioTracks()->find($trackId);
            if (! $track) return response()->json(['error' => 'Track not found.'], 404);
        }

        $nas      = app(\App\Services\NasSyncService::class);
        $existing = $nas->getLocalLyrics($video, $track) ?: [];
        $spaceless = ['th', 'zh', 'ja', 'lo', 'my', 'km', 'yue', 'wuu'];

        $inLines = $request->input('lines', []);
        if (! is_array($inLines)) $inLines = [];

        $out = [];
        foreach ($inLines as $ln) {
            $text = trim(strip_tags((string) ($ln['text'] ?? '')));
            if ($text === '' || mb_strlen($text) > 1000) {
                if ($text === '') continue;
                $text = mb_substr($text, 0, 1000);
            }
            $start = round((float) ($ln['start'] ?? 0), 3);
            $end   = round((float) ($ln['end'] ?? $start), 3);
            $lang  = (string) ($ln['lang'] ?? ($existing['language'] ?? 'en'));
            $isSpaceless = in_array($lang, $spaceless, true);

            // Keep original word timings if the text wasn't changed; otherwise
            // redistribute the line's span evenly across the new tokens.
            $origWords = (isset($ln['words']) && is_array($ln['words'])) ? $ln['words'] : [];
            $sep = $isSpaceless ? '' : ' ';
            $joined = implode($sep, array_map(fn ($w) => (string) ($w['text'] ?? ''), $origWords));
            $unchanged = $origWords
                && preg_replace('/\s+/u', '', $joined) === preg_replace('/\s+/u', '', $text);

            if ($unchanged) {
                $words = array_map(fn ($w) => [
                    'start' => round((float) ($w['start'] ?? $start), 3),
                    'end'   => round((float) ($w['end'] ?? $end), 3),
                    'text'  => (string) ($w['text'] ?? ''),
                ], $origWords);
            } else {
                $words = $this->redistributeWords($start, $end, $text, $isSpaceless);
            }

            $out[] = ['start' => $start, 'end' => $end, 'text' => $text, 'lang' => $lang, 'words' => $words];
            if (count($out) >= 1000) break;
        }

        usort($out, fn ($a, $b) => $a['start'] <=> $b['start']);

        $nas->putLyrics($video, $track, [
            'version'      => 1,
            'status'       => 'ready',
            'source'       => 'edited',
            'language'     => $existing['language'] ?? ($out[0]['lang'] ?? 'en'),
            'multilingual' => $existing['multilingual'] ?? false,
            'lines'        => $out,
            'generated_at' => now()->toIso8601String(),
        ]);

        return response()->json(['status' => 'ok', 'lines' => count($out)]);
    }

    /** Evenly distribute a line's [start,end] across its words (used for edited lines). */
    private function redistributeWords(float $start, float $end, string $text, bool $spaceless): array
    {
        $text = trim($text);
        if ($text === '' || $end <= $start) {
            return $text === '' ? [] : [['start' => $start, 'end' => max($end, $start + 0.5), 'text' => $text]];
        }
        $tokens = $spaceless
            ? preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY)
            : preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
        $tokens = array_values(array_filter($tokens, fn ($t) => trim($t) !== ''));
        $n = count($tokens);
        if ($n === 0) return [];
        $slice = ($end - $start) / $n;
        $words = [];
        foreach ($tokens as $i => $tok) {
            $words[] = [
                'start' => round($start + $i * $slice, 3),
                'end'   => round($start + ($i + 1) * $slice, 3),
                'text'  => $tok,
            ];
        }
        return $words;
    }

    public function matchData(Video $video)
    {
        if (! $video->canView(Auth::user())) {
            abort(403);
        }

        return response()->json([
            'success' => true,
            'rounds' => $video->matchRounds()->with('points')->orderBy('round_number')->get(),
            'reviews' => $video->coachReviews()->orderBy('start_time_seconds')->get(),
        ]);
    }

    public function edit(Video $video, Request $request)
    {
        if (Auth::id() !== $video->user_id) {
            abort(403);
        }

        if (! $request->expectsJson() && ! $request->ajax()) {
            return view('videos.edit', compact('video'));
        }

        $slides = $video->slides()->orderBy('position')->get()->map(fn($s) => [
            'id'              => $s->id,
            'url'             => $s->url,
            'audio_track_id'  => $s->audio_track_id, // null = primary / song-wide
        ])->values();

        $audioTracks = $video->audioTracks->map(fn ($t) => [
            'id'          => $t->id,
            'language'    => $t->language,
            'label'       => $t->label,
            'title'       => $t->title       ?? '',
            'description' => $t->description ?? '',
            'stream_url'  => route('videos.audio-track', ['video' => $video, 'track' => $t->id]),
        ])->values();

        return response()->json([
            'success' => true,
            'video' => [
                'id'             => $video->id,
                'title'          => $video->title,
                'description'    => $video->description,
                'thumbnail'      => $video->thumbnail,
                'thumbnail_url'  => $video->thumbnail_url,
                'visibility'     => $video->visibility ?? 'public',
                'type'           => $video->type ?? 'generic',
                'download_access' => $video->download_access,
                'is_audio'       => $this->isAudioOnlyFile($video),
                'slides'         => $slides,
                'language'       => $video->language,
                'audio_tracks'   => $audioTracks,
            ],
        ]);
    }

    public function update(Request $request, Video $video)
    {
        if (Auth::id() !== $video->user_id) {
            abort(403);
        }

        $request->validate([
            'title'               => 'required|string|max:255',
            'description'         => 'nullable|string',
            'thumbnail'           => 'nullable|image|mimes:jpg,jpeg,png,webp|max:20480',
            'visibility'          => 'nullable|in:public,unlisted,private',
            'type'                => 'nullable|in:generic,music,match',
            'download_access'     => 'nullable|in:disabled,everyone,registered,subscribers',
            'slides_add'          => 'nullable|array',
            'slides_add.*'        => 'image|mimes:jpg,jpeg,png,webp|max:20480',
            'primary_language'    => 'nullable|string|max:10',
            'extra_track_files'   => 'nullable|array',
            'extra_track_files.*' => 'file',
            'extra_track_languages' => 'nullable|array',
            'extra_track_languages.*' => 'nullable|string|max:10',
            'extra_track_titles'  => 'nullable|array',
            'extra_track_titles.*'=> 'nullable|string|max:255',
            'track_title_updates'          => 'nullable|array',
            'track_title_updates.*'        => 'nullable|string|max:255',
            'track_description_updates'    => 'nullable|array',
            'track_description_updates.*'  => 'nullable|string',
            'track_language_updates'       => 'nullable|array',
            'track_language_updates.*'     => 'nullable|string|max:10',
            'track_file_updates'           => 'nullable|array',
            'track_file_updates.*'         => 'nullable|file',
            'promote_track_id'    => 'nullable|integer',
            'delete_track_ids'    => 'nullable|array',
            'delete_track_ids.*'  => 'integer',
        ]);

        $oldTitle = $video->title;

        $data = $request->only(['title', 'description', 'visibility', 'type']);
        if (array_key_exists('description', $data)) {
            $data['description'] = \App\Support\HtmlSanitizer::clean($data['description']);
        }
        $data['download_access'] = $request->input('download_access', 'disabled');
        if ($request->has('primary_language')) {
            $data['language'] = $request->input('primary_language') ?: null;
        }

        if ($request->hasFile('thumbnail')) {
            if ($video->thumbnail) {
                Storage::delete($video->thumbnailStorageKey());
            }
            $nas = app(\App\Services\NasSyncService::class);
            $ext = strtolower($request->file('thumbnail')->getClientOriginalExtension() ?: 'jpg');

            if ($nas->isEnabled()) {
                // Push directly to NAS — use video root dir (handles promoted-track paths too)
                $nasDir = $this->nasVideoDir($video, $nas);
                $tempPath = $request->file('thumbnail')->storeAs('public/tmp', "thumb_{$video->id}.{$ext}");
                $tempAbs  = storage_path('app/' . $tempPath);
                $nas->mkdirp($nasDir);
                if ($nas->putFile($tempAbs, "{$nasDir}/thumb.{$ext}")) {
                    @unlink($tempAbs);
                    $data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
                } else {
                    // NAS push failed — fall back to local (auto-sync will retry)
                    $localDir = $nas->localVideoDir($video);
                    @mkdir($localDir, 0755, true);
                    rename($tempAbs, "{$localDir}/thumb.{$ext}");
                    $userSlug = $nas->userSlug($video->user);
                    $data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
                }
            } else {
                // NAS disabled — keep on local disk, mirroring the NAS path
                $nasDir   = $this->nasVideoDir($video, $nas);
                $localDir = storage_path('app/' . $nasDir);
                @mkdir($localDir, 0755, true);
                $request->file('thumbnail')->move($localDir, "thumb.{$ext}");
                $data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
            }
        }

        if (! isset($data['visibility'])) {
            unset($data['visibility']);
        }

        // Handle slide reorder / removal / additions for audio tracks
        $slidesChanged = false;
        if ($this->isAudioOnlyFile($video)) {
            // slides_order is a JSON array of kept slide IDs in their new order
            $keptOrder = json_decode($request->input('slides_order', '[]'), true) ?: [];

            // Delete slides not in the kept list
            $video->slides()->whereNotIn('id', $keptOrder)->each(function ($slide) use (&$slidesChanged) {
                $nas = app(\App\Services\NasSyncService::class);
                if ($nas->isEnabled() && str_starts_with($slide->filename, 'users/')) {
                    try { $nas->deleteFile($slide->filename); } catch (\Throwable $e) {}
                }
                Storage::delete($slide->storageKey());
                $slide->delete();
                $slidesChanged = true;
            });

            // Reorder kept slides
            foreach ($keptOrder as $pos => $slideId) {
                VideoSlide::where('id', $slideId)->where('video_id', $video->id)
                    ->update(['position' => $pos]);
            }
            if (! empty($keptOrder)) $slidesChanged = true;

            // Add new slides
            if ($request->hasFile('slides_add')) {
                $nextPos     = count($keptOrder);
                $nasForSlides = app(\App\Services\NasSyncService::class);
                $nasEnabled  = $nasForSlides->isEnabled();

                // Derive NAS dir without scanning — works regardless of NAS state
                $relBase  = $this->nasVideoDir($video, $nasForSlides);
                $nasDir   = $nasEnabled ? $relBase : null;

                // Local dir mirrors the NAS path so MediaController can find files
                // whether NAS is reachable or not.
                $localDir = storage_path('app/' . $relBase);

                if ($nasEnabled) {
                    $nasForSlides->mkdirp("{$nasDir}/slides");
                } else {
                    @mkdir("{$localDir}/slides", 0755, true);
                }

                foreach ($request->file('slides_add') as $file) {
                    $ext   = strtolower($file->getClientOriginalExtension() ?: 'jpg');
                    $slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);

                    if ($nasEnabled) {
                        // Push directly to NAS using position-based filename (matches MediaController)
                        $nasSlide = "{$nasDir}/slides/{$nextPos}.{$ext}";
                        $tempPath = $file->storeAs('public/tmp', "slide_{$slide->id}.{$ext}");
                        $tempAbs  = storage_path('app/' . $tempPath);
                        if ($nasForSlides->putFile($tempAbs, $nasSlide)) {
                            @unlink($tempAbs);
                            $slide->update(['filename' => $nasSlide]);
                        } else {
                            // NAS push failed — save locally, auto-sync will retry
                            @mkdir("{$localDir}/slides", 0755, true);
                            rename($tempAbs, "{$localDir}/slides/{$slide->id}.{$ext}");
                            $slide->update(['filename' => "{$relBase}/slides/{$slide->id}.{$ext}"]);
                        }
                    } else {
                        // NAS off — store in local project folder
                        $file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
                        $slide->update(['filename' => "{$relBase}/slides/{$slide->id}.{$ext}"]);
                    }

                    $nextPos++;
                    $slidesChanged = true;
                }
            }

            // Keep thumbnail in sync with the first slide
            $firstSlide = $video->slides()->orderBy('position')->first();
            if ($firstSlide) {
                $data['thumbnail'] = $firstSlide->filename;
            }

            // Invalidate cached slideshow videos (plain + visualizer) whenever slides change
            if ($slidesChanged) {
                // Slides feed every rendered variant (all tracks + visualizer) — wipe them all.
                $cacheDir = dirname(storage_path('app/' . $this->slideshowRel($video, false, 0)));
                foreach (glob($cacheDir . '/video*.mp4') ?: [] as $f) @unlink($f);
                $data['slideshow_video_path'] = null;
            }
        }

        // ── Audio track management (delete + add) ────────────────────────────
        if ($this->isAudioOnlyFile($video)) {
            $nas = app(\App\Services\NasSyncService::class);

            // Delete tracks requested for removal
            $deleteIds = $request->input('delete_track_ids', []);
            if (! empty($deleteIds)) {
                $video->audioTracks()->whereIn('id', $deleteIds)->each(function ($track) use ($nas) {
                    if ($track->path !== '__pending__') {
                        if ($nas->isEnabled() && str_starts_with($track->path, 'users/')) {
                            try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
                        } else {
                            @unlink($track->localPath());
                        }
                    }
                    $track->delete();
                });
            }

            // Update titles, descriptions, and languages of existing tracks
            $titleUpdates    = $request->input('track_title_updates', []);
            $descUpdates     = $request->input('track_description_updates', []);
            $languageUpdates = $request->input('track_language_updates', []);
            $allTrackIds     = array_unique(array_merge(
                array_keys($titleUpdates), array_keys($descUpdates), array_keys($languageUpdates)
            ));
            foreach ($allTrackIds as $trackId) {
                $fields = [];
                if (array_key_exists($trackId, $titleUpdates))    $fields['title']       = $titleUpdates[$trackId] ?: null;
                if (array_key_exists($trackId, $descUpdates))     $fields['description'] = \App\Support\HtmlSanitizer::clean($descUpdates[$trackId]) ?: null;
                if (array_key_exists($trackId, $languageUpdates) && $languageUpdates[$trackId]) {
                    $fields['language'] = $languageUpdates[$trackId];
                    $fields['label']    = strtoupper($languageUpdates[$trackId]);
                }
                if ($fields) $video->audioTracks()->where('id', (int) $trackId)->update($fields);
            }

            // Replace audio files for existing tracks
            if ($request->hasFile('track_file_updates')) {
                foreach ($request->file('track_file_updates') as $trackId => $trackFile) {
                    if (! $trackFile || ! $trackFile->isValid()) continue;
                    $track = $video->audioTracks()->find((int) $trackId);
                    if (! $track) continue;
                    $ext = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
                    if ($nas->isEnabled()) {
                        try {
                            $videoDir  = $this->nasVideoDir($video, $nas);
                            $nas->mkdirp($videoDir);
                            $trackName = $this->audioTrackName(basename($videoDir), $track->language, $track->id, $ext);
                            if ($track->path && $track->path !== '__pending__' && str_starts_with($track->path, 'users/')) {
                                try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
                            }
                            $tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
                            $tempAbs  = storage_path('app/' . $tempPath);
                            $nasPath  = "{$videoDir}/{$trackName}";
                            $nas->putFile($tempAbs, $nasPath);
                            @unlink($tempAbs);
                            $track->update(['path' => $nasPath, 'filename' => $trackName]);
                        } catch (\Throwable $e) {
                            \Log::error("Track file replace NAS failed (track {$trackId}): " . $e->getMessage());
                        }
                    } else {
                        $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
                    }
                }
            }

            // Upload new extra tracks
            if ($request->hasFile('extra_track_files')) {
                $trackFiles  = $request->file('extra_track_files');
                $trackLangs  = $request->input('extra_track_languages', []);
                $trackTitles = $request->input('extra_track_titles', []);
                $trackDescs  = $request->input('extra_track_descriptions', []);

                foreach ($trackFiles as $i => $trackFile) {
                    if (! $trackFile || ! $trackFile->isValid()) continue;
                    $lang  = $trackLangs[$i] ?? 'en';
                    $ext   = strtolower($trackFile->getClientOriginalExtension() ?: 'mp3');
                    $title = !empty($trackTitles[$i]) ? $trackTitles[$i] : null;
                    $desc  = !empty($trackDescs[$i]) ? \App\Support\HtmlSanitizer::clean($trackDescs[$i]) ?: null : null;

                    $track = VideoAudioTrack::create([
                        'video_id' => $video->id,
                        'language' => $lang,
                        'label'    => strtoupper($lang),
                        'title'    => $title,
                        'description' => $desc,
                        'path'     => '__pending__',
                        'filename' => '__pending__',
                    ]);

                    if ($nas->isEnabled()) {
                        try {
                            $videoDir  = $this->nasVideoDir($video, $nas);
                            $nas->mkdirp($videoDir);
                            $trackName = $this->audioTrackName(basename($videoDir), $lang, $track->id, $ext);
                            $tempPath = $trackFile->storeAs('public/tmp', "track_{$track->id}.{$ext}");
                            $tempAbs  = storage_path('app/' . $tempPath);
                            $nasPath  = "{$videoDir}/{$trackName}";
                            $nas->putFile($tempAbs, $nasPath);
                            @unlink($tempAbs);
                            $track->update(['path' => $nasPath, 'filename' => $trackName]);
                        } catch (\Throwable $e) {
                            \Log::error("Extra track NAS upload failed (update): " . $e->getMessage());
                            $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
                        }
                    } else {
                        $this->storeTrackLocally($track, $trackFile, $ext, $video, $nas);
                    }
                }
            }
        }

        // Promote a secondary track to primary (swap metadata)
        if ($promoteId = (int) $request->input('promote_track_id')) {
            $promoteTrack = $video->audioTracks()->find($promoteId);
            if ($promoteTrack) {
                \Log::info('Track promote: swapping primary ↔ secondary', [
                    'video_id'      => $video->id,
                    'old_primary'   => ['lang' => $video->language, 'path' => $video->path, 'filename' => $video->filename],
                    'new_primary'   => ['track_id' => $promoteTrack->id, 'lang' => $promoteTrack->language, 'path' => $promoteTrack->path, 'filename' => $promoteTrack->filename],
                ]);
                // Create new secondary track from current primary metadata
                VideoAudioTrack::create([
                    'video_id'    => $video->id,
                    'language'    => $video->language ?? 'en',
                    'label'       => strtoupper($video->language ?? 'en'),
                    'title'       => $video->title,
                    'description' => $video->description,
                    'path'        => $video->path,
                    'filename'    => $video->filename,
                ]);
                // Override $data with the promoted track's values
                $data['title']       = $promoteTrack->title ?: ($data['title'] ?? $video->title);
                $data['language']    = $promoteTrack->language;
                $data['description'] = $promoteTrack->description ?: ($data['description'] ?? $video->description);
                $data['path']        = $promoteTrack->path;
                $data['filename']    = $promoteTrack->filename;
                $promoteTrack->delete();
            } else {
                \Log::warning('Track promote: promote_track_id not found', ['video_id' => $video->id, 'promote_track_id' => $promoteId]);
            }
        }

        $video->update($data);

        // If the title changed, rename the NAS folder and update meta.json.
        // We do NOT dispatch NasSyncVideoJob for regular edits — thumbnails and
        // slides are already pushed to NAS above, so there is nothing left to sync.
        if (($data['title'] ?? $oldTitle) !== $oldTitle) {
            try {
                $nas = app(\App\Services\NasSyncService::class);
                if ($nas->isEnabled()) {
                    $nas->renameVideoDir($video->fresh());
                    $nas->syncVideoMeta($video->fresh());
                }
            } catch (\Throwable $e) {
                \Illuminate\Support\Facades\Log::warning('NAS video dir rename/meta failed: ' . $e->getMessage());
            }
        }

        AuditLog::record('video.updated', [
            'subject_type'  => 'Video',
            'subject_id'    => (string) $video->id,
            'subject_label' => $video->title,
        ]);

        if ($request->expectsJson() || $request->ajax()) {
            return response()->json([
                'success' => true,
                'message' => 'Video updated successfully!',
                'video' => [
                    'id'             => $video->id,
                    'title'          => $video->title,
                    'description'    => $video->description,
                    'visibility'     => $video->visibility,
                    'download_access' => $video->download_access,
                ],
            ]);
        }

        return redirect()->route('videos.show', $video)->with('success', 'Video updated!');
    }

    public function destroy(Request $request, Video $video)
    {
        if (Auth::id() !== $video->user_id) {
            abort(403);
        }

        // If the owner has 2FA enabled, require a valid OTP
        $user = Auth::user();
        if ($user->two_factor_enabled && $user->two_factor_secret) {
            $code = $request->header('X-2FA-Code') ?? $request->input('otp_code', '');
            $google2fa = app('pragmarx.google2fa');
            if (! $google2fa->verifyKey(decrypt($user->two_factor_secret), (string) $code)) {
                return response()->json(['message' => 'Invalid 2FA code. Please try again.'], 422);
            }
        }

        AuditLog::record('video.deleted', [
            'subject_type'  => 'Video',
            'subject_id'    => (string) $video->id,
            'subject_label' => $video->title,
            'details'       => ['owner_id' => $video->user_id, 'type' => $video->type],
        ]);

        Storage::delete($video->path);
        if ($video->thumbnail) {
            Storage::delete($video->thumbnailStorageKey());
        }

        $nasSync = app(\App\Services\NasSyncService::class);
        if ($nasSync->isEnabled()) {
            $nasSync->deleteVideo($video);
        }

        $video->delete();

        if ($request->expectsJson() || $request->ajax()) {
            return response()->json([
                'success' => true,
                'message' => 'Video deleted successfully!',
            ]);
        }

        return redirect()->route('videos.index')->with('success', 'Video deleted!');
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Replace media file (keeps all metadata, views, likes, comments intact)
    // ─────────────────────────────────────────────────────────────────────────
    //  Replace media file (keeps all metadata, views, likes, comments intact)
    // ─────────────────────────────────────────────────────────────────────────
    public function replaceFile(Request $request, Video $video)
    {
        $user = Auth::user();

        if ($user->id !== $video->user_id && ! $user->isSuperAdmin()) {
            abort(403);
        }

        $request->validate([
            'replacement_file' => [
                'required',
                'file',
                'mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv,mp3,m4a,aac,wav,flac,opus',
            ],
        ]);

        $newFile  = $request->file('replacement_file');
        $mimeType = $newFile->getMimeType();
        $isAudio  = str_starts_with($mimeType, 'audio/');
        $newSize  = $newFile->getSize();
        $newExt   = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
        $nas      = app(\App\Services\NasSyncService::class);

        // ── 1. Clear old HLS ─────────────────────────────────────────────────
        if ($video->has_hls && $video->hls_path) {
            \Storage::deleteDirectory($video->hls_path);
        }

        // ── 2. Delete old media file ─────────────────────────────────────────
        //      NAS: only delete the video file; thumbnail/slides/meta.json stay
        //      Local: unlink the local copy
        if ($nas->isEnabled() && str_starts_with($video->path, 'users/')) {
            try { $nas->deleteFile($video->path); } catch (\Throwable) {}
        } else {
            $oldLocal = storage_path('app/' . $video->path);
            if (file_exists($oldLocal)) @unlink($oldLocal);
        }

        // ── 3. Store new file to a temporary local path ──────────────────────
        $tempFilename = \Str::uuid() . '.' . $newExt;
        $tempRelPath  = 'public/videos/' . $tempFilename;
        $newFile->storeAs('public/videos', $tempFilename);
        $tempAbsPath  = storage_path('app/' . $tempRelPath);

        if (! file_exists($tempAbsPath)) {
            return response()->json(['success' => false, 'message' => 'Failed to store the uploaded file.'], 500);
        }

        // ── 4. Extract metadata via FFprobe ──────────────────────────────────
        $width = $height = null;
        $orientation = 'landscape';
        $duration    = 0;

        try {
            $ffprobeBin = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
            $out = [];
            exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($tempAbsPath), $out);
            $duration = (int) round((float) ($out[0] ?? 0));

            if (! $isAudio) {
                $ffprobe = \FFMpeg\FFProbe::create();
                $stream  = $ffprobe->streams($tempAbsPath)->videos()->first();
                if ($stream) {
                    $width  = $stream->get('width');
                    $height = $stream->get('height');
                    if ($width && $height) {
                        if      ($height > $width) $orientation = 'portrait';
                        elseif  ($width > $height) $orientation = 'landscape';
                        else                       $orientation = 'square';
                    }
                }
            }
        } catch (\Throwable $e) {
            \Log::warning('replaceFile: FFprobe failed: ' . $e->getMessage());
        }

        // ── 5. Persist the file to its final location ─────────────────────────
        //
        //   NAS path:   push directly to NAS using uploadDirectToNas()
        //               This handles legacy paths correctly by computing the
        //               proper users/.../videos/... directory.
        //               uploadDirectToNas() updates path/filename in DB.
        //
        //   Local path: update path/filename to temp location, then call
        //               organizeLocalFiles() which moves it to users/... layout.
        //               CompressVideoJob() sets status=ready and chains HLS.

        $nasReplaceSucceeded = false;

        if ($nas->isEnabled()) {

            // Point filename at the new file (uploadDirectToNas uses this for ext)
            $video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]);

            try {
                // Pass null for thumb — we don't want to overwrite the existing thumbnail
                $nas->uploadDirectToNas($video, $tempAbsPath, null);
                $video->refresh();
                $nasReplaceSucceeded = true;
            } catch (\Throwable $e) {
                \Log::error('replaceFile: NAS upload failed (falling back to local): ' . $e->getMessage());
                // Temp file still exists — fall through to organizeLocalFiles below
                $video->update(['path' => $tempRelPath, 'filename' => $tempFilename]);
            }
        }

        if ($nasReplaceSucceeded) {

            $metaUpdates = [
                'size'     => $newSize,
                'mime_type'=> $mimeType,
                'has_hls'  => false,
                'hls_path' => null,
                // For NAS the upload is the "done" state — set ready so GenerateHlsJob runs
                'status'   => 'ready',
            ];
            if (! $isAudio) {
                $metaUpdates['duration']    = $duration ?: $video->duration;
                $metaUpdates['width']       = $width    ?: $video->width;
                $metaUpdates['height']      = $height   ?: $video->height;
                $metaUpdates['orientation'] = $orientation;
                $metaUpdates['is_shorts']   = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
            }
            $video->update($metaUpdates);

            if (! $isAudio) {
                \App\Jobs\GenerateHlsJob::dispatch($video->fresh())
                    ->onQueue('video-processing')
                    ->onConnection('database');
            }

        } else {

            // NAS unavailable — keep file locally; NAS-mirrored layout
            try {
                $nas->organizeLocalFiles($video);
                $video->refresh();
            } catch (\Throwable $e) {
                \Log::warning('replaceFile: organizeLocalFiles failed: ' . $e->getMessage());
            }

            $metaUpdates = [
                'size'     => $newSize,
                'mime_type'=> $mimeType,
                'has_hls'  => false,
                'hls_path' => null,
                'status'   => $isAudio ? 'ready' : 'processing',
            ];
            if (! $isAudio) {
                $metaUpdates['duration']    = $duration ?: $video->duration;
                $metaUpdates['width']       = $width    ?: $video->width;
                $metaUpdates['height']      = $height   ?: $video->height;
                $metaUpdates['orientation'] = $orientation;
                $metaUpdates['is_shorts']   = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
            }
            $video->update($metaUpdates);

            if (! $isAudio) {
                \App\Jobs\CompressVideoJob::dispatch($video->fresh())
                    ->onQueue('video-processing')
                    ->onConnection('database');
            }
        }

        return response()->json([
            'success' => true,
            'message' => $isAudio
                ? 'Audio file replaced successfully.'
                : 'File replaced — re-encoding has started. The video will be ready shortly.',
            'status'   => $video->fresh()->status,
            'is_audio' => $isAudio,
        ]);
    }

    public function trending(Request $request)
    {
        $hours = $request->get('hours', 48);
        $limit = $request->get('limit', 50);

        $hours = min(max($hours, 24), 168);
        $limit = min(max($limit, 10), 100);

        $videos = Video::public()
            ->where('status', 'ready')
            ->where('created_at', '>=', now()->subDays(10))
            ->with('user')
            ->get();

        $videos = $videos->map(function ($video) use ($hours) {
            $recentViews = \DB::table('video_views')
                ->where('video_id', $video->id)
                ->where('watched_at', '>=', now()->subHours($hours))
                ->count();

            $likeCount = \DB::table('video_likes')
                ->where('video_id', $video->id)
                ->count();

            $ageHours = $video->created_at->diffInHours(now());
            $velocity = $recentViews / $hours;
            $recencyBonus = max(0, 1 - ($ageHours / 240));

            $score = ($recentViews * 0.70) +
                     ($velocity * 100 * 0.15) +
                     ($recencyBonus * 50 * 0.10) +
                     ($likeCount * 0.1 * 0.05);

            $video->trending_score = round($score, 2);
            $video->view_count = $recentViews;
            $video->like_count = $likeCount;

            return $video;
        });

        $trendingVideos = $videos
            ->filter(fn ($v) => $v->trending_score > 0)
            ->sortByDesc('trending_score')
            ->take($limit)
            ->values();

        return view('videos.trending', [
            'videos' => $trendingVideos,
            'hours' => $hours,
            'limit' => $limit,
        ]);
    }

    public function shorts(Request $request)
    {
        $videos = Video::public()
            ->where('is_shorts', true)
            ->where('status', 'ready')
            ->with('user')
            ->latest()
            ->get();

        return view('videos.shorts', compact('videos'));
    }

    public function stream(Video $video)
    {
        if (! $video->canView(Auth::user())) {
            abort(404, 'Video not found');
        }

        $path = $video->localVideoPath();

        // If not on local disk, try to pull from NAS (primary storage when NAS is enabled)
        if (! file_exists($path)) {
            $nas  = app(\App\Services\NasSyncService::class);
            $path = $nas->ensureLocalCopy($video);
            if (! $path) {
                abort(404, 'Video file not found');
            }
        }

        $fileSize = filesize($path);
        $mimeType = $video->mime_type ?: 'video/mp4';

        $handle = fopen($path, 'rb');
        if (! $handle) {
            abort(500, 'Cannot open video file');
        }

        $range = request()->header('Range');

        if ($range) {
            preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
            $start = intval($matches[1] ?? 0);
            $end = $matches[2] ? intval($matches[2]) : $fileSize - 1;

            $length = $end - $start + 1;

            header('HTTP/1.1 206 Partial Content');
            header('Content-Type: '.$mimeType);
            header('Content-Length: '.$length);
            header('Content-Range: bytes '.$start.'-'.$end.'/'.$fileSize);
            header('Accept-Ranges: bytes');
            header('Cache-Control: private, max-age=3600');

            fseek($handle, $start);
            $chunkSize = 8192;
            $bytesToRead = $length;

            while (! feof($handle) && $bytesToRead > 0) {
                $buffer = fread($handle, min($chunkSize, $bytesToRead));
                echo $buffer;
                flush();
                $bytesToRead -= strlen($buffer);
            }

            fclose($handle);
            exit;
        } else {
            header('Content-Type: '.$mimeType);
            header('Content-Length: '.$fileSize);
            header('Accept-Ranges: bytes');
            header('Cache-Control: private, max-age=3600');

            fpassthru($handle);
            fclose($handle);
            exit;
        }
    }

    public function streamAudioTrack(Video $video, VideoAudioTrack $track)
    {
        if ($track->video_id !== $video->id) {
            abort(404);
        }
        if (! $video->canView(Auth::user())) {
            abort(404);
        }

        $localPath = $track->localPath();

        if (! file_exists($localPath)) {
            // Use the same robust fallback chain as the primary stream():
            // local path → nas_cache → NAS download. This is what lets a demoted
            // secondary track (e.g. the old primary after a language swap) keep
            // playing even when its file only exists in the stream cache.
            $nas      = app(\App\Services\NasSyncService::class);
            $resolved = $nas->ensureLocalTrackCopy($track);
            if (! $resolved) {
                \Log::warning('streamAudioTrack: file not found', [
                    'video_id'    => $video->id,
                    'track_id'    => $track->id,
                    'language'    => $track->language,
                    'path'        => $track->path,
                    'filename'    => $track->filename,
                    'nas_enabled' => $nas->isEnabled(),
                ]);
                abort(404, 'Audio track file not found');
            }
            $localPath = $resolved;
        }

        $fileSize = filesize($localPath);
        $ext      = strtolower(pathinfo($track->filename, PATHINFO_EXTENSION));
        $mimeMap  = ['mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'aac' => 'audio/aac',
                     'flac' => 'audio/flac', 'wav' => 'audio/wav', 'ogg' => 'audio/ogg'];
        $mimeType = $mimeMap[$ext] ?? 'audio/mpeg';

        $handle = fopen($localPath, 'rb');
        if (! $handle) abort(500, 'Cannot open audio track');

        $range = request()->header('Range');

        if ($range) {
            preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
            $start  = intval($matches[1] ?? 0);
            $end    = $matches[2] ? intval($matches[2]) : $fileSize - 1;
            $length = $end - $start + 1;

            header('HTTP/1.1 206 Partial Content');
            header('Content-Type: ' . $mimeType);
            header('Content-Length: ' . $length);
            header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
            header('Accept-Ranges: bytes');
            header('Cache-Control: private, max-age=3600');

            fseek($handle, $start);
            $remaining = $length;
            while (! feof($handle) && $remaining > 0) {
                $buf = fread($handle, min(8192, $remaining));
                echo $buf;
                flush();
                $remaining -= strlen($buf);
            }
            fclose($handle);
            exit;
        }

        if (request()->boolean('download')) {
            $downloadName = \Str::slug($video->title) . '-' . strtolower($track->label ?: $track->language) . '.' . $ext;
            header('Content-Disposition: attachment; filename="' . $downloadName . '"');
        }
        header('Content-Type: ' . $mimeType);
        header('Content-Length: ' . $fileSize);
        header('Accept-Ranges: bytes');
        header('Cache-Control: private, max-age=3600');
        fpassthru($handle);
        fclose($handle);
        exit;
    }

    public function deleteAudioTrack(Request $request, Video $video, VideoAudioTrack $track)
    {
        if ($track->video_id !== $video->id) abort(404);
        if (Auth::id() !== $video->user_id) abort(403);

        $nas = app(\App\Services\NasSyncService::class);
        if ($track->path !== '__pending__') {
            if ($nas->isEnabled() && str_starts_with($track->path, 'users/')) {
                try { $nas->deleteFile($track->path); } catch (\Throwable $e) {}
            } else {
                @unlink($track->localPath());
            }
        }
        $track->delete();

        return response()->json(['success' => true]);
    }

    private function storeTrackLocally(VideoAudioTrack $track, $trackFile, string $ext, Video $video, $nas): void
    {
        try {
            // Music: each track gets its own folder tracks/{lang-id}/audio.{ext}.
            // (storeTrackLocally is only reached for music since extra tracks
            // only exist on music videos.)
            $songLocalDir = $nas->localVideoDir($video);
            $trackFolder  = $nas->trackFolderName($video, $track);
            $trackDirAbs  = $songLocalDir . '/tracks/' . $trackFolder;
            @mkdir($trackDirAbs, 0755, true);

            $canonical = "audio.{$ext}";
            $trackFile->move($trackDirAbs, $canonical);

            // Build the NAS-relative path from the song's relative path so it
            // works on both local-only and NAS-enabled setups.
            $songRel = $this->relFromStoragePath($songLocalDir);
            $relPath = "{$songRel}/tracks/{$trackFolder}/{$canonical}";

            $track->update(['path' => $relPath, 'filename' => $canonical]);
        } catch (\Throwable $e) {
            \Log::error("storeTrackLocally failed: " . $e->getMessage());
        }
    }

    /** Convert an absolute storage_path() value back to a storage-relative path. */
    private function relFromStoragePath(string $abs): string
    {
        $prefix = storage_path('app/');
        if (str_starts_with($abs, $prefix)) {
            return substr($abs, strlen($prefix));
        }
        return $abs;
    }

    /**
     * Persist per-track slides — the new track owns them via audio_track_id.
     * Filename scheme keeps tracks from colliding in the shared slides/ folder:
     *   slides/track-{trackId}-{position}.{ext}
     * Primary slides keep the legacy slides/{position}.{ext} scheme.
     */
    private function storeTrackSlides(Video $video, VideoAudioTrack $track, array $files, $nas): void
    {
        // Per-track slides live in the track's own folder:
        //   tracks/{lang-id}/slides/{position}.{ext}
        // Filenames are canonical (just {position}.{ext}) because the folder
        // already disambiguates by track.
        $nasEnabled = $nas->isEnabled();
        $songLocal  = $nas->localVideoDir($video);
        $songRel    = $this->relFromStoragePath($songLocal);
        $trackFold  = $nas->trackFolderName($video, $track);
        $nasTrackDir   = "{$songRel}/tracks/{$trackFold}";
        $localTrackDir = "{$songLocal}/tracks/{$trackFold}";

        if ($nasEnabled) {
            try { $nas->mkdirp("{$nasTrackDir}/slides"); } catch (\Throwable $e) {}
        } else {
            @mkdir("{$localTrackDir}/slides", 0755, true);
        }

        foreach ($files as $pos => $file) {
            if (! $file || ! $file->isValid()) continue;
            $ext  = strtolower($file->getClientOriginalExtension() ?: 'jpg');
            $name = "{$pos}.{$ext}";
            $slide = VideoSlide::create([
                'video_id'       => $video->id,
                'audio_track_id' => $track->id,
                'filename'       => '__pending__',
                'position'       => $pos,
            ]);

            $tempPath = $file->storeAs('public/tmp', "trackslide_{$slide->id}.{$ext}");
            $tempAbs  = storage_path('app/' . $tempPath);

            if ($nasEnabled) {
                $nasPath = "{$nasTrackDir}/slides/{$name}";
                if ($nas->putFile($tempAbs, $nasPath)) {
                    @unlink($tempAbs);
                    $slide->update(['filename' => $nasPath]);
                    continue;
                }
                // Fall through to local on NAS failure
            }

            @mkdir("{$localTrackDir}/slides", 0755, true);
            @rename($tempAbs, "{$localTrackDir}/slides/{$name}");
            $slide->update(['filename' => "{$nasTrackDir}/slides/{$name}"]);
        }
    }

    /**
     * Unique, lowercase filename for an audio track kept in the song's own folder:
     * {song-slug}-{lang}-{db-id}.{ext}. The db id guarantees no two tracks can ever
     * overwrite each other even when they share a language.
     */
    private function audioTrackName(string $base, ?string $lang, int $id, string $ext): string
    {
        $lang = mb_strtolower(trim((string) $lang)) ?: 'xx';
        return mb_strtolower($base . '-' . $lang . '-' . $id . '.' . strtolower($ext));
    }

    public function hls(Video $video, $file = 'playlist.m3u8')
    {
        if (! $video->canView(Auth::user())) {
            abort(404);
        }

        if (! $video->has_hls) {
            abort(404, 'HLS unavailable');
        }

        $hlsPath = storage_path('app/' . $video->hls_path . '/' . $file);

        if (! file_exists($hlsPath)) {
            abort(404);
        }

        $mimeType = pathinfo($hlsPath, PATHINFO_EXTENSION) === 'm3u8'
            ? 'application/vnd.apple.mpegurl'
            : 'video/mp2t';

        header('Content-Type: '.$mimeType);
        header('Accept-Ranges: bytes');
        header('Cache-Control: public, max-age=3600');
        header('Access-Control-Allow-Origin: *');
        header('Access-Control-Allow-Headers: Range');

        if (request()->header('Range')) {
            $size = filesize($hlsPath);
            preg_match('/bytes=(\d+)-(\d*)/', request()->header('Range'), $matches);
            $start = intval($matches[1] ?? 0);
            $end = $matches[2] ? intval($matches[2]) : $size - 1;
            $length = $end - $start + 1;

            header('HTTP/1.1 206 Partial Content');
            header('Content-Length: '.$length);
            header('Content-Range: bytes '.$start.'-'.$end.'/'.$size);

            $handle = fopen($hlsPath, 'rb');
            fseek($handle, $start);
            echo fread($handle, $length);
            fclose($handle);
        } else {
            header('Content-Length: '.filesize($hlsPath));
            readfile($hlsPath);
        }
        exit;
    }

    private function checkDownloadAccess(Video $video): void
    {
        $access = $video->download_access ?? 'disabled';
        $user   = Auth::user();

        match ($access) {
            'disabled'    => abort(403, 'Downloads are not enabled for this video.'),
            'registered'  => $user ? null : abort(403, 'You must be logged in to download this video.'),
            'subscribers' => ($user && ($user->id === $video->user_id || $user->isSubscribedTo($video->user)))
                                 ? null
                                 : abort(403, 'You must be subscribed to this channel to download this video.'),
            default       => null, // 'everyone' — allow
        };
    }

    public function download(Video $video)
    {
        $this->checkDownloadAccess($video);
        $this->recordDownload($video, 'video');

        $path = $video->localVideoPath();

        // If not on local disk, try to pull from NAS
        if (! file_exists($path)) {
            $nas  = app(\App\Services\NasSyncService::class);
            $path = $nas->ensureLocalCopy($video);
            if (! $path) {
                abort(404, 'Video file not found.');
            }
        }

        // Already a video — serve directly, no conversion needed
        if (! $this->isAudioOnlyFile($video)) {
            $ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
            return response()->download($path, $this->safeFilename($video->title, 'video') . '.' . $ext, [
                'Content-Type'        => $video->mime_type ?: 'video/mp4',
                'Content-Disposition' => $this->contentDisposition($video->title, $ext),
            ]);
        }

        // Audio-only file → generate/serve a video for the version being played.
        $trackId = (int) request()->input('track', 0);
        $viz     = request()->boolean('visualizer');
        $lyrics  = request()->boolean('lyrics');

        // Any non-primary, visualizer, or lyrics variant is served straight off disk (no DB column).
        if ($viz || $trackId || $lyrics) {
            $cacheFile = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
            if (file_exists($cacheFile) && filesize($cacheFile) > 0) {
                return response()->download($cacheFile, $this->safeFilename($video->title, 'video') . '.mp4', [
                    'Content-Type'        => 'video/mp4',
                    'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
                ]);
            }
            // Not generated yet — let the player trigger background generation.
            return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
        }

        // Primary plain — DB column must confirm it's the current version.
        if ($video->slideshow_video_path) {
            $slideshowCache = storage_path('app/' . $video->slideshow_video_path);
            if (file_exists($slideshowCache) && filesize($slideshowCache) > 0) {
                return response()->download($slideshowCache, $this->safeFilename($video->title, 'video') . '.mp4', [
                    'Content-Type'        => 'video/mp4',
                    'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
                ]);
            }
            // DB says cached but file is gone — clear the stale column
            $video->update(['slideshow_video_path' => null]);
        }

        // Not cached — redirect to the video page which will auto-trigger the
        // background generation progress bar (startSlideshowDownload). This avoids
        // blocking the HTTP worker with a synchronous FFmpeg encode.
        return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
    }

    /**
     * Extract up to three dominant, saturated colours from a cover image — the same
     * algorithm the in-player visualizer uses (extractColors() in audio-player.blade.php)
     * so the baked-in download bars match what viewers see on the page. Returns three
     * lowercase hex strings ordered left → middle → right of the bar gradient; falls
     * back to light greys when nothing usable is found.
     */
    private function slideVisualizerColors(?string $imagePath): array
    {
        $default = ['ffffff', 'c8c8c8', 'aaaaaa'];
        if (! $imagePath || ! is_file($imagePath) || ! function_exists('imagecreatefromstring')) {
            return $default;
        }

        $raw = @file_get_contents($imagePath);
        $img = $raw ? @imagecreatefromstring($raw) : false;
        if (! $img) return $default;

        $t = imagecreatetruecolor(24, 24);
        imagecopyresampled($t, $img, 0, 0, 0, 0, 24, 24, imagesx($img), imagesy($img));
        imagedestroy($img);

        $buckets = [];
        for ($y = 0; $y < 24; $y++) {
            for ($x = 0; $x < 24; $x++) {
                $p = imagecolorat($t, $x, $y);
                $r = ($p >> 16) & 255; $g = ($p >> 8) & 255; $b = $p & 255;
                $bright = ($r + $g + $b) / 3;
                if ($bright < 25 || $bright > 230) continue;          // skip near-black / near-white
                $mx = max($r, $g, $b); $mn = min($r, $g, $b);
                if ($mx == 0 || ($mx - $mn) / $mx < 0.25) continue;   // skip low-saturation
                $k = ($r >> 2) . ',' . ($g >> 2) . ',' . ($b >> 2);
                if (! isset($buckets[$k])) $buckets[$k] = ['r' => 0, 'g' => 0, 'b' => 0, 'n' => 0];
                $buckets[$k]['r'] += $r; $buckets[$k]['g'] += $g; $buckets[$k]['b'] += $b; $buckets[$k]['n']++;
            }
        }
        imagedestroy($t);
        if (! $buckets) return $default;

        usort($buckets, fn ($a, $b) => $b['n'] - $a['n']);
        $avg = fn ($c) => [$c['r'] / $c['n'], $c['g'] / $c['n'], $c['b'] / $c['n']];

        // Most-common colour first, then the next colours that are visually distinct from it.
        $chosen = [$avg($buckets[0])];
        for ($i = 1; $i < count($buckets) && count($chosen) < 3; $i++) {
            $c = $avg($buckets[$i]); $far = true;
            foreach ($chosen as $e) {
                if (sqrt(($e[0]-$c[0])**2 + ($e[1]-$c[1])**2 + ($e[2]-$c[2])**2) <= 60) { $far = false; break; }
            }
            if ($far) $chosen[] = $c;
        }
        while (count($chosen) < 3) $chosen[] = $chosen[0];

        return array_map(fn ($c) => sprintf('%02x%02x%02x', round($c[0]), round($c[1]), round($c[2])), $chosen);
    }

    // ── Background slideshow generation + progress polling ────────

    public function slideshowGenerate(Request $request, Video $video)
    {
        $this->checkDownloadAccess($video);

        if (! $this->isAudioOnlyFile($video)) {
            return response()->json(['error' => 'Not an audio file'], 422);
        }

        // Version-aware: render the video from the audio the viewer is playing — the
        // primary track, or a specific language track (?track={id}). The optional
        // visualizer (?visualizer=1) bakes in the frequency bars. Each (track, viz)
        // combination is cached under its own filename so they never clobber each other.
        $viz     = $request->boolean('visualizer');
        $trackId = (int) $request->input('track', 0);
        $lyrics  = $request->boolean('lyrics');
        $suffix  = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');

        $ffmpeg  = \App\Models\Setting::ffmpegBinary();
        $ffprobe = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');

        // Audio source = the version being played.
        if ($trackId) {
            $track = $video->audioTracks()->find($trackId);
            if (! $track) {
                return response()->json(['error' => 'Track not found'], 404);
            }
            $nas       = app(\App\Services\NasSyncService::class);
            $audioPath = $track->localPath();
            if (! file_exists($audioPath) && $nas->isEnabled()) {
                $audioPath = $nas->ensureLocalTrackCopy($track) ?: $audioPath;
            }
        } else {
            $audioPath = $video->localVideoPath();
        }

        if (! file_exists($audioPath)) {
            return response()->json(['error' => 'Audio file not found'], 404);
        }

        $outRel       = $this->slideshowRel($video, $viz, $trackId, $lyrics);   // song's cache/ folder
        $outPath      = storage_path('app/' . $outRel);
        if (! is_dir(dirname($outPath))) mkdir(dirname($outPath), 0755, true);
        $progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
        $pidFile      = sys_get_temp_dir() . '/sl_pid_'      . $video->id . $suffix . '.txt';
        $logFile      = sys_get_temp_dir() . '/sl_log_'      . $video->id . $suffix . '.txt';

        // Already cached. Only the primary plain video is tracked by the DB column; every
        // other variant (a language track and/or visualizer) is guarded by file existence.
        // All are invalidated together when the slides are edited (see update()).
        $usesDbColumn = (! $viz && ! $trackId);
        $cached = $usesDbColumn
            ? ($video->slideshow_video_path && file_exists($outPath) && filesize($outPath) > 0)
            : (file_exists($outPath) && filesize($outPath) > 0);
        if ($cached) {
            return response()->json(['status' => 'ready']);
        }

        // Stale file on disk (e.g. slides were edited) — delete it before regenerating
        if (file_exists($outPath)) {
            @unlink($outPath);
        }

        // Already running — return so the frontend keeps polling
        if (file_exists($pidFile)) {
            $pid = (int) trim(file_get_contents($pidFile));
            if ($pid > 0 && file_exists("/proc/{$pid}")) {
                exec("{$ffprobe} -v error -show_entries format=duration -of csv=p=0 "
                    . escapeshellarg($audioPath) . ' 2>/dev/null', $dO);
                return response()->json(['status' => 'running', 'duration' => (float) trim($dO[0] ?? '0')]);
            }
        }

        // Probe duration
        exec("{$ffprobe} -v error -show_entries format=duration -of csv=p=0 "
            . escapeshellarg($audioPath) . ' 2>/dev/null', $durOut);
        $dur = isset($durOut[0]) ? (float) trim($durOut[0]) : null;
        if (! $dur || $dur < 1) {
            return response()->json(['error' => 'Cannot probe audio duration'], 500);
        }

        // Use the slides for the track being rendered, applying the per-track
        // sharing fallback (own → primary → sibling). Ensures the download .mp4
        // matches the slideshow the listener saw in the player.
        $video->loadMissing('slides');
        $slides      = $video->slidesForTrack($trackId ?: null)->sortBy('position')->values();
        $validSlides = $slides->filter(fn($s) => file_exists($s->localPath()))->values();

        $scale = 'scale=1280:720:force_original_aspect_ratio=decrease,'
               . 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p';

        // ── Optional burned-in lyrics (ASS via libass) ───────────────────────
        // When ?lyrics=1 and a ready lyrics file exists, build an .ass karaoke
        // track and weave it into the final video output. $voutLabel/$assArg/
        // $assTail let each render branch inject the burn uniformly.
        $voutLabel = '[vout]';
        $assArg    = '';   // for -vf branches:           ",ass=/tmp/x.ass"
        $assTail   = '';   // for filter_complex branches: ";[vout]ass=/tmp/x.ass[vsub]"
        if ($lyrics) {
            $lyrTarget = $trackId ? $video->audioTracks()->find($trackId) : null;
            $lyrData   = app(\App\Services\NasSyncService::class)->getLocalLyrics($video, $lyrTarget);
            if (is_array($lyrData) && ($lyrData['status'] ?? null) === 'ready') {
                $assPath = sys_get_temp_dir() . '/lyr_' . $video->id . $suffix . '.ass';
                if (\App\Support\LyricsAss::write($lyrData, $assPath)) {
                    $assFilter = 'ass=' . $assPath;
                    $assArg    = ',' . $assFilter;
                    $assTail   = ';[vout]' . $assFilter . '[vsub]';
                    $voutLabel = '[vsub]';
                }
            }
        }

        @unlink($progressFile);
        @unlink($pidFile);

        // Pre-compute codec flags; CPU variant used as automatic fallback if GPU fails.
        // A single slide is normally a still image, but with the visualizer overlay the
        // bars animate — so it must not be encoded with -tune stillimage.
        $isStillImage = ($validSlides->count() === 1) && ! $viz;
        $vFlags   = $this->ffmpegVideoFlags($isStillImage);
        $cpuFlags = $this->ffmpegVideoFlagsCpu($isStillImage);

        if ($viz) {
            // ── Visualizer build ─────────────────────────────────────────────────
            // Decode each slide ONCE and repeat it with the loop filter. Re-decoding a
            // multi-megapixel PNG on every output frame (the naive `-loop 1` input) made
            // this run below real-time; the loop filter makes it ~25x real-time instead.
            // overlay shortest=1 bounds the otherwise-endless looped background to the audio.
            $rate    = 20;
            $inputs  = '';
            $fcParts = [];

            if ($validSlides->count() >= 2) {
                $n    = $validSlides->count();
                $fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
                $T    = ($dur + ($n - 1) * $fade) / $n;

                foreach ($validSlides as $i => $slide) {
                    $inputs   .= ' -i ' . escapeshellarg($slide->localPath());
                    // fps MUST come last so each branch is constant-frame-rate for xfade.
                    $fcParts[] = "[{$i}:v]{$scale},loop=loop=-1:size=1,trim=duration="
                               . number_format($T + 1, 3) . ",setpts=PTS-STARTPTS,fps={$rate}[s{$i}]";
                }
                $inputs  .= ' -i ' . escapeshellarg($audioPath);
                $audioIdx = $n;

                $prev = '[s0]';
                for ($i = 1; $i < $n; $i++) {
                    $offset    = number_format($i * ($T - $fade), 3);
                    $outLabel  = $i === $n - 1 ? '[vbase]' : "[v{$i}]";
                    $fcParts[] = "{$prev}[s{$i}]xfade=transition=fade:duration="
                               . number_format($fade, 3) . ":offset={$offset}{$outLabel}";
                    $prev = $outLabel;
                }
            } elseif ($validSlides->count() === 1) {
                $inputs  .= ' -i ' . escapeshellarg($validSlides->first()->localPath());
                $inputs  .= ' -i ' . escapeshellarg($audioPath);
                $audioIdx = 1;
                $fcParts[] = "[0:v]{$scale},loop=loop=-1:size=1,fps={$rate}[vbase]";
            } else {
                $inputs  .= ' -f lavfi -i color=c=black:s=1280x720:r=' . $rate;
                $inputs  .= ' -i ' . escapeshellarg($audioPath);
                $audioIdx = 1;
                $fcParts[] = "[0:v]format=yuv420p[vbase]";
            }

            // Frequency bars matched to the in-player visualizer:
            //   • equal-width bars  → fscale=lin (log made the left bars wide, right thin)
            //   • cover colours     → render white bars (a shape+alpha mask), then tint
            //     them with a horizontal gradient of the cover's 3 dominant colours
            //     (left → middle → right), exactly like the page's canvas bars
            //   • translucent overlay → showfreqs already outputs a transparent backdrop,
            //     so we just dial the bar alpha down and composite over the artwork.
            [$c0, $c1, $c2] = $this->slideVisualizerColors(
                $validSlides->isNotEmpty() ? $validSlides->first()->localPath() : null
            );
            $fcParts[] = "[{$audioIdx}:a]showfreqs=mode=bar:ascale=log:fscale=lin:win_size=128"
                       . ":rate={$rate}:s=1280x180:colors=white,format=rgba[bars]";
            $fcParts[] = "gradients=s=1280x180:nb_colors=3:c0=0x{$c0}:c1=0x{$c1}:c2=0x{$c2}"
                       . ":x0=0:y0=90:x1=1280:y1=90,format=rgba[grad]";
            $fcParts[] = "[bars]alphaextract[vmask]";
            $fcParts[] = "[grad][vmask]alphamerge,colorchannelmixer=aa=0.85[viz]";
            $fcParts[] = "[vbase][viz]overlay=x=0:y=H-h:format=yuv420:shortest=1[vout]";

            $fc  = implode(';', $fcParts) . $assTail;
            $cmd = "{$ffmpeg} -y{$inputs}"
                 . ' -filter_complex ' . escapeshellarg($fc)
                 . ' -map ' . $voutLabel . ' -map ' . $audioIdx . ':a'
                 . ' ' . $vFlags
                 . ' -c:a aac -b:a 192k -movflags +faststart -shortest';

        } elseif ($validSlides->count() >= 2) {
            $n    = $validSlides->count();
            $fade = max(0.5, min(1.0, ($dur / $n) * 0.15));
            $T    = ($dur + ($n - 1) * $fade) / $n;

            $inputs = '';
            $scaleFc = [];
            foreach ($validSlides as $i => $slide) {
                $imgPath  = $slide->localPath();
                $inputs  .= ' -loop 1 -t ' . number_format($T + 1, 3) . ' -i ' . escapeshellarg($imgPath);
                $scaleFc[] = "[{$i}:v]{$scale}[s{$i}]";
            }
            $inputs .= ' -i ' . escapeshellarg($audioPath);

            $xfadeFc = [];
            $prev = '[s0]';
            for ($i = 1; $i < $n; $i++) {
                $offset    = number_format($i * ($T - $fade), 3);
                $outLabel  = $i === $n - 1 ? '[vout]' : "[v{$i}]";
                $xfadeFc[] = "{$prev}[s{$i}]xfade=transition=fade:duration="
                           . number_format($fade, 3) . ":offset={$offset}{$outLabel}";
                $prev = $outLabel;
            }

            $fc  = implode(';', array_merge($scaleFc, $xfadeFc)) . $assTail;
            $cmd = "{$ffmpeg} -y{$inputs}"
                 . ' -filter_complex ' . escapeshellarg($fc)
                 . ' -map ' . $voutLabel . ' -map ' . $n . ':a'
                 . ' ' . $vFlags
                 . ' -c:a aac -b:a 192k -movflags +faststart -shortest';

        } elseif ($validSlides->count() === 1) {
            $imgPath = $validSlides->first()->localPath();
            $cmd = "{$ffmpeg} -y"
                 . ' -loop 1 -r 1 -i ' . escapeshellarg($imgPath)
                 . ' -i ' . escapeshellarg($audioPath)
                 . ' -map 0:v:0 -map 1:a:0 -shortest'
                 . ' -vf ' . escapeshellarg($scale . $assArg)
                 . ' ' . $vFlags
                 . ' -c:a aac -b:a 192k -movflags +faststart';
        } else {
            $cmd = "{$ffmpeg} -y"
                 . ' -f lavfi -i color=c=black:s=1280x720:r=1'
                 . ' -i ' . escapeshellarg($audioPath)
                 . ' -map 0:v:0 -map 1:a:0 -shortest'
                 . ($assArg !== '' ? ' -vf ' . escapeshellarg('format=yuv420p' . $assArg) : '')
                 . ' ' . $vFlags
                 . ' -c:a aac -b:a 192k -movflags +faststart';
        }

        $cmd .= ' -progress ' . escapeshellarg($progressFile)
              . ' '           . escapeshellarg($outPath);

        // $vFlags already reflects a live GPU health check (Setting::gpuUsable); when the
        // GPU is in use, wrap in a bash fallback so a mid-encode GPU failure still retries
        // on CPU (libx264) and the download keeps working.
        if (Setting::gpuUsable() && $vFlags !== $cpuFlags) {
            $cpuCmd  = str_replace($vFlags, $cpuFlags, $cmd);
            $inner   = $cmd
                     . ' || { truncate -s 0 ' . escapeshellarg($progressFile) . '; ' . $cpuCmd . '; }';
            $bgCmd   = 'nohup bash -c ' . escapeshellarg($inner)
                     . ' 2>' . escapeshellarg($logFile)
                     . ' & echo $! > ' . escapeshellarg($pidFile);
        } else {
            $bgCmd = 'nohup ' . $cmd
                   . ' 2>'    . escapeshellarg($logFile)
                   . ' & echo $! > ' . escapeshellarg($pidFile);
        }

        exec($bgCmd);

        return response()->json(['status' => 'started', 'duration' => $dur]);
    }

    public function slideshowProgress(Video $video)
    {
        $viz     = request()->boolean('visualizer');
        $trackId = (int) request()->input('track', 0);
        $lyrics  = request()->boolean('lyrics');
        $suffix  = ($trackId ? '_t' . $trackId : '') . ($viz ? '_viz' : '') . ($lyrics ? '_lyr' : '');

        $outPath      = storage_path('app/' . $this->slideshowRel($video, $viz, $trackId, $lyrics));
        $progressFile = sys_get_temp_dir() . '/sl_progress_' . $video->id . $suffix . '.txt';
        $pidFile      = sys_get_temp_dir() . '/sl_pid_'      . $video->id . $suffix . '.txt';

        // Read progress content once
        $content = file_exists($progressFile) ? file_get_contents($progressFile) : '';
        $isDone  = strpos($content, 'progress=end') !== false;

        // If progress says done OR the output file exists and process is gone
        if (! $isDone && file_exists($outPath) && filesize($outPath) > 0) {
            $pid = file_exists($pidFile) ? (int) trim(file_get_contents($pidFile)) : 0;
            if (! ($pid > 0 && file_exists("/proc/{$pid}"))) {
                $isDone = true;
            }
        }

        if ($isDone && file_exists($outPath) && filesize($outPath) > 0) {
            // Only the plain variant is tracked by the DB column; the visualizer variant
            // is served straight off disk (see download()).
            if (! $viz && ! $trackId && ! $lyrics && ! $video->slideshow_video_path) {
                $video->update(['slideshow_video_path' => $this->slideshowRel($video, false, 0)]);
            }
            return response()->json(['percent' => 100, 'status' => 'ready']);
        }

        if (! $content) {
            // If a PID was recorded but the process is already gone, generation failed
            if (file_exists($pidFile)) {
                $pid = (int) trim(file_get_contents($pidFile));
                if ($pid > 0 && ! file_exists("/proc/{$pid}")) {
                    return response()->json(['percent' => 0, 'status' => 'error']);
                }
            }
            return response()->json(['percent' => 0, 'status' => 'waiting']);
        }

        // Parse the most-recent out_time_ms value from the progress file
        preg_match_all('/out_time_ms=(\d+)/', $content, $m);
        $outTimeMs = ! empty($m[1]) ? (int) end($m[1]) : 0;

        // FFmpeg's out_time_ms field is actually in microseconds despite its name
        $totalUs = (float) request()->query('duration', 0) * 1_000_000;
        $percent = $totalUs > 0 ? min(99, (int) round(($outTimeMs / $totalUs) * 100)) : 2;

        return response()->json(['percent' => $percent, 'status' => 'processing']);
    }

    public function downloadMp3(Video $video)
    {
        $this->checkDownloadAccess($video);

        $path = $video->localVideoPath();

        if (! file_exists($path)) {
            $nas  = app(\App\Services\NasSyncService::class);
            $path = $nas->ensureLocalCopy($video);
            if (! $path) {
                abort(404, 'Video file not found.');
            }
        }

        $slug   = $this->safeFilename($video->title, 'audio');
        $ffmpeg = \App\Models\Setting::ffmpegBinary();

        // Already MP3 — serve directly
        if (strtolower(pathinfo($video->filename, PATHINFO_EXTENSION)) === 'mp3') {
            $this->recordDownload($video, 'mp3');
            return response()->download($path, $this->safeFilename($video->title, 'audio') . '.mp3', [
                'Content-Type'        => 'audio/mpeg',
                'Content-Disposition' => $this->contentDisposition($video->title, 'mp3'),
            ]);
        }

        $isAudio = $this->isAudioOnlyFile($video);
        $hwaccel = $this->ffmpegHwaccelFlags(! $isAudio);
        $escaped = escapeshellarg($path);
        $tmp     = sys_get_temp_dir() . '/' . \Str::uuid() . '.mp3';

        $cmd = "{$ffmpeg} -y {$hwaccel}-i {$escaped}"
             . " -vn -acodec libmp3lame -q:a 2"
             . ' ' . escapeshellarg($tmp) . ' 2>/dev/null';

        set_time_limit(0);
        exec($cmd, $out, $exitCode);

        if ($exitCode !== 0 || ! file_exists($tmp) || filesize($tmp) === 0) {
            @unlink($tmp);
            abort(500, 'Failed to generate MP3 file.');
        }

        $this->recordDownload($video, 'mp3');

        return response()->download($tmp, $this->safeFilename($video->title, 'audio') . '.mp3', [
            'Content-Type'        => 'audio/mpeg',
            'Content-Disposition' => $this->contentDisposition($video->title, 'mp3'),
        ])->deleteFileAfterSend(true);
    }

    private function safeFilename(string $title, string $fallback): string
    {
        // Strip characters illegal on Windows/Linux/macOS filesystems, then trim
        $name = preg_replace('/[\\\\\/:\*\?"<>\|]/', '', $title);
        $name = trim($name);
        return $name !== '' ? $name : $fallback;
    }

    private function contentDisposition(string $title, string $ext): string
    {
        $ascii = \Str::slug($title) ?: 'download';
        $safe  = $this->safeFilename($title, $ascii);
        $utf8  = rawurlencode($safe . '.' . $ext);
        return 'attachment; filename="' . $ascii . '.' . $ext . '"; filename*=UTF-8\'\'' . $utf8;
    }

    private function downloadSlideshowVideo(Video $video, $slides, string $audioPath, string $ffmpeg)
    {
        if ($video->slideshow_video_path) {
            $cached = storage_path('app/' . $video->slideshow_video_path);
            if (file_exists($cached) && filesize($cached) > 0) {
                return response()->download($cached, $this->safeFilename($video->title, 'video') . '.mp4', [
                    'Content-Type'        => 'video/mp4',
                    'Content-Disposition' => $this->contentDisposition($video->title, 'mp4'),
                ]);
            }
        }

        return redirect()->route('videos.show', $video)->with('_auto_dl_video', true);
    }

    /** Full video codec flags for raw FFmpeg shell commands (GPU if enabled, else CPU). */
    private function ffmpegVideoFlags(bool $stillImage = false): string
    {
        return Setting::ffmpegVideoFlags($stillImage);
    }

    /** CPU-only codec flags — used as fallback when GPU encoding fails. */
    private function ffmpegVideoFlagsCpu(bool $stillImage = false): string
    {
        return Setting::ffmpegVideoFlagsCpu($stillImage);
    }

    /** hwaccel decode flags — only used when input is a real video file and GPU is on. */
    private function ffmpegHwaccelFlags(bool $inputIsVideo): string
    {
        return Setting::ffmpegHwaccelFlags($inputIsVideo);
    }

    /**
     * Song-folder-relative path (under storage/app) for the generated "Download Video".
     * Regenerable renders live in the song's own `cache/` subfolder — separated from the
     * source files and kept LOCAL-only (never pushed to NAS):
     *   {song-folder}/cache/video.mp4  /  cache/video-viz.mp4
     */
    private function slideshowRel(Video $video, bool $viz, int $trackId = 0, bool $lyrics = false): string
    {
        $nas = app(\App\Services\NasSyncService::class);
        return $this->nasVideoDir($video, $nas) . '/cache/video'
             . ($trackId ? '-t' . $trackId : '')
             . ($viz ? '-viz' : '')
             . ($lyrics ? '-lyr' : '') . '.mp4';
    }

    /**
     * Return the NAS video root directory (where thumb.jpg and slides/ live).
     * When the primary file was promoted from a secondary track, video->path points
     * into a 'tracks/' subfolder — in that case we go up one extra level.
     */
    private function nasVideoDir(Video $video, \App\Services\NasSyncService $nas): string
    {
        if (str_starts_with($video->path, 'users/')) {
            $dir = dirname($video->path);
            if (basename($dir) === 'tracks') {
                $dir = dirname($dir);
            }
            return $dir;
        }
        return $nas->resolveVideoDir($video);
    }

    private function isAudioOnlyFile(Video $video): bool
    {
        if ($video->mime_type) {
            return str_starts_with($video->mime_type, 'audio/');
        }
        $audioExts = ['mp3', 'aac', 'm4a', 'ogg', 'wav', 'flac', 'opus', 'wma'];
        return in_array(strtolower(pathinfo($video->filename, PATHINFO_EXTENSION)), $audioExts);
    }

    private function recordDownload(Video $video, string $type): void
    {
        $request = request();
        $ip      = $request->header('CF-Connecting-IP')
                ?? $request->header('X-Real-IP')
                ?? $request->ip();
        $userId  = Auth::id();

        // Deduplicate: browsers/download managers sometimes retry the connection.
        // Skip recording if the same user (or guest IP) already downloaded this
        // video+type within the last 10 minutes.
        $alreadyRecorded = \DB::table('video_downloads')
            ->where('video_id', $video->id)
            ->where('type', $type)
            ->where('downloaded_at', '>=', now()->subMinutes(10))
            ->when(
                $userId !== null,
                fn ($q) => $q->where('user_id', $userId),
                fn ($q) => $q->whereNull('user_id')->where('ip_address', $ip)
            )
            ->exists();

        if ($alreadyRecorded) {
            return;
        }

        $geo = GeoIpService::lookup($ip);

        $fp = $request->cookie('_fp');
        $fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;

        \DB::table('video_downloads')->insert([
            'video_id'      => $video->id,
            'user_id'       => $userId,
            'ip_address'    => $ip,
            'country'       => $geo['country'],
            'country_name'  => $geo['country_name'],
            'user_agent'    => substr((string) $request->userAgent(), 0, 512),
            'device_hash'   => $fp,
            'type'          => $type,
            'downloaded_at' => now(),
        ]);

        \DB::table('videos')->where('id', $video->id)->increment('download_count');
    }

    public function recordShare(Video $video)
    {
        $userId = Auth::id();

        // Authenticated users reuse their existing share token for this video
        if ($userId) {
            $existing = \DB::table('video_shares')
                ->where('video_id', $video->id)
                ->where('user_id', $userId)
                ->first();
            if ($existing) {
                return response()->json(['url' => route('share.access', $existing->token)]);
            }
        }

        // Generate a unique 10-char token
        do {
            $token = Str::random(10);
        } while (\DB::table('video_shares')->where('token', $token)->exists());

        \DB::table('video_shares')->insert([
            'video_id'   => $video->id,
            'user_id'    => $userId,
            'token'      => $token,
            'created_at' => now(),
        ]);

        return response()->json(['url' => route('share.access', $token)]);
    }

    /**
     * Email a friend a properly-formatted share email, version-aware: the link and the
     * email's title reflect the language track the sender chose. The URL is built
     * server-side so outgoing mail can never carry an attacker-supplied link.
     */
    public function shareByEmail(Request $request, Video $video)
    {
        if (! $video->isShareable() || ! $video->canView(Auth::user())) {
            return response()->json(['error' => 'This video cannot be shared.'], 403);
        }

        $data = $request->validate([
            'email'   => 'required|email|max:255',
            'message' => 'nullable|string|max:500',
            'track'   => 'nullable|integer',
        ]);

        $trackId    = (int) ($data['track'] ?? 0);
        $shareTitle = $video->title;
        $shareUrl   = $video->share_url;
        if ($trackId && ($track = $video->audioTracks->firstWhere('id', $trackId))) {
            if (! empty($track->title)) $shareTitle = $track->title;
            $shareUrl .= (str_contains($shareUrl, '?') ? '&' : '?') . 'track=' . $trackId;
        }

        try {
            \Mail::to($data['email'])->send(new \App\Mail\VideoShared(
                $video,
                $shareUrl,
                Auth::user(),
                $data['message'] ?? null,
                $shareTitle,
            ));
        } catch (\Throwable $e) {
            \Log::error('Share-by-email failed: ' . $e->getMessage(), ['video_id' => $video->id]);
            return response()->json(['error' => 'Could not send the email right now. Please try again.'], 500);
        }

        return response()->json(['success' => true]);
    }

    /**
     * Share a video directly with selected members. Each recipient gets an in-app
     * notification (clicking it opens the video) and an email.
     */
    public function shareWithMembers(Request $request, Video $video)
    {
        if (! $video->isShareable() || ! $video->canView(Auth::user())) {
            return response()->json(['error' => 'This video cannot be shared.'], 403);
        }

        $data = $request->validate([
            'user_ids'   => 'required|array|min:1|max:30',
            'user_ids.*' => 'integer|exists:users,id',
            'message'    => 'nullable|string|max:500',
        ]);

        $sharer  = Auth::user();
        $message = $data['message'] ?? null;

        $recipients = \App\Models\User::whereIn('id', $data['user_ids'])
            ->where('id', '!=', $sharer->id)
            ->get();

        foreach ($recipients as $member) {
            try {
                $member->notify(new \App\Notifications\VideoSharedWithUser(
                    $video, $sharer, $message, $video->share_url
                ));
            } catch (\Throwable $e) {
                \Log::error('Share-to-member failed: ' . $e->getMessage(), [
                    'video_id' => $video->id, 'member_id' => $member->id,
                ]);
            }
        }

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

    public function accessShare(Request $request, string $token)
    {
        $share = \DB::table('video_shares')->where('token', $token)->first();
        if (! $share) {
            return redirect('/');
        }

        $video = Video::find($share->video_id);
        if (! $video || ! $video->canView(Auth::user())) {
            return redirect('/');
        }

        // Identify device via persistent cookie; generate one for first-time visitors
        $did   = $request->cookie('_did') ?: (string) Str::uuid();

        // Record only the first access from this device for this share link
        $seen = \DB::table('share_accesses')
            ->where('share_id', $share->id)
            ->where('device_id', $did)
            ->exists();

        if (! $seen) {
            $ip  = $request->header('CF-Connecting-IP')
                ?? $request->header('X-Real-IP')
                ?? $request->ip();
            $geo = GeoIpService::lookup($ip);

            $fp = $request->cookie('_fp');
            $fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;

            \DB::table('share_accesses')->insert([
                'share_id'    => $share->id,
                'device_id'   => $did,
                'ip_address'  => $ip,
                'country'     => $geo['country']      ?? null,
                'country_name'=> $geo['country_name'] ?? null,
                'user_agent'  => substr((string) $request->userAgent(), 0, 512),
                'device_hash' => $fp,
                'accessed_at' => now(),
            ]);
        }

        // Carry the version selector (?track=) through the redirect so the recipient opens
        // the exact language the sharer was listening to.
        $dest = route('videos.showByToken', $video->share_token);
        if ($request->filled('track')) {
            $dest .= '?track=' . (int) $request->input('track');
        }

        return redirect($dest)
            ->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
    }

    public function ogImage(Video $video)
    {
        // If video has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews
        if ($video->thumbnail) {
            $path = $video->localThumbnailPath();
            if ($path && file_exists($path)) {
                $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
                // Load source image via GD
                $src = match($ext) {
                    'png'  => @imagecreatefrompng($path),
                    'webp' => @imagecreatefromwebp($path),
                    'gif'  => @imagecreatefromgif($path),
                    default => @imagecreatefromjpeg($path),
                };
                if ($src) {
                    $ow = imagesx($src); $oh = imagesy($src);
                    // Fit inside 1200×630, preserving aspect ratio
                    $maxW = 1200; $maxH = 630;
                    $ratio = min($maxW / $ow, $maxH / $oh, 1.0);
                    $nw = (int)round($ow * $ratio); $nh = (int)round($oh * $ratio);
                    $dst = imagecreatetruecolor($nw, $nh);
                    // Preserve transparency before converting to JPEG (fill white)
                    $white = imagecolorallocate($dst, 255, 255, 255);
                    imagefill($dst, 0, 0, $white);
                    imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $ow, $oh);
                    imagedestroy($src);
                    ob_start();
                    imagejpeg($dst, null, 82);
                    imagedestroy($dst);
                    $jpeg = ob_get_clean();
                    return response($jpeg, 200, [
                        'Content-Type'  => 'image/jpeg',
                        'Cache-Control' => 'public, max-age=86400',
                    ]);
                }
            }
        }

        // Generate a branded 1200×630 fallback card
        $w = 1200; $h = 630;
        $img = imagecreatetruecolor($w, $h);

        $cBg     = imagecolorallocate($img, 10, 10, 10);
        $cRed    = imagecolorallocate($img, 230, 30, 30);
        $cWhite  = imagecolorallocate($img, 240, 240, 240);
        $cGray   = imagecolorallocate($img, 120, 120, 120);
        $cDark   = imagecolorallocate($img, 28, 28, 28);

        imagefill($img, 0, 0, $cBg);

        // Subtle vignette border
        imagefilledrectangle($img, 0, 0, $w - 1, $h - 1, $cDark);
        imagefilledrectangle($img, 4, 4, $w - 5, $h - 5, $cBg);

        // Red accent top bar
        imagefilledrectangle($img, 0, 0, $w, 8, $cRed);

        // Red accent bottom bar
        imagefilledrectangle($img, 0, $h - 8, $w, $h, $cRed);

        // Play button circle (center)
        $cx = (int)($w / 2); $cy = (int)($h / 2) - 30;
        $r  = 72;
        imagefilledellipse($img, $cx, $cy, $r * 2, $r * 2, $cRed);
        // Play triangle
        $tri = [
            $cx - 22, $cy - 30,
            $cx - 22, $cy + 30,
            $cx + 34, $cy,
        ];
        imagefilledpolygon($img, $tri, $cWhite);

        $fontBold   = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
        $fontNormal = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';

        // App name (top-left)
        imagettftext($img, 22, 0, 40, 56, $cRed, $fontBold, strtoupper(config('app.name')));

        // Video title (wrap long titles)
        $title = $video->title ?: 'Video';
        $maxChars = 42;
        if (mb_strlen($title) > $maxChars) {
            $lines = [];
            $words = explode(' ', $title);
            $line  = '';
            foreach ($words as $word) {
                if (mb_strlen($line . ' ' . $word) > $maxChars) {
                    $lines[] = trim($line);
                    $line = $word;
                } else {
                    $line .= ($line ? ' ' : '') . $word;
                }
            }
            if ($line) $lines[] = trim($line);
        } else {
            $lines = [$title];
        }
        $lines = array_slice($lines, 0, 2);
        $titleY = $cy + $r + 60;
        foreach ($lines as $i => $line) {
            $bbox = imagettfbbox(28, 0, $fontBold, $line);
            $tw   = $bbox[2] - $bbox[0];
            $tx   = (int)(($w - $tw) / 2);
            imagettftext($img, 28, 0, $tx, $titleY + $i * 44, $cWhite, $fontBold, $line);
        }

        // Channel name (below title)
        $channel = $video->user?->name ?? config('app.name');
        $bbox  = imagettfbbox(16, 0, $fontNormal, $channel);
        $tw    = $bbox[2] - $bbox[0];
        $tx    = (int)(($w - $tw) / 2);
        imagettftext($img, 16, 0, $tx, $titleY + count($lines) * 44 + 20, $cGray, $fontNormal, $channel);

        // Domain (bottom-right)
        $domain = parse_url(config('app.url'), PHP_URL_HOST) ?? config('app.url');
        imagettftext($img, 14, 0, $w - 300, $h - 24, $cGray, $fontNormal, $domain);

        ob_start();
        imagejpeg($img, null, 85);
        imagedestroy($img);
        $jpeg = ob_get_clean();

        return response($jpeg, 200, [
            'Content-Type'  => 'image/jpeg',
            'Cache-Control' => 'public, max-age=86400',
        ]);
    }

    public function trackProgress(Request $request, Video $video)
    {
        $seconds   = max(0, (int) $request->input('watched_seconds', 0));
        $completed = (bool) $request->boolean('completed');
        $viewDid   = $request->cookie('_did');

        $query = \DB::table('video_views')
            ->where('video_id', $video->id)
            ->where('watched_at', '>=', now()->subDay())
            ->orderByDesc('id')
            ->limit(1);

        if (Auth::check()) {
            $query->where('user_id', Auth::id());
        } else {
            $query->whereNull('user_id');
            if ($viewDid) {
                $query->where('device_id', $viewDid);
            } else {
                return response()->json(['ok' => false], 204);
            }
        }

        $view = $query->first();
        if (! $view) {
            return response()->json(['ok' => false], 204);
        }

        $updates = [];
        if ($seconds > (int) $view->watched_seconds) {
            $updates['watched_seconds'] = $seconds;
        }
        if ($completed && ! $view->completed) {
            $updates['completed'] = true;
        }
        if ($updates) {
            \DB::table('video_views')->where('id', $view->id)->update($updates);
        }

        return response()->json(['ok' => true]);
    }

    public function insights(Video $video)
    {
        if (Auth::id() !== $video->user_id) {
            abort(403);
        }

        $id  = $video->id;
        $now = now();

        $totalViews = \DB::table('video_views')->where('video_id', $id)->count();

        $uniqueViewers = \DB::table('video_views')
            ->where('video_id', $id)
            ->whereNotNull('user_id')
            ->distinct('user_id')
            ->count('user_id');

        $viewsToday = \DB::table('video_views')
            ->where('video_id', $id)
            ->where('watched_at', '>=', $now->copy()->startOfDay())
            ->count();

        $viewsThisWeek = \DB::table('video_views')
            ->where('video_id', $id)
            ->where('watched_at', '>=', $now->copy()->subDays(7))
            ->count();

        $viewsLastWeek = \DB::table('video_views')
            ->where('video_id', $id)
            ->whereBetween('watched_at', [
                $now->copy()->subDays(14),
                $now->copy()->subDays(7),
            ])
            ->count();

        $weekChange = $viewsLastWeek > 0
            ? round(($viewsThisWeek - $viewsLastWeek) / $viewsLastWeek * 100)
            : ($viewsThisWeek > 0 ? 100 : 0);

        // 14-day daily breakdown — segmented by viewer category (male / female / other-or-guest)
        $rawDaily = \DB::table('video_views')
            ->leftJoin('users', 'users.id', '=', 'video_views.user_id')
            ->selectRaw("date(video_views.watched_at) as day,
                sum(case when users.gender = 'male'   then 1 else 0 end) as male_cnt,
                sum(case when users.gender = 'female' then 1 else 0 end) as female_cnt,
                sum(case when video_views.user_id is null or (users.gender is null or users.gender not in ('male','female')) then 1 else 0 end) as other_cnt,
                count(*) as cnt")
            ->where('video_views.video_id', $id)
            ->where('video_views.watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
            ->groupByRaw("date(video_views.watched_at)")
            ->orderBy('day')
            ->get()
            ->keyBy('day');

        $daily = [];
        for ($i = 13; $i >= 0; $i--) {
            $d   = $now->copy()->subDays($i);
            $key = $d->format('Y-m-d');
            $row = $rawDaily->get($key);
            $daily[] = [
                'date'   => $key,
                'label'  => $d->format('M d'),
                'short'  => $d->format('D'),
                'count'  => $row ? (int) $row->cnt        : 0,
                'male'   => $row ? (int) $row->male_cnt   : 0,
                'female' => $row ? (int) $row->female_cnt : 0,
                'other'  => $row ? (int) $row->other_cnt  : 0,
            ];
        }

        // Country breakdown (top 10)
        $rawCountries = \DB::table('video_views')
            ->selectRaw("country, country_name, count(*) as cnt")
            ->where('video_id', $id)
            ->whereNotNull('country')
            ->groupBy('country', 'country_name')
            ->orderByDesc('cnt')
            ->limit(10)
            ->get();

        $totalGeo = $rawCountries->sum('cnt');
        $countries = $rawCountries->map(fn ($c) => [
            'code'  => $c->country,
            'name'  => $c->country_name,
            'count' => (int) $c->cnt,
            'pct'   => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
        ])->values();

        // Peak hour (0-23) across all views
        $driver  = \DB::getDriverName();
        $hourExpr = $driver === 'sqlite' ? "strftime('%H', watched_at)" : 'HOUR(watched_at)';
        $peakRow  = \DB::table('video_views')
            ->selectRaw("{$hourExpr} as hr, count(*) as cnt")
            ->where('video_id', $id)
            ->groupByRaw("{$hourExpr}")
            ->orderByDesc('cnt')
            ->first();
        $peakHour = $peakRow ? (int) $peakRow->hr : null;

        // Top registered viewers (by view count) — also surface device/browser of their latest visit
        $topViewersRaw = \DB::table('video_views')
            ->join('users', 'users.id', '=', 'video_views.user_id')
            ->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
            ->where('video_views.video_id', $id)
            ->whereNotNull('video_views.user_id')
            ->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
            ->orderByDesc('cnt')
            ->limit(20)
            ->get();

        // One extra query: most-recent user_agent per top viewer (small, indexed lookup)
        $topViewerIds  = $topViewersRaw->pluck('id')->all();
        $latestUaByUid = [];
        if ($topViewerIds) {
            $latestRows = \DB::table('video_views as v1')
                ->whereIn('v1.user_id', $topViewerIds)
                ->where('v1.video_id', $id)
                ->whereRaw('v1.watched_at = (select max(v2.watched_at) from video_views v2 where v2.user_id = v1.user_id and v2.video_id = v1.video_id)')
                ->get(['v1.user_id', 'v1.user_agent']);
            foreach ($latestRows as $row) {
                $latestUaByUid[$row->user_id] = $row->user_agent;
            }
        }

        $topViewers = $topViewersRaw->map(function ($u) use ($latestUaByUid) {
            $ua = $this->parseUserAgent($latestUaByUid[$u->id] ?? null);
            return [
                'id'      => $u->id,
                'channel' => $u->username,
                'name'    => $u->name,
                'avatar'  => $u->avatar
                    ? route('media.avatar', $u->avatar)
                    : 'https://i.pravatar.cc/150?u=' . $u->id,
                'count'   => (int) $u->cnt,
                'last_at' => $u->last_at,
                'device'  => $ua['device'],
                'browser' => $ua['browser'],
            ];
        });

        $guestViews = \DB::table('video_views')
            ->where('video_id', $id)
            ->whereNull('user_id')
            ->count();

        // Recent activity — grouped by viewer.
        // Registered users group by user_id. For guests we layer three signals,
        // strongest first: device_hash (multi-signal browser fingerprint) → device_id
        // cookie → ip_address (legacy / cookie-less). This survives cookie clears,
        // incognito mode, browser swaps, and VPN / country hops.
        $recentRaw = \DB::table('video_views')
            ->leftJoin('users', 'users.id', '=', 'video_views.user_id')
            ->select(
                'video_views.watched_at',
                'video_views.country',
                'video_views.ip_address',
                'video_views.device_id',
                'video_views.device_hash',
                'video_views.user_agent',
                'users.id as user_id',
                'users.name as user_name',
                'users.avatar as user_avatar',
                'users.username as user_channel'
            )
            ->where('video_views.video_id', $id)
            ->orderByDesc('video_views.watched_at')
            ->limit(500)
            ->get();

        $groups = [];
        foreach ($recentRaw as $r) {
            if ($r->user_id) {
                $key = 'u:' . $r->user_id;
            } else {
                $guestKey = $r->device_hash ?: ($r->device_id ?: ($r->ip_address ?: 'unknown'));
                $key      = 'g:' . $guestKey;
            }
            if (! isset($groups[$key])) {
                $ua = $this->parseUserAgent($r->user_agent);
                $groups[$key] = [
                    'key'          => $key,
                    'user_id'      => $r->user_id,
                    'user_channel' => $r->user_channel,
                    'user_name'    => $r->user_name ?? 'Guest',
                    'user_avatar'  => $r->user_id
                        ? ($r->user_avatar
                            ? route('media.avatar', $r->user_avatar)
                            : 'https://i.pravatar.cc/150?u=' . $r->user_id)
                        : null,
                    'country'      => $r->country,
                    'device'       => $ua['device'],
                    'browser'      => $ua['browser'],
                    'count'        => 0,
                    'last_at'      => $r->watched_at,
                ];
            }
            $groups[$key]['count']++;
            // first row is the most recent (orderByDesc) so device/browser reflect their latest visit
        }
        $recentViewers = array_values($groups);

        // Download details
        $totalDownloads = \DB::table('video_downloads')->where('video_id', $id)->count();

        // Per-type breakdown
        $dlByType = \DB::table('video_downloads')
            ->selectRaw('type, count(*) as cnt')
            ->where('video_id', $id)
            ->groupBy('type')
            ->pluck('cnt', 'type');

        // Per-user download counts (top 20 logged-in users) + device/browser of their latest download
        $dlUsersRaw = \DB::table('video_downloads')
            ->join('users', 'users.id', '=', 'video_downloads.user_id')
            ->selectRaw('users.id, users.name, users.avatar, count(*) as cnt, max(video_downloads.downloaded_at) as last_at')
            ->where('video_downloads.video_id', $id)
            ->whereNotNull('video_downloads.user_id')
            ->groupBy('users.id', 'users.name', 'users.avatar')
            ->orderByDesc('cnt')
            ->limit(20)
            ->get();

        $dlUserIds       = $dlUsersRaw->pluck('id')->all();
        $dlLatestUaByUid = [];
        if ($dlUserIds) {
            $latestDl = \DB::table('video_downloads as d1')
                ->whereIn('d1.user_id', $dlUserIds)
                ->where('d1.video_id', $id)
                ->whereRaw('d1.downloaded_at = (select max(d2.downloaded_at) from video_downloads d2 where d2.user_id = d1.user_id and d2.video_id = d1.video_id)')
                ->get(['d1.user_id', 'd1.user_agent']);
            foreach ($latestDl as $row) {
                $dlLatestUaByUid[$row->user_id] = $row->user_agent;
            }
        }

        $dlUsers = $dlUsersRaw->map(function ($u) use ($dlLatestUaByUid) {
            $ua = $this->parseUserAgent($dlLatestUaByUid[$u->id] ?? null);
            return [
                'id'      => $u->id,
                'name'    => $u->name,
                'avatar'  => $u->avatar
                    ? route('media.avatar', $u->avatar)
                    : 'https://i.pravatar.cc/150?u=' . $u->id,
                'count'   => (int) $u->cnt,
                'last_at' => $u->last_at,
                'device'  => $ua['device'],
                'browser' => $ua['browser'],
            ];
        });

        $dlGuests = \DB::table('video_downloads')
            ->where('video_id', $id)
            ->whereNull('user_id')
            ->count();

        // Recent download activity — grouped by downloader.
        // Guests are keyed by ip_address (video_downloads doesn't track device_id);
        // device/browser comes from the most-recent download's user_agent.
        $dlRecentRaw = \DB::table('video_downloads')
            ->leftJoin('users', 'users.id', '=', 'video_downloads.user_id')
            ->select(
                'video_downloads.type',
                'video_downloads.downloaded_at',
                'video_downloads.country',
                'video_downloads.country_name',
                'video_downloads.ip_address',
                'video_downloads.user_agent',
                'users.id as user_id',
                'users.name as user_name',
                'users.avatar as user_avatar'
            )
            ->where('video_downloads.video_id', $id)
            ->orderByDesc('video_downloads.downloaded_at')
            ->limit(500)
            ->get();

        $dlGroups = [];
        foreach ($dlRecentRaw as $r) {
            $key = $r->user_id ? 'u:' . $r->user_id : 'g:' . ($r->ip_address ?: 'unknown');
            if (! isset($dlGroups[$key])) {
                $ua = $this->parseUserAgent($r->user_agent);
                $dlGroups[$key] = [
                    'key'        => $key,
                    'user_id'    => $r->user_id,
                    'user_name'  => $r->user_name ?? 'Guest',
                    'user_avatar'=> $r->user_id
                        ? ($r->user_avatar
                            ? route('media.avatar', $r->user_avatar)
                            : 'https://i.pravatar.cc/150?u=' . $r->user_id)
                        : null,
                    'country'    => $r->country,
                    'type'       => $r->type,
                    'device'     => $ua['device'],
                    'browser'    => $ua['browser'],
                    'count'      => 0,
                    'last_at'    => $r->downloaded_at,
                ];
            }
            $dlGroups[$key]['count']++;
        }
        $dlRecent = array_values($dlGroups);

        // Gender breakdown (authenticated viewers only)
        $genderRows  = \DB::table('video_views')
            ->join('users', 'users.id', '=', 'video_views.user_id')
            ->selectRaw('users.gender, count(*) as cnt')
            ->where('video_views.video_id', $id)
            ->whereNotNull('video_views.user_id')
            ->whereNotNull('users.gender')
            ->groupBy('users.gender')
            ->get();

        $totalGender = $genderRows->sum('cnt');
        $genders = $genderRows->map(fn ($g) => [
            'gender' => $g->gender,
            'count'  => (int) $g->cnt,
            'pct'    => $totalGender > 0 ? round($g->cnt / $totalGender * 100) : 0,
        ])->values();

        // Age group breakdown (authenticated viewers with birthday set)
        $ageExpr = $driver === 'sqlite'
            ? "CAST((julianday(video_views.watched_at) - julianday(users.birthday)) / 365.25 AS INTEGER)"
            : "TIMESTAMPDIFF(YEAR, users.birthday, video_views.watched_at)";

        $rawAges = \DB::table('video_views')
            ->join('users', 'users.id', '=', 'video_views.user_id')
            ->selectRaw("{$ageExpr} as age, users.gender as gender")
            ->where('video_views.video_id', $id)
            ->whereNotNull('video_views.user_id')
            ->whereNotNull('users.birthday')
            ->get();

        $blank      = ['count' => 0, 'male' => 0, 'female' => 0, 'other' => 0];
        $ageBuckets = [
            'Under 13' => $blank, '13–17' => $blank, '18–24' => $blank, '25–34' => $blank,
            '35–44'    => $blank, '45–54' => $blank, '55–64' => $blank, '65+'   => $blank,
        ];
        foreach ($rawAges as $row) {
            $age = (int) $row->age;
            $bucket = match (true) {
                $age < 13  => 'Under 13',
                $age <= 17 => '13–17',
                $age <= 24 => '18–24',
                $age <= 34 => '25–34',
                $age <= 44 => '35–44',
                $age <= 54 => '45–54',
                $age <= 64 => '55–64',
                default    => '65+',
            };
            $ageBuckets[$bucket]['count']++;
            if ($row->gender === 'male')        $ageBuckets[$bucket]['male']++;
            elseif ($row->gender === 'female')  $ageBuckets[$bucket]['female']++;
            else                                $ageBuckets[$bucket]['other']++;
        }

        $totalAge   = array_sum(array_column($ageBuckets, 'count'));
        $ageGroups  = collect($ageBuckets)
            ->filter(fn ($b) => $b['count'] > 0)
            ->map(fn ($b, $label) => [
                'label'  => $label,
                'count'  => $b['count'],
                'male'   => $b['male'],
                'female' => $b['female'],
                'other'  => $b['other'],
                'pct'    => $totalAge > 0 ? round($b['count'] / $totalAge * 100) : 0,
            ])->values();

        // Who liked this video
        $likers = \DB::table('video_likes')
            ->join('users', 'users.id', '=', 'video_likes.user_id')
            ->select('users.id', 'users.name', 'users.avatar', 'users.username', 'video_likes.created_at as liked_at')
            ->where('video_likes.video_id', $id)
            ->orderByDesc('video_likes.created_at')
            ->limit(50)
            ->get()
            ->map(fn ($u) => [
                'id'      => $u->id,
                'channel' => $u->username,
                'name'    => $u->name,
                'avatar'  => $u->avatar
                    ? route('media.avatar', $u->avatar)
                    : 'https://i.pravatar.cc/150?u=' . $u->id,
                'liked_at' => $u->liked_at,
            ]);

        // ── Share analytics ────────────────────────────────────────
        $shareLinks = \DB::table('video_shares')->where('video_id', $id)->get();
        $shareIds   = $shareLinks->pluck('id');

        $shareReach = $shareIds->isEmpty() ? 0
            : \DB::table('share_accesses')->whereIn('share_id', $shareIds)->count();

        $shareLinksCount = $shareLinks->count();

        // Per-link breakdown for insights panel
        $shareBreakdown = $shareLinks->map(function ($s) {
            $accesses = \DB::table('share_accesses')->where('share_id', $s->id)->count();

            $user = $s->user_id
                ? \DB::table('users')->where('id', $s->user_id)->first(['id', 'name', 'avatar', 'username'])
                : null;

            // Most common country among accesses for this link
            $topCountry = \DB::table('share_accesses')
                ->where('share_id', $s->id)
                ->whereNotNull('country')
                ->selectRaw('country, country_name, count(*) as cnt')
                ->groupBy('country', 'country_name')
                ->orderByDesc('cnt')
                ->first();

            return [
                'token'        => $s->token,
                'sharer'       => $user ? $user->name : 'Guest',
                'sharer_id'    => $user ? $user->id : null,
                'sharer_channel' => $user ? ($user->username ?? $user->id) : null,
                'avatar'       => $user
                    ? ($user->avatar
                        ? route('media.avatar', $user->avatar)
                        : 'https://i.pravatar.cc/150?u=' . $user->id)
                    : null,
                'country'      => $topCountry ? $topCountry->country : null,
                'country_name' => $topCountry ? $topCountry->country_name : null,
                'reach'        => $accesses,
                'created_at'   => $s->created_at,
            ];
        })->sortByDesc('reach')->values()->take(10);

        // ── Skip rate ──────────────────────────────────────────────
        // Skipped = watched_seconds < max(10, 10% of duration)
        $duration       = (int) ($video->duration ?? 0);
        $skipThreshold  = max(10, (int) floor($duration * 0.10));
        $skippedViews   = \DB::table('video_views')
            ->where('video_id', $id)
            ->where('watched_seconds', '<', $skipThreshold)
            ->count();
        $skipRate = $totalViews > 0 ? round($skippedViews / $totalViews * 100) : 0;

        // ── Save rate ──────────────────────────────────────────────
        // Distinct users who added this video to a playlist (excluding the uploader's own playlists)
        $saveCount = \DB::table('playlist_videos')
            ->join('playlists', 'playlists.id', '=', 'playlist_videos.playlist_id')
            ->where('playlist_videos.video_id', $id)
            ->where('playlists.user_id', '!=', $video->user_id)
            ->distinct('playlists.user_id')
            ->count('playlists.user_id');
        $uniqueViewersAll = \DB::table('video_views')
            ->where('video_id', $id)
            ->whereNotNull('user_id')
            ->distinct('user_id')
            ->count('user_id');
        $saveRate = $uniqueViewersAll > 0 ? round($saveCount / $uniqueViewersAll * 100) : 0;

        // ── Profile (wall) visits originating from this video ───────
        $profileVisits = \DB::table('profile_visits')
            ->where('profile_user_id', $video->user_id)
            ->where('source_video_id', $id)
            ->count();

        // ── New subscribers driven by this video ───────────────────
        $newSubscribers = \DB::table('user_subscriptions')
            ->where('channel_id', $video->user_id)
            ->where('source_video_id', $id)
            ->count();

        // ── Comments (including replies) ───────────────────────────
        $commentsCount = \DB::table('comments')->where('video_id', $id)->count();

        // ── Accounts reached (distinct users + distinct guest devices) ──
        $reachedUsers   = $uniqueViewersAll;
        $reachedGuests  = \DB::table('video_views')
            ->where('video_id', $id)
            ->whereNull('user_id')
            ->whereNotNull('device_id')
            ->distinct('device_id')
            ->count('device_id');
        $accountsReached = $reachedUsers + $reachedGuests;

        return response()->json([
            'total_views'      => $totalViews,
            'unique_viewers'   => $uniqueViewers,
            'skip_rate'        => $skipRate,
            'skipped_views'    => $skippedViews,
            'skip_threshold'   => $skipThreshold,
            'save_count'       => $saveCount,
            'save_rate'        => $saveRate,
            'profile_visits'   => $profileVisits,
            'new_subscribers'  => $newSubscribers,
            'comments_count'   => $commentsCount,
            'accounts_reached' => $accountsReached,
            'reached_users'    => $reachedUsers,
            'reached_guests'   => $reachedGuests,
            'top_viewers'      => $topViewers,
            'guest_views'      => $guestViews,
            'recent_viewers'   => $recentViewers,
            'views_today'      => $viewsToday,
            'views_this_week'  => $viewsThisWeek,
            'views_last_week'  => $viewsLastWeek,
            'week_change'      => $weekChange,
            'downloads'        => $totalDownloads,
            'dl_video'         => (int) ($dlByType->get('video') ?? 0),
            'dl_mp3'           => (int) ($dlByType->get('mp3') ?? 0),
            'dl_users'         => $dlUsers,
            'dl_guests'        => $dlGuests,
            'dl_recent'        => $dlRecent,
            'shares'           => $shareReach,
            'share_links'      => $shareLinksCount,
            'share_breakdown'  => $shareBreakdown,
            'countries'        => $countries,
            'daily'            => $daily,
            'peak_hour'        => $peakHour,
            'likes'            => $video->like_count,
            'likers'           => $likers,
            'genders'          => $genders,
            'age_groups'       => $ageGroups,
        ]);
    }

    // ── Drill-down: viewers from one country ──────────────────────────────
    public function insightsCountry(Video $video, string $country)
    {
        if (Auth::id() !== $video->user_id) abort(403);

        $id     = $video->id;
        $country = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $country), 0, 2));

        $totalViews  = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->count();
        $countryName = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNotNull('country_name')->value('country_name');
        $guestCount  = \DB::table('video_views')->where('video_id', $id)->where('country', $country)->whereNull('user_id')->count();

        $registeredUsers = \DB::table('video_views')
            ->join('users', 'users.id', '=', 'video_views.user_id')
            ->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
            ->where('video_views.video_id', $id)
            ->where('video_views.country', $country)
            ->whereNotNull('video_views.user_id')
            ->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
            ->orderByDesc('cnt')
            ->limit(20)
            ->get()
            ->map(fn ($u) => [
                'id'      => $u->id,
                'channel' => $u->username,
                'name'    => $u->name,
                'avatar'  => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
                'count'   => (int) $u->cnt,
                'last_at' => $u->last_at,
            ]);

        // 14-day trend for this country
        $now      = now();
        $rawDaily = \DB::table('video_views')
            ->selectRaw("date(watched_at) as day, count(*) as cnt")
            ->where('video_id', $id)
            ->where('country', $country)
            ->where('watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
            ->groupByRaw("date(watched_at)")
            ->orderBy('day')
            ->pluck('cnt', 'day');

        $daily = [];
        for ($i = 13; $i >= 0; $i--) {
            $d       = $now->copy()->subDays($i);
            $daily[] = ['label' => $d->format('M d'), 'short' => $d->format('D'), 'count' => (int) ($rawDaily->get($d->format('Y-m-d')) ?? 0)];
        }

        return response()->json([
            'country'          => $country,
            'country_name'     => $countryName ?? $country,
            'total_views'      => $totalViews,
            'registered_users' => $registeredUsers,
            'guest_count'      => $guestCount,
            'daily'            => $daily,
        ]);
    }

    // ── Drill-down: viewers on a specific day ─────────────────────────────
    public function insightsDay(Video $video, string $date)
    {
        if (Auth::id() !== $video->user_id) abort(403);

        try {
            $day = \Carbon\Carbon::createFromFormat('Y-m-d', $date, config('app.timezone'));
        } catch (\Throwable) {
            abort(400, 'Invalid date');
        }

        $id    = $video->id;
        $start = $day->copy()->startOfDay();
        $end   = $day->copy()->endOfDay();

        $totalViews = \DB::table('video_views')->where('video_id', $id)->whereBetween('watched_at', [$start, $end])->count();
        $guestCount = \DB::table('video_views')->where('video_id', $id)->whereBetween('watched_at', [$start, $end])->whereNull('user_id')->count();

        $registeredUsers = \DB::table('video_views')
            ->join('users', 'users.id', '=', 'video_views.user_id')
            ->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
            ->where('video_views.video_id', $id)
            ->whereBetween('video_views.watched_at', [$start, $end])
            ->whereNotNull('video_views.user_id')
            ->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
            ->orderByDesc('cnt')
            ->limit(20)
            ->get()
            ->map(fn ($u) => [
                'id'      => $u->id,
                'channel' => $u->username,
                'name'    => $u->name,
                'avatar'  => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
                'count'   => (int) $u->cnt,
                'last_at' => $u->last_at,
            ]);

        // Hourly breakdown (0-23)
        $driver   = \DB::getDriverName();
        $hourExpr = $driver === 'sqlite' ? "CAST(strftime('%H', watched_at) AS INTEGER)" : 'HOUR(watched_at)';
        $rawHourly = \DB::table('video_views')
            ->selectRaw("{$hourExpr} as hr, count(*) as cnt")
            ->where('video_id', $id)
            ->whereBetween('watched_at', [$start, $end])
            ->groupByRaw($hourExpr)
            ->orderBy('hr')
            ->pluck('cnt', 'hr');

        $hourly = [];
        for ($h = 0; $h < 24; $h++) {
            $hourly[] = [
                'hour'  => $h,
                'label' => ($h === 0 ? '12am' : ($h < 12 ? $h . 'am' : ($h === 12 ? '12pm' : ($h - 12) . 'pm'))),
                'count' => (int) ($rawHourly->get($h) ?? 0),
            ];
        }

        // Top countries that day
        $countries = \DB::table('video_views')
            ->selectRaw("country, country_name, count(*) as cnt")
            ->where('video_id', $id)
            ->whereBetween('watched_at', [$start, $end])
            ->whereNotNull('country')
            ->groupBy('country', 'country_name')
            ->orderByDesc('cnt')
            ->limit(5)
            ->get()
            ->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name, 'count' => (int) $c->cnt]);

        return response()->json([
            'date'             => $day->format('M d, Y'),
            'day_of_week'      => $day->format('l'),
            'total_views'      => $totalViews,
            'registered_users' => $registeredUsers,
            'guest_count'      => $guestCount,
            'hourly'           => $hourly,
            'countries'        => $countries,
        ]);
    }

    // ── Drill-down: one user's full download history on this video ────────
    public function insightsDownloaderHistory(Video $video, int $userId)
    {
        if (Auth::id() !== $video->user_id) abort(403);

        $user = \App\Models\User::findOrFail($userId);

        $records = \DB::table('video_downloads')
            ->where('video_id', $video->id)
            ->where('user_id', $userId)
            ->orderByDesc('downloaded_at')
            ->get(['id', 'type', 'country', 'country_name', 'downloaded_at'])
            ->map(fn ($r) => [
                'type'         => $r->type,
                'country'      => $r->country,
                'country_name' => $r->country_name,
                'at'           => $r->downloaded_at,
            ]);

        return response()->json([
            'user' => [
                'id'     => $user->id,
                'name'   => $user->name,
                'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
            ],
            'total'   => $records->count(),
            'records' => $records,
        ]);
    }

    // ── Backfill a device fingerprint after the JS computes it ────────────
    // On the very first visit the cookie isn't there yet, so the view row is
    // inserted with device_hash = NULL. fp.js calls this endpoint a few hundred
    // ms later with the freshly-computed hash so we can stamp it onto the row
    // that was just inserted (and the future cookie does the rest).
    public function identify(Request $request, Video $video)
    {
        $hash = (string) $request->input('hash', '');
        if (! preg_match('/^[a-f0-9]{64}$/', $hash)) {
            return response()->json(['ok' => false], 422);
        }

        $ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip();

        // Update the latest matching view row for this caller (user OR cookie OR IP)
        // within a short window so we don't accidentally stamp someone else's row.
        $q = \DB::table('video_views')
            ->where('video_id', $video->id)
            ->where('watched_at', '>', now()->subMinutes(10))
            ->whereNull('device_hash');

        if (Auth::check()) {
            $q->where('user_id', Auth::id());
        } else {
            $did = $request->cookie('_did');
            $q->whereNull('user_id')->where(function ($w) use ($did, $ip) {
                if ($did) $w->where('device_id', $did);
                $w->orWhere('ip_address', $ip);
            });
        }

        $q->orderByDesc('watched_at')->limit(1)->update(['device_hash' => $hash]);

        return response()->json(['ok' => true])
            ->withCookie(cookie('_fp', $hash, 60 * 24 * 365 * 5));
    }

    // ── User-agent → coarse device / browser / OS labels ──────────────────
    // Shared by every insights endpoint so labels stay consistent.
    private function parseUserAgent(?string $ua): array
    {
        if (! $ua) return ['device' => 'Unknown', 'os' => 'Unknown', 'browser' => 'Unknown'];

        // Device family
        $device = 'Desktop';
        if (preg_match('/iPad/i', $ua))            $device = 'iPad';
        elseif (preg_match('/Tablet/i', $ua))      $device = 'Tablet';
        elseif (preg_match('/iPhone|iPod/i', $ua)) $device = 'iPhone';
        elseif (preg_match('/Android/i', $ua))     $device = preg_match('/Mobile/i', $ua) ? 'Android phone' : 'Android tablet';
        elseif (preg_match('/Mobile/i', $ua))      $device = 'Mobile';

        // OS
        $os = 'Unknown';
        if (preg_match('/Windows NT 10/i', $ua))                       $os = 'Windows 10/11';
        elseif (preg_match('/Windows NT/i', $ua))                      $os = 'Windows';
        elseif (preg_match('/Mac OS X ([0-9_\.]+)/i', $ua, $m))         $os = 'macOS ' . str_replace('_', '.', $m[1]);
        elseif (preg_match('/Android ([0-9\.]+)/i', $ua, $m))           $os = 'Android ' . $m[1];
        elseif (preg_match('/iPhone OS ([0-9_]+)/i', $ua, $m))          $os = 'iOS ' . str_replace('_', '.', $m[1]);
        elseif (preg_match('/CPU OS ([0-9_]+)/i', $ua, $m))             $os = 'iPadOS ' . str_replace('_', '.', $m[1]);
        elseif (preg_match('/Linux/i', $ua))                            $os = 'Linux';
        elseif (preg_match('/CrOS/i', $ua))                             $os = 'ChromeOS';

        // Browser — order matters (Edge/Opera before Chrome; Chrome before Safari)
        $browser = 'Unknown';
        if (preg_match('/Edg\/([0-9\.]+)/i', $ua, $m))                  $browser = 'Edge ' . $m[1];
        elseif (preg_match('/OPR\/([0-9\.]+)/i', $ua, $m))              $browser = 'Opera ' . $m[1];
        elseif (preg_match('/Firefox\/([0-9\.]+)/i', $ua, $m))          $browser = 'Firefox ' . $m[1];
        elseif (preg_match('/Chrome\/([0-9\.]+)/i', $ua, $m))           $browser = 'Chrome ' . $m[1];
        elseif (preg_match('/Version\/([0-9\.]+).*Safari/i', $ua, $m))  $browser = 'Safari ' . $m[1];
        elseif (preg_match('/Safari\/([0-9\.]+)/i', $ua, $m))           $browser = 'Safari ' . $m[1];

        return ['device' => $device, 'os' => $os, 'browser' => $browser];
    }

    // ── Drill-down: detail for one share link ─────────────────────────────
    public function insightsShare(Video $video, string $token)
    {
        if (Auth::id() !== $video->user_id) abort(403);

        $share = \DB::table('video_shares')
            ->where('video_id', $video->id)
            ->where('token', $token)
            ->first();
        if (! $share) abort(404);

        // Sharer profile
        $sharer = ['is_guest' => true, 'name' => 'Guest', 'avatar' => null, 'channel' => null];
        if ($share->user_id) {
            $u = \DB::table('users')->where('id', $share->user_id)->first(['id', 'name', 'avatar', 'username']);
            if ($u) {
                $sharer = [
                    'is_guest' => false,
                    'id'       => $u->id,
                    'name'     => $u->name,
                    'channel'  => $u->username,
                    'avatar'   => $u->avatar
                        ? route('media.avatar', $u->avatar)
                        : 'https://i.pravatar.cc/150?u=' . $u->id,
                ];
            }
        }

        $accesses = \DB::table('share_accesses')
            ->where('share_id', $share->id)
            ->orderByDesc('accessed_at')
            ->get();

        // Country / device / browser / os aggregation
        $countries     = [];
        $deviceCounts  = [];
        $browserCounts = [];
        $osCounts      = [];
        foreach ($accesses as $a) {
            $code = $a->country ?: 'XX';
            if (! isset($countries[$code])) {
                $countries[$code] = ['code' => $code, 'name' => $a->country_name ?: $code, 'count' => 0];
            }
            $countries[$code]['count']++;

            $p = $this->parseUserAgent($a->user_agent ?? null);
            $deviceCounts[$p['device']]   = ($deviceCounts[$p['device']]   ?? 0) + 1;
            $browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
            $osCounts[$p['os']]           = ($osCounts[$p['os']]           ?? 0) + 1;
        }
        usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);
        arsort($deviceCounts);
        arsort($browserCounts);
        arsort($osCounts);

        $bucketise = fn (array $counts) => collect($counts)
            ->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
            ->values();

        $recent = $accesses->take(50)->map(function ($a) {
            $p = $this->parseUserAgent($a->user_agent ?? null);
            return [
                'at'      => $a->accessed_at,
                'country' => $a->country,
                'device'  => $p['device'],
                'browser' => $p['browser'],
            ];
        })->values();

        return response()->json([
            'token'      => $share->token,
            'created_at' => $share->created_at,
            'sharer'     => $sharer,
            'reach'      => $accesses->count(),
            'countries'  => array_values($countries),
            'devices'    => $bucketise($deviceCounts),
            'browsers'   => $bucketise($browserCounts),
            'os'         => $bucketise($osCounts),
            'recent'     => $recent,
        ]);
    }

    // ── Drill-down: one viewer's full view history on this video ─────────
    // $who is either "u:{userId}" for a registered user or "g:{ip}" for a guest.
    public function insightsViewer(Video $video, string $who)
    {
        if (Auth::id() !== $video->user_id) abort(403);

        $who    = substr($who, 0, 80);
        $isUser = str_starts_with($who, 'u:');
        $key    = substr($who, 2);

        $q = \DB::table('video_views')->where('video_id', $video->id);
        if ($isUser) {
            $q->where('user_id', (int) $key);
        } else {
            // Match across all three guest signals: fingerprint hash > device cookie > legacy IP
            $q->whereNull('user_id')->where(function ($w) use ($key) {
                $w->where('device_hash', $key)
                  ->orWhere('device_id', $key)
                  ->orWhere('ip_address', $key);
            });
        }

        $rows = $q->orderByDesc('watched_at')
            ->get(['watched_at', 'country', 'country_name', 'ip_address', 'device_id', 'device_hash', 'user_agent']);

        if ($rows->isEmpty()) abort(404);

        // Build identity block
        $identity = [
            'is_guest' => ! $isUser,
            'name'     => 'Guest',
            'avatar'   => null,
            'channel'  => null,
        ];
        if ($isUser) {
            $user = \App\Models\User::find((int) $key);
            if ($user) {
                $identity = [
                    'is_guest' => false,
                    'id'       => $user->id,
                    'name'     => $user->name,
                    'channel'  => $user->username,
                    'avatar'   => $user->avatar
                        ? route('media.avatar', $user->avatar)
                        : 'https://i.pravatar.cc/150?u=' . $user->id,
                ];
            }
        } else {
            $identity['name']   = 'Guest (' . $key . ')';
            $identity['avatar'] = 'https://i.pravatar.cc/150?u=guest-' . $key;
        }

        // Country aggregation
        $countries = [];
        foreach ($rows as $r) {
            $code = $r->country ?: 'XX';
            if (! isset($countries[$code])) {
                $countries[$code] = ['code' => $code, 'name' => $r->country_name ?: $code, 'count' => 0];
            }
            $countries[$code]['count']++;
        }
        usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);

        // Device / browser / OS — parse on the fly from user_agent
        $deviceCounts  = [];
        $browserCounts = [];
        $osCounts      = [];
        foreach ($rows as $r) {
            $p = $this->parseUserAgent($r->user_agent);
            $deviceCounts[$p['device']]   = ($deviceCounts[$p['device']]   ?? 0) + 1;
            $browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
            $osCounts[$p['os']]           = ($osCounts[$p['os']]           ?? 0) + 1;
        }
        arsort($deviceCounts);
        arsort($browserCounts);
        arsort($osCounts);

        $bucketise = fn (array $counts) => collect($counts)
            ->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
            ->values();

        // Recent timestamps (cap 50 for the modal)
        $recent = $rows->take(50)->map(fn ($r) => [
            'at'      => $r->watched_at,
            'country' => $r->country,
        ]);

        return response()->json([
            'identity'  => $identity,
            'total'     => $rows->count(),
            'first_at'  => $rows->last()->watched_at,
            'last_at'   => $rows->first()->watched_at,
            'countries' => $countries,
            'devices'   => $bucketise($deviceCounts),
            'browsers'  => $bucketise($browserCounts),
            'os'        => $bucketise($osCounts),
            'recent'    => $recent,
        ]);
    }
}

