<?php

namespace App\Console\Commands;

use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

/**
 * Major file-structure cleanup for audio "songs":
 *
 *  1. CONSOLIDATE every song's files into the song's own folder. Each file is
 *     gathered from wherever its local copy currently is (the canonical path OR
 *     the nas_cache/) and moved to:
 *        - primary  : kept at its canonical name; promoted/legacy primaries are
 *                     relocated to {song-folder}/{title-slug}.{ext}
 *        - secondary: {song-folder}/{folder-slug}-{lang}-{track-id}.{ext}
 *     (lowercase, unique — the db id means nothing can ever overwrite anything,
 *     and there is no more tracks/ subfolder).
 *  2. UPDATE the DB records (path + filename) to match.
 *  3. DELETE orphan files (referenced by NO database column) and empty folders
 *     across the media roots.
 *
 * Moves are two-phase (src -> temp -> final) so the historical primary<->secondary
 * "swaps" cannot clobber each other. Serving works straight afterwards because
 * NasSyncService::ensureLocalCopy() prefers the local canonical path.
 *
 * Defaults to a DRY RUN. Pass --force to apply.
 */
class ReorganizeAudioTracks extends Command
{
    protected $signature = 'tracks:reorganize {--force : Apply changes (default is a dry run)}';
    protected $description = 'Consolidate audio tracks into one folder per song with unique names, update records, delete orphans + empty folders';

    private const AUDIO_EXT = ['mp3', 'm4a', 'aac', 'wav', 'flac', 'ogg', 'opus', 'wma'];

    /** Media roots (relative to storage/app) that hold ONLY user media — safe to clean. */
    private const SCAN_ROOTS = ['users', 'nas_cache/videos', 'public/videos', 'public/thumbnails', 'public/avatars'];

    private bool $dry = true;
    private string $appRoot;

    public function handle(): int
    {
        $this->dry     = ! $this->option('force');
        $this->appRoot = storage_path('app');

        $this->info($this->dry ? '=== DRY RUN (no changes) — pass --force to apply ===' : '=== APPLYING CHANGES ===');
        $this->newLine();

        $plan = $this->buildPlan();
        $this->printPlan($plan);

        if (! $this->dry) {
            $this->applyPlan($plan);
        }

        $finalPaths = $this->finalReferencedPaths($plan);
        $this->handleOrphans($plan, $finalPaths);
        $this->handleEmptyDirs();

        $this->newLine();
        $this->info($this->dry ? 'Dry run complete. Re-run with --force to apply.' : 'Done.');
        return self::SUCCESS;
    }

    // ── helpers ────────────────────────────────────────────────────────────────
    private function isAudio(Video $v): bool
    {
        if ($v->mime_type && str_starts_with($v->mime_type, 'audio/')) return true;
        return in_array(strtolower(pathinfo($v->filename ?? '', PATHINFO_EXTENSION)), self::AUDIO_EXT, true);
    }

    private function titleSlug(string $title): string
    {
        $title = \Normalizer::normalize($title, \Normalizer::FORM_C) ?: $title;
        $slug  = preg_replace('/[^\p{L}\p{N}]+/u', '-', $title);
        $slug  = trim(mb_strtolower($slug), '-');
        if (mb_strlen($slug) > 100) $slug = rtrim(mb_substr($slug, 0, 100), '-');
        return $slug ?: 'video';
    }

    private function songDir(Video $v): string
    {
        $path = (string) $v->path;
        if (str_starts_with($path, 'users/')) {
            $dir = dirname($path);
            if (basename($dir) === 'tracks') $dir = dirname($dir);   // promoted-primary case
            return $dir;
        }
        $userSlug = $v->user?->username ?: (string) $v->user_id;
        return 'users/' . $userSlug . '/videos/' . $this->titleSlug($v->title);
    }

    private function trackName(string $base, ?string $lang, int $id, string $ext): string
    {
        $lang = mb_strtolower(trim((string) $lang)) ?: 'xx';
        return mb_strtolower($base . '-' . $lang . '-' . $id . '.' . strtolower($ext));
    }

    /** Locate the actual local copy of a file given its DB path + filename. */
    private function locate(string $dbPath, string $filename): ?string
    {
        foreach ([$dbPath, 'nas_cache/videos/' . $filename] as $cand) {
            if ($cand && is_file($this->appRoot . '/' . $cand)) return $cand;
        }
        return null;
    }

    // ── plan ─────────────────────────────────────────────────────────────────
    private function buildPlan(): array
    {
        $plan = [];

        foreach (Video::with(['user', 'audioTracks'])->get() as $video) {
            if (! $this->isAudio($video)) continue;

            $dir  = $this->songDir($video);
            $base = basename($dir);

            // ── primary ──
            $pExt   = strtolower(pathinfo($video->filename ?: $video->path, PATHINFO_EXTENSION)) ?: 'mp3';
            $inDir  = str_starts_with((string) $video->path, 'users/') && basename(dirname((string) $video->path)) !== 'tracks';
            $pDst   = $inDir
                ? (string) $video->path                              // already in song folder — keep its name
                : $dir . '/' . $this->titleSlug($video->title) . '.' . $pExt;   // promoted/legacy — canonical name
            $plan[] = $this->item('primary', $video, (string) $video->path, $video->filename, $pDst);

            // ── secondaries ──
            foreach ($video->audioTracks as $t) {
                $tExt = strtolower(pathinfo($t->filename ?: $t->path, PATHINFO_EXTENSION)) ?: 'mp3';
                $tDst = $dir . '/' . $this->trackName($base, $t->language, $t->id, $tExt);
                $plan[] = $this->item('track', $t, (string) $t->path, $t->filename, $tDst);
            }
        }
        return $plan;
    }

    private function item(string $type, $model, string $dbPath, ?string $filename, string $dst): array
    {
        $src = $this->locate($dbPath, (string) $filename);
        return [
            'type'      => $type,
            'id'        => $model->id,
            'model'     => $model,
            'db_path'   => $dbPath,
            'src'       => $src,                       // actual local file (or null if only on NAS)
            'dst'       => $dst,
            'needsMove' => $src !== null && $src !== $dst,
            'atDest'    => $src !== null && $src === $dst,
            'dbStale'   => $dbPath !== $dst,           // DB needs updating even if file already at dest
        ];
    }

    private function printPlan(array $plan): void
    {
        $moves = array_filter($plan, fn ($p) => $p['needsMove']);
        $miss  = array_filter($plan, fn ($p) => $p['src'] === null);

        $this->line('<comment>Consolidation / rename plan:</comment>');
        foreach ($plan as $p) {
            if ($p['src'] === null) {
                $this->line(sprintf('  <fg=red>[%s #%d] NO LOCAL COPY</> (db: %s) — skipped', $p['type'], $p['id'], $p['db_path']));
                continue;
            }
            if ($p['needsMove']) {
                $this->line(sprintf('  [%s #%d] %s', $p['type'], $p['id'], $p['src']));
                $this->line(sprintf('        -> %s', $p['dst']));
            }
        }
        $this->newLine();
        $this->line('  moves: ' . count($moves) . ' | already correct: ' . (count($plan) - count($moves) - count($miss)) . ' | no local copy: ' . count($miss));
        $this->newLine();
    }

    private function applyPlan(array $plan): void
    {
        // Phase 1: move every file that needs moving to a unique temp name (collision-safe).
        $temps = [];
        foreach ($plan as $i => $p) {
            if (! $p['needsMove']) continue;
            $srcAbs = $this->appRoot . '/' . $p['src'];
            $tmpRel = dirname($p['dst']) . '/.reorg_' . $p['type'] . '_' . $p['id'] . '.tmp';
            $tmpAbs = $this->appRoot . '/' . $tmpRel;
            @mkdir(dirname($tmpAbs), 0755, true);
            if (@rename($srcAbs, $tmpAbs)) {
                $temps[$i] = $tmpRel;
            } else {
                $this->error("  FAILED phase1 move: {$p['src']}");
            }
        }

        // Phase 2: temp -> final, then update DB.
        foreach ($plan as $i => $p) {
            if (isset($temps[$i])) {
                $tmpAbs = $this->appRoot . '/' . $temps[$i];
                $dstAbs = $this->appRoot . '/' . $p['dst'];
                if (! @rename($tmpAbs, $dstAbs)) {
                    $this->error("  FAILED phase2 move -> {$p['dst']}");
                    continue;
                }
                $this->line("  moved #{$p['id']} -> {$p['dst']}");
            }
            // Update DB record whenever the stored path/name is out of date.
            if ($p['src'] !== null && $p['dbStale']) {
                $p['model']->update(['path' => $p['dst'], 'filename' => basename($p['dst'])]);
            }
        }
    }

    // ── orphans + empties ──────────────────────────────────────────────────────
    /** All file paths (relative to storage/app) referenced by the DB after migration. */
    private function finalReferencedPaths(array $plan): array
    {
        $paths   = [];
        $planned = [];   // db_path keyed -> handled by plan
        foreach ($plan as $p) {
            if ($p['src'] !== null) { $paths[$p['dst']] = true; $planned[$p['db_path']] = true; }
            else                    { $paths[$p['db_path']] = true; }   // keep NAS-only refs
        }

        foreach (Video::all() as $v) {
            if ($v->path && ! isset($planned[$v->path]) && ! $this->plannedPrimary($plan, $v->id)) $paths[$v->path] = true;
            if ($v->thumbnail) $paths[$v->thumbnail] = true;
            if ($v->slideshow_video_path) $paths[$v->slideshow_video_path] = true;   // generated download video
        }
        foreach (VideoAudioTrack::all() as $t) {
            if ($t->path && ! isset($planned[$t->path]) && ! $this->plannedTrack($plan, $t->id)) $paths[$t->path] = true;
        }
        foreach (DB::table('video_slides')->pluck('filename') as $f) if ($f) $paths[$f] = true;
        foreach (User::all() as $u) { if ($u->avatar) $paths[$u->avatar] = true; if ($u->banner) $paths[$u->banner] = true; }
        foreach (Playlist::all() as $pl) if ($pl->thumbnail) $paths[$pl->thumbnail] = true;
        foreach (DB::table('post_images')->pluck('filename') as $f) if ($f) $paths[$f] = true;
        foreach (DB::table('posts')->pluck('image') as $f) if ($f) $paths[$f] = true;

        return $paths;
    }

    private function plannedPrimary(array $plan, int $id): bool
    {
        foreach ($plan as $p) if ($p['type'] === 'primary' && $p['id'] === $id && $p['src'] !== null) return true;
        return false;
    }
    private function plannedTrack(array $plan, int $id): bool
    {
        foreach ($plan as $p) if ($p['type'] === 'track' && $p['id'] === $id && $p['src'] !== null) return true;
        return false;
    }

    private function handleOrphans(array $plan, array $finalPaths): void
    {
        // Conservative: also protect any file whose basename matches a referenced basename
        // or a planned move-source (sources are being moved, not orphaned).
        $keepBasenames = ['meta.json' => true];
        foreach (array_keys($finalPaths) as $rel) $keepBasenames[basename($rel)] = true;
        foreach ($plan as $p) if ($p['src']) $keepBasenames[basename($p['src'])] = true;

        // Files actively moved this run (their old location is vacated, not an orphan to re-check).
        $movedFrom = [];
        foreach ($plan as $p) if ($p['needsMove']) $movedFrom[$p['src']] = true;

        $orphans = [];
        foreach (self::SCAN_ROOTS as $root) {
            $abs = $this->appRoot . '/' . $root;
            if (! is_dir($abs)) continue;
            $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS));
            foreach ($it as $file) {
                if (! $file->isFile()) continue;
                $rel = substr($file->getPathname(), strlen($this->appRoot) + 1);
                if (isset($finalPaths[$rel]))            continue;
                if (isset($keepBasenames[$file->getFilename()])) continue;
                if (isset($movedFrom[$rel]))             continue;
                // Everything under a song's cache/ is a regenerable render — never an orphan.
                if (str_contains($rel, '/cache/'))       continue;
                $orphans[] = $rel;
            }
        }

        $this->newLine();
        if (! $orphans) { $this->line('No orphan files found.'); return; }
        $this->line('<comment>Orphan files (no DB reference) — ' . count($orphans) . ':</comment>');
        $bytes = 0;
        foreach ($orphans as $rel) {
            $abs = $this->appRoot . '/' . $rel;
            $sz  = is_file($abs) ? filesize($abs) : 0; $bytes += $sz;
            $this->line(sprintf('  %s  (%s KB)', $rel, number_format($sz / 1024, 1)));
            if (! $this->dry) @unlink($abs);
        }
        $this->line('  total: ' . number_format($bytes / 1048576, 1) . ' MB' . ($this->dry ? '' : ' (deleted)'));
    }

    private function handleEmptyDirs(): void
    {
        $this->newLine();
        $removed = 0; $listed = [];
        // Loop because removing leaf dirs can empty their parents.
        do {
            $found = 0;
            foreach (self::SCAN_ROOTS as $root) {
                $abs = $this->appRoot . '/' . $root;
                if (! is_dir($abs)) continue;
                $it = new \RecursiveIteratorIterator(
                    new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS),
                    \RecursiveIteratorIterator::CHILD_FIRST
                );
                foreach ($it as $f) {
                    if (! $f->isDir()) continue;
                    if ((new \FilesystemIterator($f->getPathname()))->valid()) continue;   // not empty
                    $rel = substr($f->getPathname(), strlen($this->appRoot) + 1);
                    if (isset($listed[$rel])) continue;
                    $listed[$rel] = true; $found++;
                    if (! $this->dry) { @rmdir($f->getPathname()); $removed++; }
                }
            }
        } while (! $this->dry && $found > 0);

        if (! $listed) { $this->line('No empty folders found.'); return; }
        $this->line('<comment>Empty folders — ' . count($listed) . ':</comment>');
        foreach (array_keys($listed) as $rel) $this->line('  ' . $rel);
        if (! $this->dry) $this->line("  ({$removed} removed)");
    }
}
