<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\Artwork;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use ZipArchive;

final class ZipUnsupportedArtworkOriginalsCommand extends Command
{
    protected $signature = 'artworks:zip-unsupported-originals
        {--artwork-id= : Process only this artwork ID}
        {--id= : Process only this artwork ID}
        {--limit= : Stop after processing this many artworks}
        {--chunk=200 : Number of artworks to scan per batch}
        {--force : Rebuild the zip even when the artwork currently points at a supported extension or an existing zip}
        {--delete-original-object : Delete the previous original object from object storage after repointing the artwork}
        {--dry-run : Report candidate artworks without writing files or updating metadata}';

    protected $description = 'Wrap artwork originals with unsupported file extensions into zip archives and update artwork metadata.';

    private const ZIP_MIME = 'application/zip';

    /**
     * Extensions that can stay as-is because they are already images or well-known archives.
     *
     * @var list<string>
     */
    private const SUPPORTED_EXTENSIONS = [
        'jpg',
        'jpeg',
        'png',
        'gif',
        'webp',
        'bmp',
        'tif',
        'tiff',
        'svg',
        'avif',
        'heic',
        'heif',
        'ico',
        'jfif',
        'zip',
        'rar',
        '7z',
        '7zip',
        'tar',
        'gz',
        'tgz',
        'bz2',
        'xz',
    ];

    public function handle(ArtworkOriginalFileLocator $locator, UploadStorageService $storage): int
    {
        $artworkId = $this->resolveArtworkIdOption();
        $limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
        $chunkSize = max(1, min((int) $this->option('chunk'), 1000));
        $force = (bool) $this->option('force');
        $deleteOriginalObject = (bool) $this->option('delete-original-object');
        $dryRun = (bool) $this->option('dry-run');

        $this->info(sprintf(
            'Starting unsupported artwork original zip pass. chunk=%d limit=%s dry_run=%s force=%s delete_original_object=%s',
            $chunkSize,
            $limit !== null ? (string) $limit : 'all',
            $dryRun ? 'yes' : 'no',
            $force ? 'yes' : 'no',
            $deleteOriginalObject ? 'yes' : 'no',
        ));

        $query = Artwork::query()
            ->withTrashed()
            ->select(['id', 'title', 'slug', 'file_name', 'file_path', 'hash', 'file_ext', 'mime_type', 'file_size'])
            ->orderBy('id');

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

        $processed = 0;
        $skippedSupported = 0;
        $skippedUnresolved = 0;
        $skippedMissingSource = 0;
        $wouldFixMetadata = 0;
        $wouldConvert = 0;
        $metadataFixed = 0;
        $converted = 0;
        $failed = 0;

        $query->chunkById($chunkSize, function ($artworks) use (
            $locator,
            $storage,
            $limit,
            $force,
            $deleteOriginalObject,
            $dryRun,
            &$processed,
            &$skippedSupported,
            &$skippedUnresolved,
            &$skippedMissingSource,
            &$wouldFixMetadata,
            &$wouldConvert,
            &$metadataFixed,
            &$converted,
            &$failed,
        ) {
            foreach ($artworks as $artwork) {
                if ($limit !== null && $processed >= $limit) {
                    return false;
                }

                try {
                    $result = $this->processArtwork($artwork, $locator, $storage, $dryRun, $deleteOriginalObject, $force);

                    match ($result) {
                        'skipped_supported' => $skippedSupported++,
                        'skipped_unresolved' => $skippedUnresolved++,
                        'skipped_missing_source' => $skippedMissingSource++,
                        'would_fix_metadata' => $wouldFixMetadata++,
                        'would_convert' => $wouldConvert++,
                        'fixed_metadata' => $metadataFixed++,
                        'converted' => $converted++,
                        default => null,
                    };
                } catch (Throwable $exception) {
                    $failed++;
                    $this->warn(sprintf('Artwork %d failed: %s', (int) $artwork->id, $exception->getMessage()));
                }

                $processed++;
            }

            return true;
        });

        $this->info(sprintf(
            'Unsupported artwork original zip pass complete. processed=%d skipped_supported=%d skipped_unresolved=%d skipped_missing_source=%d would_fix_metadata=%d would_convert=%d metadata_fixed=%d converted=%d failed=%d',
            $processed,
            $skippedSupported,
            $skippedUnresolved,
            $skippedMissingSource,
            $wouldFixMetadata,
            $wouldConvert,
            $metadataFixed,
            $converted,
            $failed,
        ));

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

    private function resolveArtworkIdOption(): ?int
    {
        $artworkId = $this->option('artwork-id');
        if ($artworkId !== null) {
            return max(1, (int) $artworkId);
        }

        $legacyId = $this->option('id');
        if ($legacyId !== null) {
            return max(1, (int) $legacyId);
        }

        return null;
    }

    private function processArtwork(Artwork $artwork, ArtworkOriginalFileLocator $locator, UploadStorageService $storage, bool $dryRun, bool $deleteOriginalObject, bool $force): string
    {
        $metadataExtension = $this->normalizeExtension((string) $artwork->file_ext);
        if (! $force && $this->isSupportedExtension($metadataExtension)) {
            return 'skipped_supported';
        }

        $resolvedLocalPath = $locator->resolveLocalPath($artwork);
        $resolvedObjectPath = $locator->resolveObjectPath($artwork);

        $hash = strtolower(trim((string) $artwork->hash));
        if (! $this->isValidHash($hash)) {
            $this->line(sprintf('Artwork %d skipped: invalid or missing hash.', (int) $artwork->id));
            $this->writeArtworkContext($artwork);

            return 'skipped_unresolved';
        }

        $targetLocalPath = $storage->localOriginalPath($hash, $hash . '.zip');
        $targetObjectPath = $storage->objectPathForVariant('original', $hash, $hash . '.zip');
        $source = $this->prepareSourceFile($resolvedLocalPath, $resolvedObjectPath, $storage, $hash, $force);

        if ($source === null) {
            $this->line(sprintf('Artwork %d skipped: source file not found.', (int) $artwork->id));
            $this->writeArtworkContext($artwork);
            $this->writeVerbosePaths($resolvedLocalPath, $targetLocalPath, $resolvedObjectPath, $targetObjectPath);

            return 'skipped_missing_source';
        }

        $sourceExtension = $this->detectSourceExtension($source['path'], $resolvedObjectPath);

        if (! $force && $this->isSupportedExtension($sourceExtension)) {
            if ($dryRun) {
                $this->line(sprintf(
                    'Artwork %d would fix metadata only: file_ext=%s -> %s',
                    (int) $artwork->id,
                    $metadataExtension !== '' ? $metadataExtension : '(empty)',
                    $sourceExtension,
                ));
                $this->writeArtworkContext($artwork);

                return 'would_fix_metadata';
            }

            $size = $this->detectFileSize($source['path'], $artwork->file_size);
            $mime = $this->detectMimeType($source['path'], $artwork->mime_type, $sourceExtension);
            $updatedFileName = $this->resolveFileNameWithExtension((string) ($artwork->file_name ?? ''), $sourceExtension);

            $this->persistArtworkMetadata((int) $artwork->id, $resolvedObjectPath !== '' ? $resolvedObjectPath : null, $sourceExtension, $mime, $size, $updatedFileName);
            $this->info(sprintf(
                'Artwork %d metadata fixed: file_ext=%s -> %s',
                (int) $artwork->id,
                $metadataExtension !== '' ? $metadataExtension : '(empty)',
                $sourceExtension,
            ));
            $this->writeArtworkContext($artwork);

            return 'fixed_metadata';
        }

        if ($force && $this->isSupportedExtension($sourceExtension)) {
            $this->line(sprintf(
                'Artwork %d skipped: force requested but no non-archive source was found.',
                (int) $artwork->id,
            ));
            $this->writeArtworkContext($artwork);
            $this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath);

            return 'skipped_supported';
        }

        try {
            if ($dryRun) {
                $this->line(sprintf(
                    'Artwork %d would be archived: file_ext=%s -> zip',
                    (int) $artwork->id,
                    $metadataExtension !== '' ? $metadataExtension : '(empty)',
                ));
                $this->writeArtworkContext($artwork);
                $this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath);

                return 'would_convert';
            }

            $archiveEntryName = $this->resolveArchiveEntryName($artwork, $metadataExtension, $sourceExtension);
            $temporaryZipPath = $this->createZipArchive($source['path'], $archiveEntryName);

            try {
                $this->publishZipArchive($temporaryZipPath, $targetLocalPath, $targetObjectPath, $storage);
                $size = (int) (filesize($targetLocalPath) ?: 0);
                $updatedFileName = $this->resolveFileNameWithExtension((string) ($artwork->file_name ?? ''), 'zip');

                $this->persistArtworkMetadata((int) $artwork->id, $targetObjectPath, 'zip', self::ZIP_MIME, $size, $updatedFileName);
                $this->deleteLegacySource($resolvedLocalPath, $targetLocalPath, $resolvedObjectPath, $targetObjectPath, $storage, $deleteOriginalObject);
            } catch (Throwable $exception) {
                $this->cleanupTargetArtifacts($targetLocalPath, $targetObjectPath, $storage);

                throw $exception;
            } finally {
                File::delete($temporaryZipPath);
            }

            $this->info(sprintf(
                'Artwork %d archived to zip: file_ext=%s -> zip',
                (int) $artwork->id,
                $metadataExtension !== '' ? $metadataExtension : '(empty)',
            ));
            $this->writeArtworkContext($artwork);
            $deletedOldObjectPath = $deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath
                ? $resolvedObjectPath
                : '';
            $keptOldObjectPath = ! $deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath
                ? $resolvedObjectPath
                : '';

            $this->writeVerbosePaths($source['path'], $targetLocalPath, $resolvedObjectPath, $targetObjectPath, $deletedOldObjectPath, $keptOldObjectPath);

            return 'converted';
        } finally {
            if ($source['temporary']) {
                File::delete($source['path']);
            }
        }
    }

    /**
     * @return array{path: string, temporary: bool}|null
     */
    private function prepareSourceFile(string $resolvedLocalPath, string $resolvedObjectPath, UploadStorageService $storage, string $hash, bool $force): ?array
    {
        if ($force) {
            $forcedSourcePath = $this->resolveForceSourcePath($hash);
            if ($forcedSourcePath !== '') {
                return [
                    'path' => $forcedSourcePath,
                    'temporary' => false,
                ];
            }
        }

        if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
            return [
                'path' => $resolvedLocalPath,
                'temporary' => false,
            ];
        }

        $backupSourcePath = $this->resolveReadonlyBackupSourcePath($resolvedObjectPath);
        if ($backupSourcePath !== '' && File::isFile($backupSourcePath)) {
            return [
                'path' => $backupSourcePath,
                'temporary' => false,
            ];
        }

        if ($resolvedObjectPath === '') {
            return null;
        }

        $disk = Storage::disk($storage->objectDiskName());
        if (! $disk->exists($resolvedObjectPath)) {
            return null;
        }

        $stream = $disk->readStream($resolvedObjectPath);
        if (! is_resource($stream)) {
            throw new RuntimeException('Unable to open source object stream.');
        }

        $temporaryPath = tempnam(sys_get_temp_dir(), 'art-src-');
        if ($temporaryPath === false) {
            fclose($stream);

            throw new RuntimeException('Unable to allocate a temporary source file.');
        }

        $target = fopen($temporaryPath, 'wb');
        if (! is_resource($target)) {
            fclose($stream);
            File::delete($temporaryPath);

            throw new RuntimeException('Unable to open a temporary source file for writing.');
        }

        try {
            $copied = stream_copy_to_stream($stream, $target);
        } finally {
            fclose($stream);
            fclose($target);
        }

        if ($copied === false || $copied <= 0 || ! File::isFile($temporaryPath)) {
            File::delete($temporaryPath);

            return null;
        }

        return [
            'path' => $temporaryPath,
            'temporary' => true,
        ];
    }

    private function createZipArchive(string $sourcePath, string $archiveEntryName): string
    {
        $temporaryPath = tempnam(sys_get_temp_dir(), 'art-zip-');
        if ($temporaryPath === false) {
            throw new RuntimeException('Unable to allocate a temporary zip file.');
        }

        $zip = new ZipArchive();
        $opened = $zip->open($temporaryPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
        if ($opened !== true) {
            File::delete($temporaryPath);

            throw new RuntimeException('Unable to create zip archive.');
        }

        try {
            if (! $zip->addFile($sourcePath, $archiveEntryName)) {
                throw new RuntimeException('Unable to add artwork original to zip archive.');
            }
        } finally {
            $zip->close();
        }

        if (! File::isFile($temporaryPath)) {
            throw new RuntimeException('Zip archive was not written to disk.');
        }

        return $temporaryPath;
    }

    private function publishZipArchive(string $temporaryZipPath, string $targetLocalPath, string $targetObjectPath, UploadStorageService $storage): void
    {
        File::ensureDirectoryExists(dirname($targetLocalPath));
        File::delete($targetLocalPath);

        if (! File::copy($temporaryZipPath, $targetLocalPath)) {
            throw new RuntimeException('Unable to write local zip archive.');
        }

        $storage->putObjectFromPath($targetLocalPath, $targetObjectPath, self::ZIP_MIME);
    }

    private function cleanupTargetArtifacts(string $targetLocalPath, string $targetObjectPath, UploadStorageService $storage): void
    {
        $storage->deleteLocalFile($targetLocalPath);
        $storage->deleteObject($targetObjectPath);
    }

    private function deleteLegacySource(string $resolvedLocalPath, string $targetLocalPath, string $resolvedObjectPath, string $targetObjectPath, UploadStorageService $storage, bool $deleteOriginalObject): void
    {
        if ($resolvedLocalPath !== '' && $this->samePath($resolvedLocalPath, $targetLocalPath) === false) {
            $storage->deleteLocalFile($resolvedLocalPath);
        }

        if ($deleteOriginalObject && $resolvedObjectPath !== '' && $resolvedObjectPath !== $targetObjectPath) {
            $storage->deleteObject($resolvedObjectPath);
        }
    }

    private function persistArtworkMetadata(int $artworkId, ?string $filePath, string $fileExt, string $mimeType, int $fileSize, ?string $fileName = null): void
    {
        $values = [
            'file_path' => $filePath,
            'file_ext' => $fileExt,
            'mime_type' => $mimeType,
            'file_size' => max(0, $fileSize),
            'updated_at' => now(),
        ];

        if ($fileName !== null && trim($fileName) !== '') {
            $values['file_name'] = $fileName;
        }

        DB::table('artworks')
            ->where('id', $artworkId)
            ->update($values);
    }

    private function resolveArchiveEntryName(Artwork $artwork, string $metadataExtension, string $sourceExtension): string
    {
        $candidate = trim((string) pathinfo((string) $artwork->file_name, PATHINFO_FILENAME));
        $candidate = str_replace(['/', '\\'], '-', $candidate);
        $candidate = trim((string) preg_replace('/[\x00-\x1F\x7F]/', '', $candidate));
        $candidate = trim($candidate, ". \t\n\r\0\x0B");

        $extension = $sourceExtension !== '' ? $sourceExtension : $metadataExtension;

        if ($candidate !== '' && $candidate !== '.' && $candidate !== '..') {
            return $extension !== ''
                ? $candidate . '.' . $extension
                : $candidate;
        }

        if ($extension !== '') {
            return (string) $artwork->hash . '.' . $extension;
        }

        return ((string) $artwork->hash !== '' ? (string) $artwork->hash : 'artwork') . '.bin';
    }

    private function detectSourceExtension(string $resolvedLocalPath, string $resolvedObjectPath): string
    {
        $path = $resolvedLocalPath !== '' ? $resolvedLocalPath : $resolvedObjectPath;

        return $this->normalizeExtension((string) pathinfo($path, PATHINFO_EXTENSION));
    }

    private function detectMimeType(string $resolvedLocalPath, ?string $fallbackMimeType, string $extension): string
    {
        if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
            $detected = File::mimeType($resolvedLocalPath);
            if (is_string($detected) && $detected !== '') {
                return $detected;
            }
        }

        $fallback = trim((string) $fallbackMimeType);
        if ($fallback !== '') {
            return $fallback;
        }

        return match ($extension) {
            'jpg', 'jpeg' => 'image/jpeg',
            'png' => 'image/png',
            'gif' => 'image/gif',
            'webp' => 'image/webp',
            'bmp' => 'image/bmp',
            'tif', 'tiff' => 'image/tiff',
            'svg' => 'image/svg+xml',
            'avif' => 'image/avif',
            'heic' => 'image/heic',
            'heif' => 'image/heif',
            'ico' => 'image/x-icon',
            'zip' => self::ZIP_MIME,
            'rar' => 'application/vnd.rar',
            '7z', '7zip' => 'application/x-7z-compressed',
            'tar' => 'application/x-tar',
            'gz', 'tgz' => 'application/gzip',
            'bz2' => 'application/x-bzip2',
            'xz' => 'application/x-xz',
            default => 'application/octet-stream',
        };
    }

    private function detectFileSize(string $resolvedLocalPath, ?int $fallbackSize): int
    {
        if ($resolvedLocalPath !== '' && File::isFile($resolvedLocalPath)) {
            $size = filesize($resolvedLocalPath);
            if ($size !== false) {
                return (int) $size;
            }
        }

        return max(0, (int) $fallbackSize);
    }

    private function resolveFileNameWithExtension(string $fileName, string $extension): string
    {
        $name = trim($fileName);
        $name = str_replace(['/', '\\'], '-', $name);
        $name = preg_replace('/[\x00-\x1F\x7F]/', '', $name) ?? '';
        $name = preg_replace('/\s+/', ' ', $name) ?? '';
        $name = trim((string) $name, ". \t\n\r\0\x0B");

        $baseName = trim((string) pathinfo($name, PATHINFO_FILENAME), ". \t\n\r\0\x0B");
        if ($baseName === '') {
            $baseName = 'artwork';
        }

        $normalizedExtension = $this->normalizeExtension($extension);

        return $normalizedExtension !== ''
            ? $baseName . '.' . $normalizedExtension
            : $baseName;
    }

    private function normalizeExtension(string $extension): string
    {
        return strtolower(ltrim(trim($extension), '.'));
    }

    private function isSupportedExtension(string $extension): bool
    {
        return $extension !== '' && in_array($extension, self::SUPPORTED_EXTENSIONS, true);
    }

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

    private function samePath(string $left, string $right): bool
    {
        $normalizedLeft = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $left);
        $normalizedRight = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $right);

        return $normalizedLeft === $normalizedRight;
    }

    private function resolveForceSourcePath(string $hash): string
    {
        if (! $this->isValidHash($hash)) {
            return '';
        }

        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 (! is_string($path) || ! File::isFile($path)) {
                continue;
            }

            $extension = $this->normalizeExtension((string) pathinfo($path, PATHINFO_EXTENSION));
            if ($extension === '' || $extension === 'zip') {
                continue;
            }

            return $path;
        }

        return '';
    }

    private function resolveReadonlyBackupSourcePath(string $resolvedObjectPath): string
    {
        $root = trim((string) config('uploads.readonly_backup_originals_root', ''));
        if ($root === '' || $resolvedObjectPath === '') {
            return '';
        }

        $normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
        $filename = (string) pathinfo($resolvedObjectPath, PATHINFO_BASENAME);
        $hash = strtolower((string) pathinfo($filename, PATHINFO_FILENAME));
        $extension = $this->normalizeExtension((string) pathinfo($filename, PATHINFO_EXTENSION));

        if (! $this->isValidHash($hash) || $extension === '') {
            return '';
        }

        return $normalizedRoot
            . DIRECTORY_SEPARATOR . substr($hash, 0, 2)
            . DIRECTORY_SEPARATOR . substr($hash, 2, 2)
            . DIRECTORY_SEPARATOR . $hash . '.' . $extension;
    }

    private function writeVerbosePaths(
        string $sourcePath,
        string $targetLocalPath,
        string $sourceObjectPath = '',
        string $targetObjectPath = '',
        string $deletedOldObjectPath = '',
        string $keptOldObjectPath = '',
    ): void
    {
        $displaySourcePath = $sourcePath !== '' ? $sourcePath : '(unresolved local source path)';

        $this->line('  source_file: ' . $displaySourcePath);
        if ($sourceObjectPath !== '') {
            $this->line('  source_object: ' . $sourceObjectPath, null, OutputInterface::VERBOSITY_VERBOSE);
        }
        $this->line('  new_zip_file: ' . $targetLocalPath);
        if ($targetObjectPath !== '') {
            $this->line('  new_zip_object: ' . $targetObjectPath);
        }
        if ($deletedOldObjectPath !== '') {
            $this->line('  deleted_old_object: ' . $deletedOldObjectPath, null, OutputInterface::VERBOSITY_VERBOSE);
        }
        if ($keptOldObjectPath !== '') {
            $this->line('  kept_original_object: ' . $keptOldObjectPath);
        }
    }

    private function writeArtworkContext(Artwork $artwork): void
    {
        $this->line('  title: ' . trim((string) ($artwork->title ?? '')));
        $this->line('  artwork_url: ' . route('art.show', [
            'id' => (int) $artwork->id,
            'slug' => (string) ($artwork->slug ?? ''),
        ]));
    }
}