<?php

namespace App\Console\Commands;

use App\Models\MediaFile;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;

/**
 * Walks `public/user-uploads/` and inserts a `media_files` row for every file
 * that doesn't already have one, tagged `disk='local'`. Used as a one-shot
 * during install / upgrade to backfill the registry on installs that wrote
 * files BEFORE MediaStorage::put existed (i.e. anything before the
 * 2026-05-09 storage-registry pivot).
 *
 * Heuristic owner_type / purpose mapping by path:
 *   projects/{id}/...   → project / screen-image
 *   themes/...          → theme   / theme-asset
 *   kits/...            → kit     / kit-asset
 *   branding/...        → branding/ logo
 *   exports/{id}/...    → skipped (legacy server-side export pipeline; the
 *                         exports table was dropped 2026-05-26 after the
 *                         client-side ZIP pivot. Any leftover dirs on disk
 *                         can be wiped manually — no registry rows owe them).
 *   anything else       → null    / null  (still tracked so admin can see
 *                                          the file in the Migrate count)
 *
 * Idempotent — `firstOrCreate` keyed on (disk, path). Running it twice
 * inserts zero new rows.
 *
 * NOT scheduled — run once during install / upgrade. Periodic execution
 * would only matter if a new code path started writing files outside
 * MediaStorage::put, in which case the fix is to route that path through
 * MediaStorage instead.
 *
 * Dotfiles, `.gitignore`, `installed.lock`, and anything outside the
 * standard upload folders are skipped (the standard folders are exactly
 * the heuristic prefixes above; everything else is counted as "outside
 * known folders" and skipped — admin gets a single summary line).
 */
class MediaFilesBackfillCommand extends Command
{
    protected $signature = 'media-files:backfill
                            {--dry-run : Print what would be inserted without writing}';

    protected $description = 'Backfill media_files registry rows for files already on local disk.';

    public function handle(): int
    {
        $dry = (bool) $this->option('dry-run');

        // Read the public-disk root via the Storage facade so test fakes
        // route here too — `Storage::fake('public')` swaps the disk root to
        // a tempdir, and we want the finder to walk THAT in tests.
        $base = self::resolvePublicDiskRoot();
        if ($base === null || ! File::isDirectory($base)) {
            $this->warn('Storage path does not exist or is not local-backed: '.($base ?: '(unresolved)'));

            return self::SUCCESS;
        }

        $finder = (new Finder())
            ->files()
            ->in($base)
            ->ignoreDotFiles(true)
            ->ignoreVCS(true);

        $discovered = 0;
        $inserted = 0;
        $alreadyTracked = 0;
        $outsideKnown = 0;

        foreach ($finder as $file) {
            /** @var SplFileInfo $file */
            $relative = self::normalizeRelative($file->getRelativePathname());

            // Defensive — Finder's ignoreDotFiles handles top-level dotfiles
            // but a hidden file deep inside a folder still slips through if
            // the folder itself isn't dot-prefixed. Skip any path segment
            // that starts with a dot, plus the explicit excludes.
            if (self::shouldSkipPath($relative)) {
                continue;
            }

            $discovered++;

            [$ownerType, $ownerId, $purpose, $known] = self::classifyPath($relative);
            if (! $known) {
                $outsideKnown++;

                continue;
            }

            // Already tracked? firstOrCreate without --dry-run, but we want
            // an honest "inserted vs already-tracked" split, so we look up
            // first regardless.
            $existing = MediaFile::query()
                ->where('disk', 'local')
                ->where('path', $relative)
                ->first();

            if ($existing !== null) {
                $alreadyTracked++;

                continue;
            }

            if ($dry) {
                $inserted++;
                $this->line("[dry-run] would insert: {$relative}");

                continue;
            }

            MediaFile::query()->firstOrCreate(
                ['disk' => 'local', 'path' => $relative],
                [
                    'original_name' => basename($relative),
                    'mime_type' => self::guessMime($file->getRealPath() ?: $file->getPathname()),
                    'size_bytes' => $file->getSize() ?: null,
                    'owner_type' => $ownerType,
                    'owner_id' => $ownerId,
                    'purpose' => $purpose,
                ],
            );

            $inserted++;
        }

        $this->line(sprintf('Discovered %d files', $discovered));
        $this->line(sprintf('Inserted %d new rows', $inserted));
        $this->line(sprintf('Skipped %d already-tracked', $alreadyTracked));
        $this->line(sprintf('Skipped %d files outside known folders', $outsideKnown));
        $this->line(sprintf(
            '— summary: %d discovered, %d inserted, %d already-tracked, %d outside known folders',
            $discovered,
            $inserted,
            $alreadyTracked,
            $outsideKnown,
        ));

        return self::SUCCESS;
    }

    /**
     * @return array{0: ?string, 1: ?int, 2: ?string, 3: bool}
     *   [owner_type, owner_id, purpose, isKnownFolder]
     */
    private static function classifyPath(string $relative): array
    {
        if (preg_match('#^projects/(\d+)/#', $relative, $m) === 1) {
            return ['project', (int) $m[1], 'screen-image', true];
        }
        if (str_starts_with($relative, 'theme-assets/') || str_starts_with($relative, 'themes/')) {
            return ['theme', null, 'theme-asset', true];
        }
        if (str_starts_with($relative, 'kits/')) {
            return ['kit', null, 'kit-asset', true];
        }
        if (str_starts_with($relative, 'branding/')) {
            return ['branding', null, 'logo', true];
        }
        return [null, null, null, false];
    }

    private static function shouldSkipPath(string $relative): bool
    {
        // Hidden segment anywhere in the path — `.gitignore`, `.DS_Store`,
        // etc. Finder's ignoreDotFiles only handles dotfiles in the root.
        foreach (explode('/', $relative) as $segment) {
            if ($segment !== '' && $segment[0] === '.') {
                return true;
            }
        }

        // Explicit excludes — installer artefacts that live alongside the
        // upload folders but aren't user media.
        $explicit = ['installed.lock', '.gitignore'];
        if (in_array(basename($relative), $explicit, true)) {
            return true;
        }

        // Legacy server-side export tiles. The `exports` table was dropped
        // 2026-05-26 after the export pipeline pivoted client-side; any
        // leftover `exports/{id}/...` files have no registry owner. Skip
        // them entirely so the backfill doesn't track an unowned bucket.
        if (str_starts_with($relative, 'exports/')) {
            return true;
        }

        return false;
    }

    /**
     * Normalize a relative path to forward-slash form. Symfony's Finder
     * returns OS-native separators on Windows; the registry stores
     * forward-slash paths so URLs resolve.
     */
    private static function normalizeRelative(string $path): string
    {
        return str_replace('\\', '/', ltrim($path, '/\\'));
    }

    /**
     * Resolve the on-disk root for the `public` storage disk. Falls back
     * to `public/user-uploads` if Flysystem can't expose a path
     * (e.g. when admin has flipped to s3 — backfill of a cloud disk via
     * folder walk doesn't make sense).
     *
     * Returns null when the active disk is non-local and we therefore
     * have nothing local to walk. Test fakes (`Storage::fake('public')`)
     * route here cleanly because the fake driver IS local-backed.
     */
    private static function resolvePublicDiskRoot(): ?string
    {
        // When the public disk is overlaid to S3 (admin flipped to a cloud
        // provider), Flysystem's S3 adapter still exposes path() but returns
        // an empty string — so we can't trust the disk-derived value. Fall
        // back to the canonical local storage dir directly so the backfill
        // sweep can still register orphan files left over from before the
        // cloud flip. The walk is a pure FS operation; the active-disk tag
        // on each new registry row is hard-coded `local` (these files do
        // live on local until the operator runs the migrate step).
        try {
            $disk = Storage::disk('public');
            if (method_exists($disk, 'path')) {
                $path = (string) $disk->path('');
                $trimmed = rtrim($path, '/\\');
                if ($trimmed !== '' && is_dir($trimmed)) {
                    return $trimmed;
                }
            }
        } catch (\Throwable) {
            // fall through
        }

        $fallback = public_path('user-uploads');

        return is_string($fallback) && $fallback !== '' ? rtrim($fallback, '/\\') : null;
    }

    /**
     * Cheap mime guess from the file extension. Avoids a `file` syscall on
     * every entry — the registry's `mime_type` column is informational, not
     * load-bearing. Real uploads route through MediaStorage::put which
     * captures the client mime; this command is for legacy backfill only.
     */
    private static function guessMime(string $absolutePath): ?string
    {
        try {
            $info = @getimagesize($absolutePath);
            if (is_array($info) && isset($info['mime'])) {
                return (string) $info['mime'];
            }
        } catch (\Throwable) {
            // fall through
        }

        $ext = strtolower(pathinfo($absolutePath, PATHINFO_EXTENSION));

        return match ($ext) {
            'png' => 'image/png',
            'jpg', 'jpeg' => 'image/jpeg',
            'gif' => 'image/gif',
            'webp' => 'image/webp',
            'svg' => 'image/svg+xml',
            'json' => 'application/json',
            'zip' => 'application/zip',
            default => null,
        };
    }
}
