<?php

namespace App\Services;

use App\Models\Setting;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use Illuminate\Support\Facades\Log;

class NasSyncService
{
    // ── Enable check ──────────────────────────────────────────────────────────

    public function isEnabled(): bool
    {
        $configured = Setting::get('nas_sync_enabled', 'false') === 'true'
            && ! empty($this->cfg()['host']);

        if (! $configured) return false;

        // Cache reachability for 2 minutes so a down NAS doesn't block every request.
        // When NAS comes back online the cache naturally expires and uploads resume.
        return \Illuminate\Support\Facades\Cache::remember('nas_reachable', 120, function () {
            $host = $this->cfg()['host'] ?? null;
            if (! $host) return false;
            $sock = @fsockopen($host, 445, $errno, $errstr, 2);
            if ($sock) { fclose($sock); return true; }
            return false;
        });
    }

    /**
     * Flush the cached reachability flag so the next isEnabled() call re-tests.
     * Call this after the admin toggles the NAS setting or from tests.
     */
    public function flushReachabilityCache(): void
    {
        \Illuminate\Support\Facades\Cache::forget('nas_reachable');
    }

    // ── Slug helpers ──────────────────────────────────────────────────────────

    public function userSlug(User $user): string
    {
        return $user->username ?: (string) $user->id;
    }

    public function titleSlug(string $title): string
    {
        // NFC normalisation ensures consistent codepoints across sources
        $title = \Normalizer::normalize($title, \Normalizer::FORM_C) ?: $title;

        // Keep all Unicode letters and numbers; replace everything else with dashes.
        // This preserves Chinese, Arabic, Japanese, Cyrillic, etc. as-is.
        // Characters illegal in SMB/Windows filenames (\ / : * ? " < > |) are all
        // excluded by \p{L}\p{N}, so the result is always filesystem-safe.
        $slug = preg_replace('/[^\p{L}\p{N}]+/u', '-', $title);
        $slug = mb_strtolower($slug);
        $slug = trim($slug, '-');

        // Cap at 100 chars to stay safely within any filesystem path limit
        if (mb_strlen($slug) > 100) {
            $slug = rtrim(mb_substr($slug, 0, 100), '-');
        }

        return $slug ?: 'video';
    }

    /**
     * Top-level folder under users/{slug}/ for a video of the given type.
     * Frozen at upload time — see CLAUDE.md (canonical storage layout).
     */
    public function typeFolder(Video $video): string
    {
        return match ($video->type) {
            'music'   => 'music',
            'match'   => 'sports',
            default   => 'videos', // generic + any unknown
        };
    }

    /**
     * Track folder name inside a music song folder. Format: {lang}-{db-id}.
     * For the primary audio (no VideoAudioTrack row), pass null — the videos
     * row id is used as the track id and the video's primary language as the
     * language. Always lowercase. Falls back to 'xx' when no language is set.
     */
    public function trackFolderName(Video $video, ?VideoAudioTrack $track = null): string
    {
        $lang = mb_strtolower(trim((string) ($track ? $track->language : $video->language)));
        if ($lang === '') $lang = 'xx';
        $id = $track ? $track->id : $video->id;
        return "{$lang}-{$id}";
    }

    /**
     * Absolute NAS-relative path to a music track's folder. Music only.
     * Caller must ensure $video->type === 'music'.
     */
    public function trackDir(Video $video, ?VideoAudioTrack $track = null): string
    {
        return $this->resolveVideoDir($video) . '/tracks/' . $this->trackFolderName($video, $track);
    }

    /**
     * Return the NAS directory for a video.
     *
     * On first sync  → pick a conflict-free slug and create the folder.
     * On subsequent  → find the existing folder by matching video ID in meta.json
     *                  so renames of the title never lose the folder.
     *
     * Type-segregated: music → music/, sports → sports/, generic → videos/.
     * The folder is frozen at first resolution — if the video's path already
     * points somewhere, that location wins (so type edits don't move files).
     */
    public function resolveVideoDir(Video $video): string
    {
        // Already organised — trust the stored path verbatim. This keeps legacy
        // flat layouts working and prevents type edits from relocating files.
        if (str_starts_with((string) $video->path, 'users/')) {
            // Walk up past any tracks/{lang-id}/ then file-name segments.
            $segs = explode('/', $video->path);
            // Expect users/{slug}/{type-folder}/{video-slug}/...
            if (count($segs) >= 4) {
                return implode('/', array_slice($segs, 0, 4));
            }
        }

        $video->loadMissing('user');
        $userSlug  = $this->userSlug($video->user);
        $base      = "users/{$userSlug}/" . $this->typeFolder($video);
        $titleSlug = $this->titleSlug($video->title);

        // 1. Try the current title slug and numbered variants (-2, -3 …)
        //    If any of them already hold meta.json with our video ID → reuse it.
        for ($serial = 1; $serial <= 50; $serial++) {
            $candidate = $serial === 1 ? $titleSlug : "{$titleSlug}-{$serial}";
            $meta      = $this->readMeta("{$base}/{$candidate}");

            if ($meta === null) {
                // Folder is free — this is where we'll write
                return "{$base}/{$candidate}";
            }

            if (($meta['id'] ?? null) === $video->id) {
                // This folder already belongs to our video
                return "{$base}/{$candidate}";
            }
        }

        // 2. Fallback: scan all folders in the user's videos directory
        $found = $this->scanForVideoDir($base, $video->id);
        if ($found) return $found;

        // 3. Last resort: use title slug + video ID suffix (guaranteed unique)
        return "{$base}/{$titleSlug}-{$video->id}";
    }

    /**
     * Read meta.json from a NAS directory. Returns decoded array or null.
     */
    private function readMeta(string $dir): ?array
    {
        $content = $this->getContent("{$dir}/meta.json");
        if ($content === null) return null;
        $decoded = json_decode($content, true);
        return is_array($decoded) ? $decoded : null;
    }

    /**
     * List all subdirectories under $base and find the one whose
     * meta.json carries the given video ID.
     */
    private function scanForVideoDir(string $base, int $videoId): ?string
    {
        $cfg    = $this->cfg();
        $target = escapeshellarg($this->smbTarget($cfg));
        $cred   = escapeshellarg($this->smbCredential($cfg));

        $smbPath = str_replace('/', '\\', $base) . '\\*';
        exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg("ls \"{$smbPath}\"") . ' 2>&1', $output);

        foreach ($output as $line) {
            if (! preg_match('/^  (.+?)\s{2,}D\s/', $line, $m)) continue;
            $name = trim($m[1]);
            if ($name === '.' || $name === '..') continue;

            $meta = $this->readMeta("{$base}/{$name}");
            if (($meta['id'] ?? null) === $videoId) {
                return "{$base}/{$name}";
            }
        }

        return null;
    }

    // ── Local directory layout (mirrors NAS schema) ───────────────────────────

    /**
     * Absolute path to the local directory for a video, mirroring the NAS structure:
     *   storage/app/users/{username}/videos/{title-slug}/
     *
     * If the video is already organised (video->path starts with "users/"), the dir
     * is derived directly from the stored path so title renames never relocate files.
     * Otherwise a free slug-based slot is found (same logic as resolveVideoDir but local).
     */
    public function localVideoDir(Video $video): string
    {
        // Already organised — derive from path (respects whichever type folder it
        // ended up in, even if the type has since been edited).
        if (str_starts_with((string) $video->path, 'users/')) {
            $segs = explode('/', $video->path);
            if (count($segs) >= 4) {
                return storage_path('app/' . implode('/', array_slice($segs, 0, 4)));
            }
            // Defensive fallback for malformed legacy paths
            $dir = dirname(storage_path('app/' . $video->path));
            if (basename($dir) === 'tracks') $dir = dirname($dir);
            return $dir;
        }

        $video->loadMissing('user');
        $userSlug  = $this->userSlug($video->user);
        $base      = storage_path("app/users/{$userSlug}/" . $this->typeFolder($video));
        $titleSlug = $this->titleSlug($video->title);

        for ($i = 1; $i <= 50; $i++) {
            $candidate = $i === 1 ? $titleSlug : "{$titleSlug}-{$i}";
            $dir       = "{$base}/{$candidate}";

            if (! is_dir($dir)) {
                return $dir; // free slot
            }

            $metaFile = "{$dir}/meta.json";
            if (file_exists($metaFile)) {
                $meta = @json_decode(file_get_contents($metaFile), true);
                if (is_array($meta) && ($meta['id'] ?? null) === $video->id) {
                    return $dir; // already belongs to this video
                }
            }
        }

        return "{$base}/{$titleSlug}-{$video->id}";
    }

    /**
     * Move a freshly uploaded video's files into the NAS-mirrored local directory
     * structure and update the DB record paths.
     *
     * Call this immediately after Video::create() in the upload flow.
     * The method is idempotent: if the video is already organised it does nothing.
     */
    public function organizeLocalFiles(Video $video): void
    {
        // Already organised
        if (str_starts_with($video->path, 'users/')) return;

        $video->loadMissing(['user', 'slides']);

        $videoDir = $this->localVideoDir($video);                  // users/{slug}/{type-folder}/{slug}
        $isMusic  = ($video->type === 'music');
        // Music wraps the primary inside tracks/{lang-id}/; others stay flat.
        $primaryDir = $isMusic
            ? $videoDir . '/tracks/' . $this->trackFolderName($video, null)
            : $videoDir;

        @mkdir($primaryDir, 0755, true);

        $userSlug   = $this->userSlug($video->user);
        $videoRel   = 'users/' . $userSlug . '/' . $this->typeFolder($video) . '/' . basename($videoDir);
        $primaryRel = $isMusic
            ? $videoRel . '/tracks/' . $this->trackFolderName($video, null)
            : $videoRel;
        $updates    = [];

        // ── Video / primary audio file ───────────────────────────────────
        $oldVideoPath = storage_path('app/' . $video->path);
        if (file_exists($oldVideoPath)) {
            $ext         = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
            $canonical   = ($isMusic ? 'audio' : 'video') . ".{$ext}";
            rename($oldVideoPath, "{$primaryDir}/{$canonical}");
            $updates['path']     = "{$primaryRel}/{$canonical}";
            $updates['filename'] = $canonical;
        }

        // ── Slides — music primary track only ────────────────────────────
        $firstSlideRelPath = null;
        if ($isMusic && $video->slides->isNotEmpty()) {
            @mkdir("{$primaryDir}/slides", 0755, true);
            foreach ($video->slides->sortBy('position') as $slide) {
                $oldSlidePath = storage_path('app/public/thumbnails/' . $slide->filename);
                if (! file_exists($oldSlidePath)) continue;
                $ext          = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
                $newSlideName = "{$slide->position}.{$ext}";
                rename($oldSlidePath, "{$primaryDir}/slides/{$newSlideName}");
                $newSlideFilename = "{$primaryRel}/slides/{$newSlideName}";
                $slide->update(['filename' => $newSlideFilename]);
                if ($firstSlideRelPath === null) {
                    $firstSlideRelPath = $newSlideFilename;
                }
            }
            if ($firstSlideRelPath !== null) {
                $updates['thumbnail'] = $firstSlideRelPath;
            }
        }

        // ── Standalone thumbnail (sports/generic + music-without-slides) ─
        if ($video->thumbnail && ! isset($updates['thumbnail'])) {
            $oldThumbPath = storage_path('app/public/thumbnails/' . $video->thumbnail);
            if (file_exists($oldThumbPath)) {
                $ext          = pathinfo($video->thumbnail, PATHINFO_EXTENSION) ?: 'jpg';
                $newThumbName = "thumb.{$ext}";
                $thumbDirAbs  = $isMusic ? $primaryDir : $videoDir;
                $thumbRelDir  = $isMusic ? $primaryRel : $videoRel;
                rename($oldThumbPath, "{$thumbDirAbs}/{$newThumbName}");
                $updates['thumbnail'] = "{$thumbRelDir}/{$newThumbName}";
            }
        }

        // ── meta.json (lives at the video / song root, not per-track) ────
        $this->writeLocalMeta($video, $videoDir);

        if (! empty($updates)) {
            $video->update($updates);
        }
    }

    /**
     * Write a meta.json to the local video directory so localVideoDir() can
     * resolve the folder even after a title rename.
     */
    public function writeLocalMeta(Video $video, string $dir): void
    {
        $meta = json_encode([
            'id'         => $video->id,
            'user_id'    => $video->user_id,
            'title'      => $video->title,
            'created_at' => $video->created_at?->toIso8601String(),
        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);

        @file_put_contents("{$dir}/meta.json", $meta);
    }

    // ── Local-cache helpers (used when NAS is the primary storage) ───────────

    /**
     * Absolute path where a NAS video is cached locally for HTTP streaming.
     * Separate from public storage so it's never accidentally served statically.
     */
    public function localCachePath(Video $video): string
    {
        return storage_path('app/nas_cache/videos/' . $video->filename);
    }

    /**
     * Ensure the video file is available at a local path for streaming.
     *
     * Priority:
     *   1. Regular local storage  (video uploaded while NAS was off, or not yet cleaned)
     *   2. NAS stream cache       (previously downloaded copy)
     *   3. Download from NAS      (first-time stream after NAS-primary upload)
     *
     * Returns the local absolute path, or null if unavailable.
     */
    public function ensureLocalCopy(Video $video): ?string
    {
        // 1. Regular local storage
        $regularPath = storage_path('app/' . $video->path);
        if (file_exists($regularPath)) return $regularPath;

        // 2. Existing stream cache
        $cachePath = $this->localCachePath($video);
        if (file_exists($cachePath)) return $cachePath;

        // 3. Download from NAS
        if (! $this->isEnabled()) return null;

        // When the video's path is a full NAS-relative path (starts with 'users/'), use it
        // directly — this handles promoted tracks whose path is e.g. 'users/…/tracks/14.mp3'
        // rather than the standard 'users/…/title-slug.mp3' layout.
        if (str_starts_with($video->path, 'users/')) {
            $this->ensureLocalAsset($regularPath, $video->path);
            if (file_exists($regularPath)) {
                Log::info('NAS: video cached locally for streaming', ['video_id' => $video->id]);
                return $regularPath;
            }
            Log::warning('NAS: failed to cache video for streaming', [
                'video_id' => $video->id,
                'nas_path' => $video->path,
            ]);
            return null;
        }

        $cacheDir = dirname($cachePath);
        if (! is_dir($cacheDir)) mkdir($cacheDir, 0755, true);

        $nasDir   = $this->resolveVideoDir($video);
        $ext      = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
        $fileSlug = $this->titleSlug($video->title);
        $nasFile  = "{$nasDir}/{$fileSlug}.{$ext}";

        $cfg    = $this->cfg();
        $target = escapeshellarg($this->smbTarget($cfg));
        $cred   = escapeshellarg($this->smbCredential($cfg));

        exec('smbclient ' . $target . ' -U ' . $cred
            . ' -c ' . escapeshellarg('get "' . $nasFile . '" "' . $cachePath . '"')
            . ' 2>&1', $out, $code);

        if ($code !== 0 || ! file_exists($cachePath)) {
            @unlink($cachePath);
            Log::warning('NAS: failed to cache video for streaming', [
                'video_id' => $video->id,
                'nas_path' => $nasFile,
                'output'   => implode(' ', $out),
            ]);
            return null;
        }

        Log::info('NAS: video cached locally for streaming', ['video_id' => $video->id]);
        return $cachePath;
    }

    /**
     * Resolve a secondary audio track to a readable local path.
     *
     * Mirrors ensureLocalCopy() for the primary file so a demoted/secondary track
     * (e.g. the old primary after a language swap) streams with the same robustness:
     *   1. Regular local storage  (file still at the track's stored path)
     *   2. NAS stream cache       (downloaded earlier, e.g. while it was the primary)
     *   3. Download from NAS      (when NAS is reachable)
     *
     * Returns the local absolute path, or null if the file cannot be located.
     */
    public function ensureLocalTrackCopy(VideoAudioTrack $track): ?string
    {
        // 1. Regular local storage
        $regularPath = storage_path('app/' . $track->path);
        if (file_exists($regularPath)) return $regularPath;

        // 2. Existing NAS stream cache (keyed by filename, shared with the primary path)
        $cachePath = storage_path('app/nas_cache/videos/' . $track->filename);
        if (file_exists($cachePath)) {
            Log::info('NAS: audio track served from stream cache', [
                'track_id' => $track->id,
                'video_id' => $track->video_id,
                'cache'    => $cachePath,
            ]);
            return $cachePath;
        }

        // 3. Download from NAS
        if (! $this->isEnabled()) {
            Log::warning('NAS: audio track unavailable (NAS disabled, no local/cache copy)', [
                'track_id' => $track->id,
                'video_id' => $track->video_id,
                'path'     => $track->path,
            ]);
            return null;
        }

        if (str_starts_with($track->path, 'users/')) {
            $this->ensureLocalAsset($regularPath, $track->path);
            if (file_exists($regularPath)) {
                Log::info('NAS: audio track cached locally for streaming', [
                    'track_id' => $track->id,
                    'video_id' => $track->video_id,
                ]);
                return $regularPath;
            }
        }

        Log::warning('NAS: failed to resolve audio track for streaming', [
            'track_id' => $track->id,
            'video_id' => $track->video_id,
            'path'     => $track->path,
        ]);
        return null;
    }

    /**
     * Ensure a local asset file exists, downloading it from the NAS if missing.
     * Used by MediaController to serve thumbnails, avatars, and banners with a NAS fallback.
     *
     * Returns true if the file is available locally after the call.
     */
    public function ensureLocalAsset(string $localPath, string $nasPath): bool
    {
        if (file_exists($localPath)) return true;

        // Don't shell out to smbclient when we already know NAS is unreachable
        // — smbclient hangs ~30 sec on the SMB timeout, multiplied by every
        // thumbnail/avatar on the page. isEnabled() uses a 2-sec TCP probe
        // cached for 120 sec, so this fails fast during an outage.
        if (! $this->isEnabled()) return false;

        $dir = dirname($localPath);
        if (! is_dir($dir)) @mkdir($dir, 0755, true);

        $cfg    = $this->cfg();
        $target = escapeshellarg($this->smbTarget($cfg));
        $cred   = escapeshellarg($this->smbCredential($cfg));

        exec('smbclient ' . $target . ' -U ' . $cred
            . ' -c ' . escapeshellarg('get "' . $nasPath . '" "' . $localPath . '"')
            . ' 2>&1', $out, $code);

        if ($code !== 0 || ! file_exists($localPath)) {
            @unlink($localPath);
            Log::warning('NAS: failed to fetch asset', [
                'nas_path' => $nasPath,
                'output'   => implode(' ', $out),
            ]);
            return false;
        }

        return true;
    }

    /**
     * Delete cached NAS video files from nas_cache/videos/.
     *
     * @param int $olderThanHours  Only delete files last-accessed more than N hours ago.
     *                             Pass 0 to delete everything regardless of age.
     * @return int  Number of files deleted.
     */
    public function clearNasCache(int $olderThanHours = 24): int
    {
        $cacheDir = storage_path('app/nas_cache/videos');
        if (! is_dir($cacheDir)) return 0;

        $cutoff  = time() - ($olderThanHours * 3600);
        $deleted = 0;

        foreach (glob("{$cacheDir}/*") as $file) {
            if (! is_file($file)) continue;
            // Use mtime (last modified) as a proxy for last-used
            if ($olderThanHours === 0 || filemtime($file) < $cutoff) {
                if (@unlink($file)) {
                    $deleted++;
                    Log::info('NAS cache: evicted ' . basename($file));
                }
            }
        }

        // Remove the directory itself if now empty
        if (is_dir($cacheDir) && empty(array_diff(scandir($cacheDir) ?: [], ['.', '..']))) {
            @rmdir($cacheDir);
            $parent = dirname($cacheDir); // nas_cache/
            if (is_dir($parent) && empty(array_diff(scandir($parent) ?: [], ['.', '..']))) {
                @rmdir($parent);
            }
        }

        return $deleted;
    }

    /**
     * Return the total size in bytes of all files in nas_cache/videos/.
     */
    public function nasCacheSize(): int
    {
        $cacheDir = storage_path('app/nas_cache/videos');
        if (! is_dir($cacheDir)) return 0;

        $total = 0;
        foreach (glob("{$cacheDir}/*") as $file) {
            if (is_file($file)) $total += filesize($file);
        }
        return $total;
    }

    /**
     * Delete the local video file after it has been successfully pushed to NAS.
     */
    public function deleteLocalVideo(Video $video): void
    {
        $path = storage_path('app/' . $video->path);
        if (file_exists($path)) {
            @unlink($path);
            Log::info('NAS: local video removed after NAS push', ['video_id' => $video->id]);
        }
    }

    /**
     * Delete local thumbnail and slide images after they have been pushed to NAS.
     * MediaController will re-fetch them from NAS on demand via ensureLocalAsset().
     */
    public function deleteLocalAssets(Video $video): void
    {
        $thumb = $video->localThumbnailPath();
        if ($thumb && file_exists($thumb)) {
            @unlink($thumb);
        }

        $video->loadMissing('slides');
        foreach ($video->slides as $slide) {
            $path = $slide->localPath();
            if (file_exists($path)) {
                @unlink($path);
            }
        }

        Log::info('NAS: local assets removed after NAS push', ['video_id' => $video->id]);
    }

    /**
     * Remove the local video directory and all empty ancestor directories up to
     * storage/app/users. Call this after deleteLocalVideo + deleteLocalAssets so
     * no ghost folders are left behind.
     */
    public function pruneLocalVideoDir(Video $video): void
    {
        $videoDir = $this->localVideoDir($video);

        // Remove meta.json helper file
        @unlink("{$videoDir}/meta.json");

        // Remove slides/ subdirectory if empty
        $slidesDir = "{$videoDir}/slides";
        if (is_dir($slidesDir) && $this->isDirEmpty($slidesDir)) {
            @rmdir($slidesDir);
        }

        // Remove the video directory itself if now empty
        if (is_dir($videoDir) && $this->isDirEmpty($videoDir)) {
            @rmdir($videoDir);

            // Walk up: remove videos/ and users/{username}/ if they become empty
            $parent = dirname($videoDir); // …/users/{username}/videos
            if (is_dir($parent) && $this->isDirEmpty($parent)) {
                @rmdir($parent);

                $grandparent = dirname($parent); // …/users/{username}
                if (is_dir($grandparent) && $this->isDirEmpty($grandparent)) {
                    @rmdir($grandparent);
                }
            }
        }

        Log::info('NAS: local video directory pruned', ['video_id' => $video->id, 'dir' => $videoDir]);
    }

    private function isDirEmpty(string $dir): bool
    {
        $items = array_diff(scandir($dir) ?: [], ['.', '..']);
        return empty($items);
    }

    // ── Direct NAS upload (NAS-primary mode) ──────────────────────────────────

    /**
     * Push a freshly uploaded video directly to the NAS without keeping a local copy.
     *
     * Called from VideoController::store() when NAS is enabled.
     * Updates video->path, video->filename, video->thumbnail, and status in the DB.
     *
     * @param Video       $video
     * @param string      $tempVideoPath   Absolute path to the temporary local video file
     * @param string|null $tempThumbPath   Absolute path to the temporary thumbnail (may be null)
     * @param array       $slideAbsPaths   [ position => absPath ] for audio slides
     */
    public function uploadDirectToNas(
        Video $video,
        string $tempVideoPath,
        ?string $tempThumbPath,
        array $slideAbsPaths = []
    ): void {
        $video->loadMissing(['user', 'slides']);

        $videoDir = $this->resolveVideoDir($video);          // users/{slug}/{type-folder}/{slug}
        $isMusic  = ($video->type === 'music');
        // Music uses per-track folders. Primary audio + its slides + lyrics live in
        // tracks/{lang-id}/. Sports + generic stay flat: video.{ext} at the root.
        $primaryDir = $isMusic
            ? $videoDir . '/tracks/' . $this->trackFolderName($video, null)
            : $videoDir;

        $this->mkdirp($primaryDir);
        $updates = [];

        // ── Video / primary audio file ───────────────────────────────────
        $ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
        if (file_exists($tempVideoPath)) {
            // New canonical name. Music: audio.{ext}. Sports/generic: video.{ext}.
            $canonical = ($isMusic ? 'audio' : 'video') . ".{$ext}";
            $nasFile   = "{$primaryDir}/{$canonical}";
            $ok = $this->putFile($tempVideoPath, $nasFile);
            if (! $ok) {
                throw new \RuntimeException("NAS upload failed for video #{$video->id}: could not push {$canonical}");
            }
            @unlink($tempVideoPath);
            $updates['path']     = $nasFile;
            $updates['filename'] = $canonical;
        }

        // ── Slides — music primary track only ────────────────────────────
        $firstSlideNasPath = null;
        if ($isMusic && ! empty($slideAbsPaths)) {
            $this->mkdirp("{$primaryDir}/slides");
            foreach ($video->slides->sortBy('position') as $slide) {
                $absPath = $slideAbsPaths[$slide->position] ?? null;
                if (! $absPath || ! file_exists($absPath)) continue;
                $slideExt     = pathinfo($absPath, PATHINFO_EXTENSION) ?: 'jpg';
                $nasSlideFile = "{$primaryDir}/slides/{$slide->position}.{$slideExt}";
                if ($this->putFile($absPath, $nasSlideFile)) {
                    @unlink($absPath);
                    $slide->update(['filename' => $nasSlideFile]);
                    if ($firstSlideNasPath === null) {
                        $firstSlideNasPath = $nasSlideFile;
                    }
                }
            }
            if ($firstSlideNasPath !== null) {
                $updates['thumbnail'] = $firstSlideNasPath;
            }
        }

        // ── Standalone thumbnail (sports/generic + music-without-slides) ──
        if ($tempThumbPath && file_exists($tempThumbPath) && $firstSlideNasPath === null) {
            // Music thumb lives next to the primary audio inside the track folder.
            $thumbDir = $isMusic ? $primaryDir : $videoDir;
            $thumbExt = pathinfo($tempThumbPath, PATHINFO_EXTENSION) ?: 'webp';
            $nasThumb = "{$thumbDir}/thumb.{$thumbExt}";
            if ($this->putFile($tempThumbPath, $nasThumb)) {
                @unlink($tempThumbPath);
                $updates['thumbnail'] = $nasThumb;
            }
        }

        // ── meta.json (song / video root level, not per-track) ───────────
        $this->putContent(json_encode([
            'id'         => $video->id,
            'user_id'    => $video->user_id,
            'title'      => $video->title,
            'type'       => $video->type,
            'created_at' => $video->created_at?->toIso8601String(),
        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$videoDir}/meta.json");

        // File is now on NAS and accessible — mark as ready
        $updates['status'] = 'ready';

        Log::info('NAS: direct upload complete', ['video_id' => $video->id, 'dir' => $videoDir]);

        $video->update($updates);
    }

    // ── High-level sync methods ───────────────────────────────────────────────

    public function syncVideo(Video $video): void
    {
        $video->loadMissing('user');
        $dir       = $this->resolveVideoDir($video);
        $fileSlug  = $this->titleSlug($video->title);
        $this->mkdirp($dir);

        // {title-slug}.{ext}
        $localVideo = storage_path('app/' . $video->path);
        if (file_exists($localVideo)) {
            $ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
            $this->putFile($localVideo, "{$dir}/{$fileSlug}.{$ext}");
        }

        // thumb.webp
        $localThumb = $video->localThumbnailPath();
        if ($localThumb && file_exists($localThumb)) {
            $this->putFile($localThumb, "{$dir}/thumb.webp");
        }

        // slides/{position}.{ext}
        $video->loadMissing('slides');
        if ($video->slides->isNotEmpty()) {
            $this->mkdirp("{$dir}/slides");
            foreach ($video->slides as $slide) {
                $localSlide = $slide->localPath();
                if (file_exists($localSlide)) {
                    $ext = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
                    $this->putFile($localSlide, "{$dir}/slides/{$slide->position}.{$ext}");
                }
            }
        }

        // lyrics/*.json (source-of-truth; push any local mirror that exists)
        $video->loadMissing('audioTracks');
        $lyricsTargets = array_merge([null], $video->audioTracks->all());
        foreach ($lyricsTargets as $lt) {
            $localLyrics = $this->lyricsLocalPath($video, $lt);
            if (is_file($localLyrics)) {
                $this->mkdirp("{$dir}/lyrics");
                $this->putFile($localLyrics, $this->lyricsNasPath($video, $lt));
            }
        }

        // meta.json  (always written last so readMeta can find the folder)
        $this->putContent(json_encode([
            'id'          => $video->id,
            'user_id'     => $video->user_id,
            'title'       => $video->title,
            'description' => $video->description,
            'type'        => $video->type,
            'visibility'  => $video->visibility,
            'status'      => $video->status,
            'duration'    => $video->duration,
            'width'       => $video->width,
            'height'      => $video->height,
            'orientation' => $video->orientation,
            'mime_type'   => $video->mime_type,
            'size'        => $video->size,
            'created_at'  => $video->created_at?->toIso8601String(),
        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json");
    }

    public function syncVideoMeta(Video $video): void
    {
        $video->loadMissing('user');
        $dir = $this->resolveVideoDir($video);
        $this->mkdirp($dir);

        $this->putContent(json_encode([
            'id'          => $video->id,
            'user_id'     => $video->user_id,
            'title'       => $video->title,
            'description' => $video->description,
            'type'        => $video->type,
            'visibility'  => $video->visibility,
            'status'      => $video->status,
            'duration'    => $video->duration,
            'width'       => $video->width,
            'height'      => $video->height,
            'orientation' => $video->orientation,
            'mime_type'   => $video->mime_type,
            'size'        => $video->size,
            'updated_at'  => $video->updated_at?->toIso8601String(),
        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "{$dir}/meta.json");
    }

    public function deleteVideo(Video $video): void
    {
        $video->loadMissing(['user', 'slides']);
        $dir = $this->resolveVideoDir($video);
        $ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';

        // ── Files in the video root ───────────────────────────────────────────
        $this->deleteFile("{$dir}/" . $this->titleSlug($video->title) . ".{$ext}");
        $this->deleteFile("{$dir}/thumb.webp");
        $this->deleteFile("{$dir}/meta.json");
        $this->deleteFile("{$dir}/view-log.json");
        $this->deleteFile("{$dir}/edit-log.json");

        // ── slides/ subdirectory ──────────────────────────────────────────────
        // Delete each known slide file, then any leftover wildcard, then the dir.
        foreach ($video->slides as $slide) {
            $slideExt = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
            $this->deleteFile("{$dir}/slides/{$slide->position}.{$slideExt}");
        }
        $this->deleteFilesInDir("{$dir}/slides"); // catch anything not in DB
        $this->deleteFolder("{$dir}/slides");

        // ── Video directory itself ────────────────────────────────────────────
        $this->deleteFolder($dir);

        // ── Local NAS stream-cache copy ───────────────────────────────────────
        $cachePath = $this->localCachePath($video);
        if (file_exists($cachePath)) @unlink($cachePath);

        Log::info('NAS: video deleted', ['video_id' => $video->id, 'dir' => $dir]);
    }

    // ── Posts ─────────────────────────────────────────────────────────────────

    /**
     * NAS directory for a post's attachments:  users/{slug}/posts/{id}/
     */
    public function resolvePostDir(\App\Models\Post $post): string
    {
        $post->loadMissing('user');
        return 'users/' . $this->userSlug($post->user) . '/posts/' . $post->id;
    }

    /**
     * Upload all new-format post images to NAS (NAS path == relative path stored in DB).
     */
    public function syncPostImages(\App\Models\Post $post): void
    {
        $post->loadMissing('postImages');
        $dir = $this->resolvePostDir($post);
        $this->mkdirp($dir);

        if ($post->image && str_starts_with($post->image, 'users/')) {
            $local = storage_path('app/' . $post->image);
            if (file_exists($local)) $this->putFile($local, $post->image);
        }

        foreach ($post->postImages as $img) {
            if (! str_starts_with($img->filename, 'users/')) continue;
            $local = storage_path('app/' . $img->filename);
            if (file_exists($local)) $this->putFile($local, $img->filename);
        }
    }

    /**
     * Delete local post image files and prune the empty local post directory.
     * Handles both the old flat format and the new users/{slug}/posts/{id}/ format.
     */
    public function deleteLocalPostImages(\App\Models\Post $post): void
    {
        $post->loadMissing('postImages');

        $paths = [];
        if ($post->image) {
            $paths[] = str_starts_with($post->image, 'users/')
                ? storage_path('app/' . $post->image)
                : storage_path('app/public/post_images/' . $post->image);
        }
        foreach ($post->postImages as $img) {
            $paths[] = str_starts_with($img->filename, 'users/')
                ? storage_path('app/' . $img->filename)
                : storage_path('app/public/post_images/' . $img->filename);
        }

        foreach ($paths as $path) {
            if (file_exists($path)) @unlink($path);
        }

        // Prune empty local post dir
        $localDir = storage_path('app/' . $this->resolvePostDir($post));
        if (is_dir($localDir) && empty(array_diff(scandir($localDir) ?: [], ['.', '..']))) {
            @rmdir($localDir);
        }
    }

    /**
     * Delete the post's entire NAS directory tree.
     */
    public function deleteNasPost(\App\Models\Post $post): void
    {
        $dir = $this->resolvePostDir($post);
        $this->deleteNasTree($dir);
        Log::info('NAS: post deleted', ['post_id' => $post->id, 'dir' => $dir]);
    }

    public function syncAvatar(User $user, string $localAbsPath): void
    {
        $dir = "users/{$this->userSlug($user)}/profile";
        $this->mkdirp($dir);
        $this->putFile($localAbsPath, "{$dir}/avatar.webp");
    }

    public function syncCover(User $user, string $localAbsPath): void
    {
        $dir = "users/{$this->userSlug($user)}/profile";
        $this->mkdirp($dir);
        $this->putFile($localAbsPath, "{$dir}/cover.webp");
    }

    /**
     * Absolute path of the local profile directory for a user.
     * Mirrors the NAS path: storage/app/users/{slug}/profile/
     */
    public function localProfileDir(User $user): string
    {
        return storage_path('app/users/' . $this->userSlug($user) . '/profile');
    }

    public function deleteLocalAvatar(User $user): void
    {
        if (! $user->avatar) return;

        if (str_starts_with($user->avatar, 'users/')) {
            // New format: relative path stored in DB
            $path = storage_path('app/' . $user->avatar);
        } else {
            // Legacy flat format
            $path = storage_path('app/public/avatars/' . $user->avatar);
        }

        if (file_exists($path)) @unlink($path);
    }

    public function deleteLocalBanner(User $user): void
    {
        if (! $user->banner) return;

        if (str_starts_with($user->banner, 'users/')) {
            $path = storage_path('app/' . $user->banner);
        } else {
            $path = storage_path('app/public/banners/' . $user->banner);
        }

        if (file_exists($path)) @unlink($path);
    }

    /**
     * Check whether a file exists on the NAS share.
     * Uses a lightweight smbclient ls — does not download the file.
     */
    public function nasFileExists(string $nasRelPath): bool
    {
        $cfg    = $this->cfg();
        $target = escapeshellarg($this->smbTarget($cfg));
        $cred   = escapeshellarg($this->smbCredential($cfg));
        $cmd    = 'ls "' . $nasRelPath . '"';

        exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code);

        // smbclient exits 0 and output contains the filename when found;
        // exits non-zero or contains NT_STATUS_NO_SUCH_FILE when missing.
        if ($code !== 0) return false;
        foreach ($output as $line) {
            if (str_contains($line, 'NT_STATUS_NO_SUCH_FILE') ||
                str_contains($line, 'NT_STATUS_OBJECT_NAME_NOT_FOUND')) {
                return false;
            }
        }
        return true;
    }

    // ── SMB primitives ────────────────────────────────────────────────────────

    public function mkdirp(string $path): void
    {
        if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
        $cfg    = $this->cfg();
        $target = escapeshellarg($this->smbTarget($cfg));
        $cred   = escapeshellarg($this->smbCredential($cfg));

        $parts = array_values(array_filter(explode('/', str_replace('\\', '/', $path))));
        $cmds  = [];
        $built = '';
        foreach ($parts as $part) {
            $built  = $built ? "{$built}/{$part}" : $part;
            $cmds[] = 'mkdir "' . $built . '"';
        }

        exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg(implode('; ', $cmds)) . ' 2>&1');
        // Intentionally ignore exit code — NT_STATUS_OBJECT_NAME_COLLISION means dir exists
    }

    public function putFile(string $localAbsPath, string $nasRelPath): bool
    {
        if (! $this->isEnabled()) return false; // fail-fast when NAS unreachable
        $cfg    = $this->cfg();
        $target = escapeshellarg($this->smbTarget($cfg));
        $cred   = escapeshellarg($this->smbCredential($cfg));
        $cmd    = 'put "' . $localAbsPath . '" "' . $nasRelPath . '"';

        exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code);

        if ($code !== 0) {
            Log::warning('NAS putFile failed [' . $nasRelPath . ']: ' . implode(' ', $output));
        }

        return $code === 0;
    }

    public function putContent(string $content, string $nasRelPath): bool
    {
        $tmp = tempnam(sys_get_temp_dir(), 'nassync_');
        file_put_contents($tmp, $content);
        $ok = $this->putFile($tmp, $nasRelPath);
        @unlink($tmp);
        return $ok;
    }

    public function getContent(string $nasRelPath): ?string
    {
        if (! $this->isEnabled()) return null; // fail-fast when NAS unreachable
        $cfg    = $this->cfg();
        $target = escapeshellarg($this->smbTarget($cfg));
        $cred   = escapeshellarg($this->smbCredential($cfg));
        $tmp    = tempnam(sys_get_temp_dir(), 'nasget_');
        $cmd    = 'get "' . $nasRelPath . '" "' . $tmp . '"';

        exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code);

        if ($code !== 0 || ! file_exists($tmp)) {
            @unlink($tmp);
            return null;
        }

        $content = file_get_contents($tmp);
        @unlink($tmp);
        return $content !== false ? $content : null;
    }

    // ── Lyrics (per-track, source-of-truth JSON synced to NAS) ─────────────────

    public function lyricsDir(Video $video): string
    {
        // Legacy shared lyrics/ folder location — kept for read-fallback only.
        // Derived from the stored path (no NAS lookup) — see callers for context.
        if (str_starts_with((string) $video->path, 'users/')) {
            $dir = dirname($video->path);
            if (basename($dir) === 'tracks') $dir = dirname($dir);
            return $dir . '/lyrics';
        }
        return $this->resolveVideoDir($video) . '/lyrics';
    }

    /**
     * NAS-relative path for a track's lyrics under the new per-track-folder layout:
     *   tracks/{lang-id}/lyrics.json
     * Falls back to the legacy shared lyrics/ folder if the song still uses the
     * old flat layout (we detect this by checking whether the path passes through
     * a tracks/ segment).
     */
    public function lyricsNasPath(Video $video, ?VideoAudioTrack $track = null): string
    {
        // New layout (music with per-track folders).
        if ($video->type === 'music' && str_starts_with((string) $video->path, 'users/')) {
            $segs = explode('/', $video->path);
            // users/{slug}/music/{song-slug}/tracks/{lang-id}/audio.{ext}
            if (count($segs) >= 6 && $segs[4] === 'tracks') {
                $songRoot = implode('/', array_slice($segs, 0, 4));
                $folder   = $this->trackFolderName($video, $track);
                return "{$songRoot}/tracks/{$folder}/lyrics.json";
            }
        }
        // Legacy fallback — shared lyrics/ folder, keyed by track suffix.
        $name = $track ? "track-{$track->id}" : 'primary';
        return $this->lyricsDir($video) . "/{$name}.json";
    }

    /** Canonical local mirror path for a track's lyrics. */
    public function lyricsLocalPath(Video $video, ?VideoAudioTrack $track = null): string
    {
        return storage_path('app/' . $this->lyricsNasPath($video, $track));
    }

    /**
     * Write lyrics JSON for a track. Always writes the local mirror; pushes to
     * NAS when reachable. Returns false only if the NAS push was attempted and
     * failed (the local copy is written regardless, so nas:auto-sync can retry).
     */
    public function putLyrics(Video $video, ?VideoAudioTrack $track, array $data): bool
    {
        $json  = json_encode($data, JSON_UNESCAPED_UNICODE);
        $local = $this->lyricsLocalPath($video, $track);
        if (! is_dir(dirname($local))) @mkdir(dirname($local), 0775, true);
        file_put_contents($local, $json);

        if ($this->isEnabled()) {
            // Make sure the directory containing this lyrics file exists on NAS.
            // New layout: tracks/{lang-id}/. Legacy: shared lyrics/ folder.
            $this->mkdirp(dirname($this->lyricsNasPath($video, $track)));
            return $this->putContent($json, $this->lyricsNasPath($video, $track));
        }
        return true;
    }

    /**
     * Read lyrics JSON from the LOCAL mirror only — never touches the NAS.
     * Use this on hot paths (page render, player-data) so a missing file can't
     * block the request on smbclient or the port-445 reachability probe. The
     * lyrics mirror is written by putLyrics() and is never wiped by cache
     * cleanup, so a song that has lyrics will have them locally.
     */
    public function getLocalLyrics(Video $video, ?VideoAudioTrack $track = null): ?array
    {
        $local = $this->lyricsLocalPath($video, $track);
        if (! is_file($local)) {
            // Read fallback to the legacy shared lyrics/ folder so songs that
            // haven't been migrated yet still serve their lyrics.
            $legacy = storage_path('app/' . $this->lyricsDir($video) . '/' . ($track ? "track-{$track->id}" : 'primary') . '.json');
            if (is_file($legacy)) $local = $legacy;
        }
        if (is_file($local)) {
            $d = json_decode((string) file_get_contents($local), true);
            if (is_array($d)) return $d;
        }
        return null;
    }

    /**
     * Remove a track's lyrics from both the local mirror and the NAS. Used by
     * the owner when the generated lyrics are wrong and they want to start
     * over — after calling this, the next Generate produces a fresh file.
     */
    public function deleteLyrics(Video $video, ?VideoAudioTrack $track = null): void
    {
        $local = $this->lyricsLocalPath($video, $track);
        if (is_file($local)) @unlink($local);

        if ($this->isEnabled()) {
            try { $this->deleteFile($this->lyricsNasPath($video, $track)); }
            catch (\Throwable $e) { /* best-effort: local removal is what matters for next regenerate */ }
        }
    }

    /** Read lyrics JSON for a track, pulling from NAS into the local mirror if needed. */
    public function getLyrics(Video $video, ?VideoAudioTrack $track = null): ?array
    {
        $local = $this->lyricsLocalPath($video, $track);
        if (is_file($local)) {
            $d = json_decode((string) file_get_contents($local), true);
            if (is_array($d)) return $d;
        }
        if ($this->isEnabled()) {
            $c = $this->getContent($this->lyricsNasPath($video, $track));
            if ($c !== null) {
                if (! is_dir(dirname($local))) @mkdir(dirname($local), 0775, true);
                file_put_contents($local, $c);
                $d = json_decode($c, true);
                if (is_array($d)) return $d;
            }
        }
        return null;
    }

    public function deleteFile(string $nasRelPath): void
    {
        if (! $this->isEnabled()) return; // fail-fast when NAS unreachable
        $cfg    = $this->cfg();
        $target = escapeshellarg($this->smbTarget($cfg));
        $cred   = escapeshellarg($this->smbCredential($cfg));
        exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rm "' . $nasRelPath . '"') . ' 2>&1');
    }

    /**
     * Delete all files inside a NAS directory using a wildcard.
     * Does NOT remove the directory itself — call deleteFolder() after.
     */
    public function deleteFilesInDir(string $nasRelPath): void
    {
        $cfg    = $this->cfg();
        $target = escapeshellarg($this->smbTarget($cfg));
        $cred   = escapeshellarg($this->smbCredential($cfg));
        // smbclient `del` accepts a mask relative to the share root
        exec('smbclient ' . $target . ' -U ' . $cred
            . ' -c ' . escapeshellarg('del "' . $nasRelPath . '/*"')
            . ' 2>&1');
    }

    public function deleteFolder(string $nasRelPath): void
    {
        $cfg    = $this->cfg();
        $target = escapeshellarg($this->smbTarget($cfg));
        $cred   = escapeshellarg($this->smbCredential($cfg));
        exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rmdir "' . $nasRelPath . '"') . ' 2>&1');
    }

    /**
     * Rename the video's NAS and local directories to match the current title,
     * then update all affected DB paths (video, slides).
     *
     * Call this after video.title has been saved but before syncVideo(), so that
     * the sync job writes to the correctly-named folder.
     */
    public function renameVideoDir(Video $video): void
    {
        if (! str_starts_with($video->path, 'users/')) return;

        $video->loadMissing(['user', 'slides', 'audioTracks']);
        $userSlug = $this->userSlug($video->user);
        $base     = "users/{$userSlug}/videos";

        // Current folder derived from the stored path (title not yet reflected in folder name)
        $currentDir = dirname($video->path); // "users/{slug}/videos/{old-slug}"
        // If the primary file lives inside a 'tracks/' subfolder (promoted track),
        // go up one extra level to reach the video root directory.
        if (basename($currentDir) === 'tracks') {
            $currentDir = dirname($currentDir);
        }

        // Desired folder based on the (already-saved) new title
        $newDir = $this->findFreeVideoDir($base, $this->titleSlug($video->title), $video->id);

        if ($currentDir === $newDir) return;

        // Rename on NAS
        $this->renameNasPath($currentDir, $newDir);

        // Rename locally if the dir exists
        $oldLocal = storage_path('app/' . $currentDir);
        $newLocal = storage_path('app/' . $newDir);
        if (is_dir($oldLocal)) {
            rename($oldLocal, $newLocal);
        }

        // Update all DB paths that reference the old directory
        $oldPrefix = $currentDir . '/';
        $newPrefix = $newDir . '/';

        $videoUpdates = [];
        if (str_starts_with($video->path, $oldPrefix)) {
            $videoUpdates['path'] = $newPrefix . substr($video->path, strlen($oldPrefix));
        }
        if ($video->thumbnail && str_starts_with($video->thumbnail, $oldPrefix)) {
            $videoUpdates['thumbnail'] = $newPrefix . substr($video->thumbnail, strlen($oldPrefix));
        }
        if (! empty($videoUpdates)) {
            $video->update($videoUpdates);
            $video->refresh();
        }

        foreach ($video->slides as $slide) {
            if (str_starts_with($slide->filename, $oldPrefix)) {
                $slide->update(['filename' => $newPrefix . substr($slide->filename, strlen($oldPrefix))]);
            }
        }

        // Secondary language tracks (incl. the demoted old primary after a swap) also
        // live under the renamed directory — update their stored paths too.
        foreach ($video->audioTracks as $track) {
            if ($track->path && str_starts_with($track->path, $oldPrefix)) {
                $track->update(['path' => $newPrefix . substr($track->path, strlen($oldPrefix))]);
            }
        }

        Log::info('NAS: video dir renamed', [
            'video_id' => $video->id,
            'from'     => $currentDir,
            'to'       => $newDir,
        ]);
    }

    /**
     * Find a free (or already-owned) NAS video directory for the given slug.
     */
    private function findFreeVideoDir(string $base, string $slug, int $videoId): string
    {
        for ($i = 1; $i <= 50; $i++) {
            $candidate = $i === 1 ? $slug : "{$slug}-{$i}";
            $meta      = $this->readMeta("{$base}/{$candidate}");
            if ($meta === null) return "{$base}/{$candidate}";
            if (($meta['id'] ?? null) === $videoId) return "{$base}/{$candidate}";
        }
        return "{$base}/{$slug}-{$videoId}";
    }

    /**
     * Rename a path on the NAS share (works for both files and directories).
     */
    private function renameNasPath(string $oldNasRelPath, string $newNasRelPath): void
    {
        $cfg    = $this->cfg();
        $target = escapeshellarg($this->smbTarget($cfg));
        $cred   = escapeshellarg($this->smbCredential($cfg));
        $old    = str_replace('/', '\\', $oldNasRelPath);
        $new    = str_replace('/', '\\', $newNasRelPath);
        exec(
            'smbclient ' . $target . ' -U ' . $cred
            . ' -c ' . escapeshellarg("rename \"{$old}\" \"{$new}\"")
            . ' 2>&1',
            $output, $code
        );
        if ($code !== 0) {
            Log::warning('NAS rename failed', [
                'old'    => $oldNasRelPath,
                'new'    => $newNasRelPath,
                'output' => implode(' ', $output),
            ]);
        }
    }

    /**
     * List subdirectory names directly under a NAS path.
     */
    public function listNasDirs(string $nasRelPath): array
    {
        $cfg     = $this->cfg();
        $target  = escapeshellarg($this->smbTarget($cfg));
        $cred    = escapeshellarg($this->smbCredential($cfg));
        $smbPath = str_replace('/', '\\', $nasRelPath) . '\\*';

        exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg("ls \"{$smbPath}\"") . ' 2>&1', $output);

        $dirs = [];
        foreach ($output as $line) {
            if (! preg_match('/^  (.+?)\s{2,}D\s/', $line, $m)) continue;
            $name = trim($m[1]);
            if ($name === '.' || $name === '..') continue;
            $dirs[] = $name;
        }
        return $dirs;
    }

    /**
     * Recursively delete a NAS directory tree (all files, subdirs, then the dir itself).
     */
    public function deleteNasTree(string $nasRelPath): void
    {
        foreach ($this->listNasDirs($nasRelPath) as $subdir) {
            $this->deleteNasTree("{$nasRelPath}/{$subdir}");
        }
        $this->deleteFilesInDir($nasRelPath);
        $this->deleteFolder($nasRelPath);
    }

    /**
     * Scan every video folder on the NAS and return those whose meta.json
     * references a video ID that no longer exists in the database.
     *
     * Returns array of ['dir' => 'users/{slug}/videos/{slug}', 'video_id' => int|null]
     */
    public function scanNasOrphans(): array
    {
        $orphans  = [];
        $userDirs = $this->listNasDirs('users');

        foreach ($userDirs as $userSlug) {
            $videosBase = "users/{$userSlug}/videos";
            $videoDirs  = $this->listNasDirs($videosBase);

            foreach ($videoDirs as $videoSlug) {
                $dir     = "{$videosBase}/{$videoSlug}";
                $meta    = $this->readMeta($dir);
                $videoId = $meta ? ($meta['id'] ?? null) : null;

                if ($videoId === null) {
                    $orphans[] = ['dir' => $dir, 'video_id' => null];
                    continue;
                }

                if (! \App\Models\Video::where('id', $videoId)->exists()) {
                    $orphans[] = ['dir' => $dir, 'video_id' => $videoId];
                }
            }
        }

        return $orphans;
    }

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

    private function cfg(): array
    {
        return app(\P7H\NasFileManager\NasStorageService::class)->cfg();
    }

    private function smbTarget(array $cfg): string
    {
        return '//' . $cfg['host'] . '/' . trim($cfg['smb_share'] ?? '', '/');
    }

    private function smbCredential(array $cfg): string
    {
        $domain = ! empty($cfg['smb_domain']) ? $cfg['smb_domain'] . '/' : '';
        return $domain . ($cfg['username'] ?? '') . '%' . ($cfg['password'] ?? '');
    }
}
