<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\Artwork;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\Uploads\UploadDerivativesService;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Throwable;

final class RepairArtworkThumbnailsCommand extends Command
{
    private const SOURCE_IMAGE_EXTENSIONS = [
        'avif',
        'bmp',
        'gif',
        'jpg',
        'jpeg',
        'png',
        'tif',
        'tiff',
        'webp',
    ];

    protected $signature = 'artworks:repair-missing-thumbnails
        {--id= : Repair only this artwork ID}
        {--limit= : Stop after processing this many artworks}
        {--chunk=200 : Number of artworks to scan per batch}
        {--variant=* : Specific thumbnail variants to repair (defaults to all configured derivatives)}
        {--only-missing-flagged : Scan only artworks already marked with has_missing_thumbnails=1}
        {--csv= : Optional path to write a CSV report}
        {--force : Regenerate the selected variants even when they already exist}
        {--dry-run : Report repairs without writing files}';

    protected $description = 'Scan artworks from newest to oldest, detect missing CDN thumbnails, and rebuild only the missing derivatives from local source files.';

    public function handle(
        UploadStorageService $storage,
        UploadDerivativesService $derivatives,
        ArtworkFileRepository $artworkFiles,
        ArtworkOriginalFileLocator $locator,
        ArtworkCdnPurgeService $cdnPurge,
    ): int {
        $artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
        $limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
        $chunkSize = max(1, min((int) $this->option('chunk'), 1000));
        $onlyMissingFlagged = (bool) $this->option('only-missing-flagged');
        $csvPath = trim((string) $this->option('csv'));
        $force = (bool) $this->option('force');
        $dryRun = (bool) $this->option('dry-run');

        $allVariants = $this->resolveConfiguredVariants();
        $selectedVariants = $this->resolveSelectedVariants($allVariants);
        if ($selectedVariants === []) {
            return self::FAILURE;
        }

        $auditColumnsAvailable = Schema::hasColumns('artworks', [
            'has_missing_thumbnails',
            'missing_thumbnail_variants_json',
            'thumbnails_checked_at',
        ]);

        if ($onlyMissingFlagged && ! $auditColumnsAvailable) {
            $this->error('The --only-missing-flagged option requires thumbnail audit columns on the artworks table.');

            return self::FAILURE;
        }

        $diskName = $storage->objectDiskName();
        $disk = Storage::disk($diskName);
        $csvHandle = $this->openCsvHandle($csvPath);

        $baseQuery = $this->baseQuery($onlyMissingFlagged);
        $totalCandidates = $this->resolveTotalCandidates($baseQuery, $artworkId, $limit);
        $progressBar = $totalCandidates > 0 ? $this->output->createProgressBar($totalCandidates) : null;

        $this->info(sprintf(
            'Starting thumbnail repair. order=id_desc include_trashed=yes disk=%s variants=%s chunk=%d limit=%s flagged_only=%s force=%s dry_run=%s csv=%s',
            $diskName,
            implode(',', $selectedVariants),
            $chunkSize,
            $limit !== null ? (string) $limit : 'all',
            $onlyMissingFlagged ? 'yes' : 'no',
            $force ? 'yes' : 'no',
            $dryRun ? 'yes' : 'no',
            $csvPath !== '' ? $csvPath : 'off',
        ));

        if ($progressBar !== null) {
            $progressBar->start();
        }

        $processed = 0;
        $healthy = 0;
        $planned = 0;
        $repaired = 0;
        $failed = 0;
        $lastSeenId = null;

        try {
            do {
                $artworks = $this->nextChunk($baseQuery, $artworkId, $chunkSize, $lastSeenId);
                if ($artworks->isEmpty()) {
                    break;
                }

                foreach ($artworks as $artwork) {
                    if ($limit !== null && $processed >= $limit) {
                        break 2;
                    }

                    try {
                        $targetVariants = $force
                            ? $selectedVariants
                            : $this->resolveMissingVariants($artwork, $selectedVariants, $storage, $disk);

                        if ($targetVariants === []) {
                            $healthy++;
                            $processed++;
                            $this->writeCsvRow($csvHandle, [
                                'artwork_id' => (int) $artwork->id,
                                'status' => 'healthy',
                                'variants' => '',
                                'source_file' => '',
                                'message' => '',
                            ]);
                            $progressBar?->advance();
                            continue;
                        }

                        $sourcePath = $this->resolveLocalSourcePath($artwork, $locator);
                        if ($sourcePath === '') {
                            throw new \RuntimeException('No local original source file was found in the configured artwork roots.');
                        }

                        if ($dryRun) {
                            $planned++;
                            $this->line(sprintf(
                                'Artwork %d would repair thumbnails: %s',
                                (int) $artwork->id,
                                implode(',', $targetVariants),
                            ));
                            $this->line('  source_file: ' . $sourcePath);
                            $this->writeCsvRow($csvHandle, [
                                'artwork_id' => (int) $artwork->id,
                                'status' => 'planned',
                                'variants' => implode(',', $targetVariants),
                                'source_file' => $sourcePath,
                                'message' => '',
                            ]);
                            $processed++;
                            $progressBar?->advance();
                            continue;
                        }

                        $assets = $derivatives->generateSelectedPublicDerivatives($sourcePath, (string) $artwork->hash, $targetVariants);
                        if ($assets === []) {
                            throw new \RuntimeException('No thumbnail assets were generated for the requested variants.');
                        }

                        DB::transaction(function () use ($artwork, $assets, $artworkFiles, $storage, $disk, $allVariants, $auditColumnsAvailable): void {
                            foreach ($assets as $variant => $asset) {
                                $artworkFiles->upsert((int) $artwork->id, (string) $variant, $asset['path'], $asset['mime'], $asset['size']);
                            }

                            $update = [
                                'thumb_ext' => 'webp',
                            ];

                            if ($auditColumnsAvailable) {
                                $remainingMissing = $this->resolveMissingVariants($artwork, $allVariants, $storage, $disk);
                                $update['has_missing_thumbnails'] = $remainingMissing !== [];
                                $update['missing_thumbnail_variants_json'] = $remainingMissing === []
                                    ? null
                                    : json_encode(array_values($remainingMissing), JSON_UNESCAPED_SLASHES);
                                $update['thumbnails_checked_at'] = now();
                            }

                            Artwork::query()->withTrashed()->whereKey($artwork->id)->update($update);
                        });

                        $cdnPurge->purgeArtworkObjectPaths(array_map(
                            static fn (array $asset): string => (string) $asset['path'],
                            array_values($assets),
                        ), [
                            'artwork_id' => (int) $artwork->id,
                            'reason' => 'thumbnail_repair',
                        ]);

                        $repaired++;
                        $this->info(sprintf(
                            'Artwork %d repaired thumbnails: %s',
                            (int) $artwork->id,
                            implode(',', array_keys($assets)),
                        ));
                        $this->writeCsvRow($csvHandle, [
                            'artwork_id' => (int) $artwork->id,
                            'status' => 'repaired',
                            'variants' => implode(',', array_keys($assets)),
                            'source_file' => $sourcePath,
                            'message' => '',
                        ]);
                    } catch (Throwable $exception) {
                        $failed++;
                        $this->warn(sprintf('Artwork %d repair failed: %s', (int) $artwork->id, $exception->getMessage()));
                        $this->writeCsvRow($csvHandle, [
                            'artwork_id' => (int) $artwork->id,
                            'status' => 'failed',
                            'variants' => isset($targetVariants) && is_array($targetVariants) ? implode(',', $targetVariants) : '',
                            'source_file' => isset($sourcePath) ? (string) $sourcePath : '',
                            'message' => $exception->getMessage(),
                        ]);
                    }

                    $processed++;
                    $progressBar?->advance();
                }

                $lastSeenId = (int) $artworks->last()->id;
            } while (true);
        } finally {
            if ($progressBar !== null) {
                $progressBar->finish();
                $this->newLine(2);
            }

            if (is_resource($csvHandle)) {
                fclose($csvHandle);
            }
        }

        $this->info(sprintf(
            'Thumbnail repair complete. processed=%d healthy=%d planned=%d repaired=%d failed=%d',
            $processed,
            $healthy,
            $planned,
            $repaired,
            $failed,
        ));

        return $failed > 0 ? self::FAILURE : self::SUCCESS;
    }

    /**
     * @return Collection<int, Artwork>
     */
    private function nextChunk(mixed $baseQuery, ?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
    {
        $query = clone $baseQuery;

        if ($artworkId !== null) {
            $query->whereKey($artworkId);
        } elseif ($lastSeenId !== null) {
            $query->where('id', '<', $lastSeenId);
        }

        return $query->limit($chunkSize)->get();
    }

    private function baseQuery(bool $onlyMissingFlagged): mixed
    {
        $query = Artwork::query()
            ->withTrashed()
            ->select(['id', 'slug', 'hash', 'file_path', 'file_ext', 'thumb_ext'])
            ->whereNotNull('hash')
            ->where('hash', '!=', '')
            ->orderByDesc('id');

        if ($onlyMissingFlagged) {
            $query->where('has_missing_thumbnails', true);
        }

        return $query;
    }

    private function resolveTotalCandidates(mixed $baseQuery, ?int $artworkId, ?int $limit): int
    {
        $countQuery = clone $baseQuery;

        if ($artworkId !== null) {
            $countQuery->whereKey($artworkId);
        }

        $count = (int) $countQuery->count();
        if ($limit !== null) {
            return min($count, $limit);
        }

        return $count;
    }

    /**
     * @return list<string>
     */
    private function resolveConfiguredVariants(): array
    {
        return array_values(array_filter(array_map(
            static fn ($variant): string => strtolower(trim((string) $variant)),
            array_keys((array) config('uploads.derivatives', [])),
        )));
    }

    /**
     * @param  list<string>  $configuredVariants
     * @return list<string>
     */
    private function resolveSelectedVariants(array $configuredVariants): array
    {
        if ($configuredVariants === []) {
            $this->error('No thumbnail variants are configured. Check uploads.derivatives.');

            return [];
        }

        $requested = (array) $this->option('variant');
        if ($requested === []) {
            return $configuredVariants;
        }

        $normalizedRequested = array_values(array_unique(array_filter(array_map(
            static fn ($variant): string => strtolower(trim((string) $variant)),
            $requested,
        ))));

        $invalid = array_values(array_diff($normalizedRequested, $configuredVariants));
        if ($invalid !== []) {
            $this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
            $this->line('Configured variants: ' . implode(', ', $configuredVariants));

            return [];
        }

        return $normalizedRequested;
    }

    /**
     * @param  list<string>  $variants
     * @return list<string>
     */
    private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
    {
        $hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
        if ($hash === '') {
            return $variants;
        }

        $missing = [];
        foreach ($variants as $variant) {
            $objectPath = $storage->objectPathForVariant($variant, $hash, $hash . '.webp');
            if (! $disk->exists($objectPath)) {
                $missing[] = $variant;
            }
        }

        return $missing;
    }

    private function resolveLocalSourcePath(Artwork $artwork, ArtworkOriginalFileLocator $locator): string
    {
        $hash = strtolower((string) ($artwork->hash ?? ''));
        if (! $this->isValidHash($hash)) {
            return '';
        }

        $preferred = $locator->resolveLocalPath($artwork);
        if ($this->isUsableSourceFile($preferred)) {
            return $preferred;
        }

        foreach ($this->candidateOriginalRoots() as $root) {
            $candidatePath = $this->findNonZipSourceInRoot($root, $hash);
            if ($candidatePath !== '') {
                return $candidatePath;
            }
        }

        return '';
    }

    /**
     * @return list<string>
     */
    private function candidateOriginalRoots(): array
    {
        $roots = [
            trim((string) config('uploads.local_originals_root', '')),
            trim((string) config('uploads.readonly_backup_originals_root', '')),
        ];

        $normalizedRoots = [];

        foreach ($roots as $root) {
            if ($root === '') {
                continue;
            }

            $normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
            if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) {
                continue;
            }

            $normalizedRoots[] = $normalizedRoot;
        }

        return $normalizedRoots;
    }

    private function findNonZipSourceInRoot(string $root, string $hash): string
    {
        $directory = $root
            . DIRECTORY_SEPARATOR . substr($hash, 0, 2)
            . DIRECTORY_SEPARATOR . substr($hash, 2, 2);

        if (! File::isDirectory($directory)) {
            return '';
        }

        $matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*');
        if (! is_array($matches)) {
            return '';
        }

        foreach ($matches as $path) {
            if ($this->isUsableSourceFile($path)) {
                return $path;
            }
        }

        return '';
    }

    private function isUsableSourceFile(string $path): bool
    {
        if ($path === '' || ! File::isFile($path)) {
            return false;
        }

        $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
        if ($extension === '' || ! in_array($extension, self::SOURCE_IMAGE_EXTENSIONS, true)) {
            return false;
        }

        $mime = strtolower((string) (File::mimeType($path) ?? ''));

        return str_starts_with($mime, 'image/');
    }

    private function isValidHash(string $hash): bool
    {
        return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
    }

    /**
     * @return resource|null
     */
    private function openCsvHandle(string $csvPath)
    {
        if ($csvPath === '') {
            return null;
        }

        File::ensureDirectoryExists(dirname($csvPath));
        $handle = fopen($csvPath, 'wb');
        if (! is_resource($handle)) {
            throw new \RuntimeException('Unable to open CSV output path for writing: ' . $csvPath);
        }

        fputcsv($handle, ['artwork_id', 'status', 'variants', 'source_file', 'message']);

        return $handle;
    }

    /**
     * @param resource|null $csvHandle
     * @param array<string, scalar|null> $row
     */
    private function writeCsvRow($csvHandle, array $row): void
    {
        if (! is_resource($csvHandle)) {
            return;
        }

        fputcsv($csvHandle, [
            $row['artwork_id'] ?? '',
            $row['status'] ?? '',
            $row['variants'] ?? '',
            $row['source_file'] ?? '',
            $row['message'] ?? '',
        ]);
    }
}